""" Afwezigheden (o.a. B-codes) uit Smartschool ophalen en bewaren. Werking - Per schooldag wordt `getAbsentsWithInternalNumberByDate` aangeroepen. Smartschool geeft per leerling (internnummer) de code van de voormiddag (am) en namiddag (pm) terug. - Enkel de codes uit config.ini (standaard: B) worden bewaard in de tabel `smartschool_afwezigheden`. Andere codes worden genegeerd. - Eén keer per dag (standaard 06:00) synchroniseert een achtergrondtaak. De laatste 14 dagen worden telkens opnieuw opgehaald, zodat correcties in Smartschool ook in PIVOT terechtkomen. Vereist in Smartschool: Beheer › Webservices › het webservice-wachtwoord moet het recht 'Afwezigheden' hebben. """ import json import sys import threading import time from datetime import date, datetime, timedelta from config import Config from database import execute_query from tenancy import school_singleton, alle_scholen, in_school, met_school HERHAAL_DAGEN = 14 # laatste X dagen altijd opnieuw ophalen DAGDEEL = {'am': 'VM', 'pm': 'NM'} def schooljaar_start(vandaag=None): vandaag = vandaag or date.today() jaar = vandaag.year if vandaag.month >= 9 else vandaag.year - 1 return date(jaar, 9, 1) class AfwezighedenService: _scheduler = None # één achtergrondtaak voor alle scholen def __new__(cls): # één instantie per school (eigen status, lock en Smartschool-client) def init(inst): inst._lock = threading.Lock() inst._client = None inst.status = {'bezig': False, 'gestart': None, 'voortgang': 0, 'totaal': 0, 'laatste_fout': None, 'laatste_resultaat': None} return school_singleton(cls, init) # ---------------- configuratie ---------------- @property def codes(self): return [c.strip().upper() for c in Config.AFWEZIGHEID_CODES.split(',') if c.strip()] @property def laat_codes(self): """Codes die 'te laat' betekenen (Laatkomers). Ze worden samen met de afwezigheidscodes opgehaald.""" return [c.strip().upper() for c in str(Config.LAATKOMER_CODES or '').split(',') if c.strip()] def _ss(self): if self._client is None: from services.smartschool_api import SmartschoolService if not (Config.SS_API_PLATFORM and Config.SS_API_PW): raise RuntimeError("Smartschool is nog niet ingesteld voor deze school " "(platform en webservice-wachtwoord via 🔌 Koppelingen van deze school).") self._client = SmartschoolService(platform=Config.SS_API_PLATFORM, webservice_pwd=Config.SS_API_PW) return self._client # ---------------- database ---------------- @staticmethod def ensure_tables(): execute_query(""" CREATE TABLE IF NOT EXISTS smartschool_afwezigheden ( Datum DATE NOT NULL, Internnummer VARCHAR(32) NOT NULL, Dagdeel CHAR(2) NOT NULL, Code VARCHAR(8) NOT NULL, PRIMARY KEY (Datum, Internnummer, Dagdeel), INDEX idx_intern (Internnummer), INDEX idx_code (Code) )""", commit=True) execute_query(""" CREATE TABLE IF NOT EXISTS smartschool_afwezigheden_sync ( Datum DATE NOT NULL PRIMARY KEY, Opgehaald DATETIME NOT NULL, Aantal INT NOT NULL DEFAULT 0 )""", commit=True) # ---------------- Smartschool ---------------- def fetch_day(self, dag): """Geeft {internnummer: {'VM': code, 'NM': code}} voor één dag (alleen de gezochte codes).""" raw = self._ss().client.service.getAbsentsWithInternalNumberByDate( Config.SS_API_PW, dag.strftime('%Y-%m-%d')) if raw is None or raw == '': return {} if isinstance(raw, (int, float)) or (isinstance(raw, str) and raw.strip().lstrip('-').isdigit()): # Smartschool geeft bij fouten een numerieke foutcode terug raise RuntimeError(f"Smartschool foutcode {raw} (controleer het recht 'Afwezigheden' " "voor het webservice-wachtwoord).") data = json.loads(raw) if isinstance(raw, str) else raw if not isinstance(data, dict): return {} gezocht = set(self.codes) | set(self.laat_codes) result = {} for intern, delen in data.items(): if not isinstance(delen, dict): continue for key, label in DAGDEEL.items(): val = delen.get(key) if isinstance(val, dict): # variant met alias: {'code': 'B', 'alias': ...} val = val.get('code') code = (val or '').strip().upper() if code in gezocht: result.setdefault(str(intern).strip(), {})[label] = code return result def _store_day(self, dag, per_leerling): execute_query("DELETE FROM smartschool_afwezigheden WHERE Datum = %s", (dag,), commit=True) aantal = 0 for intern, delen in per_leerling.items(): for dagdeel, code in delen.items(): execute_query( "INSERT INTO smartschool_afwezigheden (Datum, Internnummer, Dagdeel, Code) " "VALUES (%s, %s, %s, %s)", (dag, intern, dagdeel, code), commit=True) aantal += 1 execute_query( "INSERT INTO smartschool_afwezigheden_sync (Datum, Opgehaald, Aantal) VALUES (%s, NOW(), %s) " "ON DUPLICATE KEY UPDATE Opgehaald = NOW(), Aantal = VALUES(Aantal)", (dag, aantal), commit=True) return aantal # ---------------- synchronisatie ---------------- def dagen_te_synchroniseren(self, vandaag=None, volledig=False): vandaag = vandaag or date.today() start = schooljaar_start(vandaag) alle = [start + timedelta(days=i) for i in range((vandaag - start).days + 1)] schooldagen = [d for d in alle if d.weekday() < 5] # ma–vr if volledig: return schooldagen rows = execute_query("SELECT Datum FROM smartschool_afwezigheden_sync WHERE Datum >= %s", (start,)) gehad = {r['Datum'] for r in rows} grens = vandaag - timedelta(days=HERHAAL_DAGEN) return [d for d in schooldagen if d not in gehad or d >= grens] def sync(self, volledig=False): """Synchroniseert (blokkerend). Geeft (ok, bericht) terug.""" if not self._lock.acquire(blocking=False): return False, "Er loopt al een synchronisatie." try: self.ensure_tables() dagen = self.dagen_te_synchroniseren(volledig=volledig) self.status.update(bezig=True, gestart=datetime.now(), voortgang=0, totaal=len(dagen), laatste_fout=None) gevonden, fouten = 0, 0 for i, dag in enumerate(dagen, 1): try: gevonden += self._store_day(dag, self.fetch_day(dag)) except Exception as e: fouten += 1 self.status['laatste_fout'] = str(e) sys.stderr.write(f"Afwezigheden {dag}: {e}\n") self.status['voortgang'] = i if fouten >= 3 and fouten == i: # eerste 3 dagen allemaal fout → stoppen break if fouten and fouten == self.status['voortgang']: msg = f"Synchronisatie mislukt: {self.status['laatste_fout']}" ok = False else: msg = (f"{len(dagen)} schooldag(en) gecontroleerd, {gevonden} halve dag(en) met code " f"{'/'.join(self.codes)} op die dagen" + (f", {fouten} dag(en) met fouten." if fouten else ".")) ok = True self.status['laatste_resultaat'] = msg return ok, msg finally: self.status['bezig'] = False self._lock.release() def sync_in_background(self, volledig=False): if self.status['bezig']: return False self.status.update(bezig=True, voortgang=0, totaal=0) # meteen tonen op de pagina threading.Thread(target=met_school(self.sync), kwargs={'volledig': volledig}, daemon=True).start() return True def dagelijkse_controle(self, nu=None): """Start de synchronisatie als het sync-uur van deze school voorbij is en er vandaag nog niet gesynchroniseerd werd.""" nu = nu or datetime.now() if nu.hour < Config.AFWEZIGHEID_SYNC_UUR: return self.ensure_tables() rows = execute_query("SELECT MAX(Opgehaald) AS m FROM smartschool_afwezigheden_sync") laatst = rows[0]['m'] if rows else None if not laatst or laatst.date() < nu.date(): print(f"[{Config.SCHOOLNAME_SHORT}] Afwezigheden: dagelijkse synchronisatie gestart.", flush=True) ok, msg = self.sync() print(f"[{Config.SCHOOLNAME_SHORT}] Afwezigheden: {msg}", flush=True) @classmethod def start_scheduler(cls): """Eén achtergrondtaak die elk kwartier alle scholen met een Smartschool-koppeling overloopt.""" if AfwezighedenService._scheduler: return def loop(): time.sleep(30) # app eerst laten opstarten gedaan = {} # school → laatste dag waarop gecontroleerd while True: nu = datetime.now() for school in alle_scholen(): if not school.heeft('SS_API_PLATFORM', 'SS_API_PW') or gedaan.get(school.code) == nu.date(): continue try: with in_school(school): if nu.hour >= Config.AFWEZIGHEID_SYNC_UUR: cls().dagelijkse_controle(nu) gedaan[school.code] = nu.date() except Exception as e: sys.stderr.write(f"[{school.code}] Afwezigheden scheduler: {e}\n") time.sleep(15 * 60) AfwezighedenService._scheduler = threading.Thread(target=loop, daemon=True) AfwezighedenService._scheduler.start() # ---------------- opvragen ---------------- def laatste_sync(self): rows = execute_query("SELECT MAX(Opgehaald) AS m, MAX(Datum) AS d FROM smartschool_afwezigheden_sync") return rows[0] if rows else {'m': None, 'd': None} def overzicht(self, van, tot, code=None, zoek=None, minimum=1): params = [van, tot] where = "a.Datum BETWEEN %s AND %s" codes = [code] if code else self.codes # laatkomerscodes horen niet bij de afwezigheden where += f" AND a.Code IN ({', '.join(['%s'] * len(codes))})" params.extend(codes) sql = f""" SELECT a.Internnummer, MAX(l.Voornaam) AS Voornaam, MAX(l.Naam) AS Naam, MAX(l.Gebruikersnaam) AS Gebruikersnaam, COUNT(*) AS Halve_dagen, COUNT(DISTINCT a.Datum) AS Dagen, MIN(a.Datum) AS Eerste, MAX(a.Datum) AS Laatste FROM smartschool_afwezigheden a LEFT JOIN Leerlingen l ON l.Internnummer = a.Internnummer WHERE {where} GROUP BY a.Internnummer HAVING COUNT(*) >= %s ORDER BY Halve_dagen DESC, Laatste DESC """ params.append(int(minimum or 1)) rows = execute_query(sql, tuple(params)) if zoek: z = zoek.lower() rows = [r for r in rows if z in ' '.join(str(r.get(k) or '') for k in ('Internnummer', 'Voornaam', 'Naam', 'Gebruikersnaam')).lower()] return rows def detail(self, internnummer, van, tot, code=None): params = [internnummer, van, tot] codes = [code] if code else self.codes extra = f" AND Code IN ({', '.join(['%s'] * len(codes))})" params.extend(codes) rows = execute_query( "SELECT Datum, Dagdeel, Code FROM smartschool_afwezigheden " f"WHERE Internnummer = %s AND Datum BETWEEN %s AND %s{extra} ORDER BY Datum DESC, Dagdeel", tuple(params)) # samenvoegen per dag per_dag = {} for r in rows: d = per_dag.setdefault(r['Datum'], {'Datum': r['Datum'], 'VM': None, 'NM': None}) d[r['Dagdeel']] = r['Code'] return list(per_dag.values()) def laatkomers(self, van, tot): """Alle leerlingen die tussen van en tot te laat waren: aantal keer, eerste en laatste datum en de lijst van data (nieuwste eerst). Eén keer te laat = één halve dag met een laatkomerscode.""" codes = self.laat_codes if not codes: return [] rows = execute_query( f"""SELECT a.Internnummer, a.Datum, a.Dagdeel, l.Voornaam, l.Naam, l.Gebruikersnaam FROM smartschool_afwezigheden a LEFT JOIN Leerlingen l ON l.Internnummer = a.Internnummer WHERE a.Datum BETWEEN %s AND %s AND a.Code IN ({', '.join(['%s'] * len(codes))}) ORDER BY a.Datum DESC, a.Dagdeel""", (van, tot, *codes)) per = {} for r in rows: l = per.setdefault(r['Internnummer'], { 'Internnummer': r['Internnummer'], 'Voornaam': r['Voornaam'], 'Naam': r['Naam'], 'Gebruikersnaam': r['Gebruikersnaam'], 'Aantal': 0, 'Laatste': r['Datum'], 'Eerste': r['Datum'], 'Data': []}) l['Aantal'] += 1 l['Eerste'] = r['Datum'] l['Data'].append((r['Datum'], r['Dagdeel'])) return sorted(per.values(), key=lambda x: (-x['Aantal'], -x['Laatste'].toordinal()))