PIVOT 1.1.0

This commit is contained in:
marijn committed 2026-10-14 13:34:37 +02:00
commit 8a53234eb6
161 files changed
+31109

No files matched your search

+296
View File
@@ -0,0 +1,296 @@
"""
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()))