297 lines
14 KiB
Python
297 lines
14 KiB
Python
"""
|
||
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()))
|