Initial commit
This commit is contained in:
commit
536ff43ce4
158 files changed
+30355
No files matched your search
@@ -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()))
|
||||
Reference in new issue
Block a user