Files
Pivot/services/afwezigheden_service.py
T
2026-10-14 13:34:37 +02:00

297 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()))