Initial commit

This commit is contained in:
Kristof committed 2026-10-14 12:29:29 +02:00
commit 536ff43ce4
158 files changed
+30355

No files matched your search

+193
View File
@@ -0,0 +1,193 @@
"""
Smartschool › SGR groepen: de subgroepen van één hoofdgroep (standaard 'groepen GO-Antwerpen') met hun huidige leden.
Werking (Smartschool-webservices V3):
1. getAllGroupsAndClasses → de volledige boom van groepen en klassen (XML, meestal base64-gecodeerd)
2. de hoofdgroep zoeken op naam of code, en alle groepen eronder verzamelen (ook dieper geneste)
3. getAllAccountsExtended(code, '0') → de rechtstreekse leden van elke (sub)groep
Het resultaat wordt 10 minuten per school bewaard (knop 'Opnieuw ophalen' om te vernieuwen).
"""
import base64
import json
import threading
import time
import xml.etree.ElementTree as ET
from concurrent.futures import ThreadPoolExecutor
from config import Config
STANDAARD_GROEP = 'groepen GO-Antwerpen'
CACHE_SECONDEN = 10 * 60
ROLLEN = {'1': 'Leerling', '2': 'Leerkracht', '3': 'Directie', '4': 'Andere', '5': 'Andere', '6': 'Andere'}
class SgrFout(Exception):
pass
def _foutcode(raw):
return isinstance(raw, (int, float)) or (isinstance(raw, str) and raw.strip().lstrip('-').isdigit())
def _tekst(raw):
"""Antwoord van Smartschool → tekst (base64 wordt gedecodeerd als dat XML/JSON oplevert)."""
if isinstance(raw, bytes):
raw = raw.decode('utf-8', 'replace')
t = str(raw or '').strip()
if t and t[0] not in '<{[':
try:
d = base64.b64decode(t, validate=False).decode('utf-8', 'replace').strip()
if d[:1] in ('<', '{', '['):
return d
except Exception:
pass
return t
def _kind(el, *namen):
for n in namen:
for c in el:
if c.tag.lower() == n:
return c
return None
def _veld(el, *namen):
c = _kind(el, *namen)
return (c.text or '').strip() if c is not None and c.text else ''
def _groepen_xml(root):
"""XML-boom → [{'naam','code','omschrijving','type','kinderen':[...]}]."""
def groep(el):
details = _kind(el, 'details')
d = details if details is not None else el
kids_el = _kind(el, 'children')
kinderen = [groep(c) for c in (kids_el if kids_el is not None else el) if c.tag.lower() == 'group']
return {'naam': _veld(d, 'name', 'naam'), 'code': _veld(d, 'code'), 'omschrijving': _veld(d, 'desc', 'description'),
'type': _veld(d, 'type'), 'kinderen': kinderen}
if root.tag.lower() == 'group':
return [groep(root)]
return [groep(c) for c in root if c.tag.lower() == 'group']
def _groepen_json(data):
def groep(g):
kids = g.get('children') or g.get('groups') or []
if isinstance(kids, dict):
kids = kids.get('group') or list(kids.values())
if isinstance(kids, dict):
kids = [kids]
d = g.get('details') or g
return {'naam': str(d.get('name') or ''), 'code': str(d.get('code') or ''), 'omschrijving': str(d.get('desc') or ''),
'type': str(d.get('type') or ''), 'kinderen': [groep(k) for k in kids if isinstance(k, dict)]}
if isinstance(data, dict):
data = data.get('groups') or data.get('group') or [data]
if isinstance(data, dict):
data = [data]
return [groep(g) for g in data if isinstance(g, dict)]
def boom(ss):
raw = ss.client.service.getAllGroupsAndClasses(Config.SS_API_PW)
if _foutcode(raw):
raise SgrFout(f"Smartschool weigert de groepenlijst (foutcode {raw}). Heeft het webservice-wachtwoord het recht "
"'getAllGroupsAndClasses'?")
t = _tekst(raw)
try:
return _groepen_xml(ET.fromstring(t)) if t.startswith('<') else _groepen_json(json.loads(t))
except Exception as e:
raise SgrFout(f"Kon de groepenlijst van Smartschool niet lezen: {e}")
def zoek(groepen, naam):
"""Eerste groep (diepte-eerst) waarvan naam of code overeenkomt (hoofdletterongevoelig)."""
n = (naam or '').strip().lower()
for g in groepen:
if g['naam'].lower() == n or g['code'].lower() == n:
return g
hit = zoek(g['kinderen'], naam)
if hit:
return hit
return None
def plat(groep, pad=()):
"""Alle groepen onder `groep` (niet de groep zelf), met hun pad en diepte, in boomvolgorde."""
uit = []
for k in sorted(groep['kinderen'], key=lambda g: g['naam'].lower()):
uit.append({**k, 'pad': list(pad), 'diepte': len(pad)})
uit += plat(k, pad + (k['naam'],))
return uit
def _leden(ss, code, recursief='0'):
raw = ss.client.service.getAllAccountsExtended(Config.SS_API_PW, code, recursief)
if _foutcode(raw):
raise SgrFout(f"foutcode {raw}")
t = _tekst(raw)
if not t:
return []
data = json.loads(t)
if isinstance(data, dict):
data = next((data[k] for k in ('accounts', 'users', 'items') if isinstance(data.get(k), list)), list(data.values()))
uit = []
for a in data or []:
if not isinstance(a, dict):
continue
rol = str(a.get('basisrol') or a.get('basisRol') or '').strip()
uit.append({'voornaam': str(a.get('voornaam') or '').strip(), 'naam': str(a.get('naam') or '').strip(),
'gebruikersnaam': str(a.get('gebruikersnaam') or '').strip(),
'rol': ROLLEN.get(rol, rol), 'email': str(a.get('emailadres') or a.get('email') or '').strip(),
'status': str(a.get('status') or '').strip()})
return sorted(uit, key=lambda x: (x['naam'].lower(), x['voornaam'].lower()))
def overzicht(hoofdgroep=None, vernieuw=False):
"""(hoofdgroep-dict, [subgroepen met 'leden' of 'fout'], opgehaald-tijdstip)."""
from tenancy import huidige_school, in_school
from services.smartschool_api import SmartschoolService
school = huidige_school()
if not school.heeft('SS_API_PLATFORM', 'SS_API_PW'):
raise SgrFout("Smartschool is nog niet ingesteld voor deze school (🔌 Koppelingen › Smartschool).")
hoofdgroep = (hoofdgroep or school.get('SS_SGR_GROEP') or STANDAARD_GROEP).strip()
sleutel = ('sgr_groepen', hoofdgroep.lower())
bewaard = school.cache.get(sleutel)
if bewaard and not vernieuw and time.time() - bewaard[0] < CACHE_SECONDEN:
return bewaard[1], bewaard[2], bewaard[0]
try:
ss = SmartschoolService(Config.SS_API_PLATFORM, Config.SS_API_PW)
except Exception as e:
raise SgrFout(f"Smartschool is niet bereikbaar: {e}")
hoofd = zoek(boom(ss), hoofdgroep)
if not hoofd:
raise SgrFout(f"De groep '{hoofdgroep}' bestaat niet in Smartschool ({Config.SS_API_PLATFORM}).")
groepen = plat(hoofd)
lokaal = threading.local()
def haal(g):
with in_school(school):
if not hasattr(lokaal, 'ss'):
lokaal.ss = SmartschoolService(Config.SS_API_PLATFORM, Config.SS_API_PW)
try:
if not g['code']:
return {**g, 'leden': [], 'fout': 'geen code'}
leden = _leden(lokaal.ss, g['code'])
if not leden and not g['kinderen']:
# Groep zonder subgroepen: rechtstreeks = recursief; sommige platformen geven enkel zo de leden
leden = _leden(lokaal.ss, g['code'], '1')
return {**g, 'leden': leden, 'fout': None}
except Exception as e:
return {**g, 'leden': [], 'fout': str(e)[:200]}
with ThreadPoolExecutor(max_workers=4) as pool:
resultaat = list(pool.map(haal, groepen))
for g in resultaat:
g.pop('kinderen', None)
g['aantal_sub'] = sum(1 for x in resultaat if x['pad'][:len(g['pad']) + 1] == g['pad'] + [g['naam']])
info = {'naam': hoofd['naam'], 'code': hoofd['code'], 'omschrijving': hoofd['omschrijving']}
nu = time.time()
school.cache[sleutel] = (nu, info, resultaat)
return info, resultaat, nu