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

+326
View File
@@ -0,0 +1,326 @@
"""
Skore-synchronisatie in Smartschool (klassen en leerlingen van Smartschool → Skore).
Smartschool-webservices:
startSkoreSync(accesscode) → start de synchronisatie, geeft een taak-ID (serviceId) terug
checkStatus(accesscode, serviceId) → status van die taak
Per school:
- skore_planning : één rij met de planning (uit / dagelijks / weekdagen / wekelijks / maandelijks + uur)
- skore_log : elke gestarte synchronisatie (manueel of gepland) met taak-ID en laatste status
Eén achtergrondtaak overloopt elke minuut alle scholen en start de synchronisatie wanneer de planning dat vraagt.
"""
import json
import sys
import threading
import time
from datetime import datetime, timedelta
from config import Config
from database import execute_query, execute_insert
from tenancy import alle_scholen, in_school
FREQUENTIES = [
('uit', 'Niet automatisch (enkel manueel)'),
('dagelijks', 'Elke dag'),
('weekdagen', 'Elke weekdag (ma–vr)'),
('wekelijks', 'Eén keer per week'),
('maandelijks', 'Eén keer per maand'),
]
WEEKDAGEN = ['maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag']
# Een geplande synchronisatie die gemist werd (PIVOT stond uit) wordt nog ingehaald tot zoveel uur later.
INHAAL_UREN = 6
# Statuscodes van checkStatus die we kennen (vastgesteld: 2 = "SYNC IS COMPLETED")
STATUSCODES = {2: 'voltooid'}
# Automatische opvolging na een start: elke 30 s de status opvragen, maximaal 45 minuten
OPVOLG_INTERVAL = 30
OPVOLG_MAX = 45 * 60
EINDSTATUS = ('voltooid', 'mislukt')
TABELLEN = [
"""CREATE TABLE IF NOT EXISTS skore_planning (
ID TINYINT NOT NULL PRIMARY KEY,
Frequentie VARCHAR(20) NOT NULL DEFAULT 'uit',
Uur TINYINT NOT NULL DEFAULT 6,
Minuut TINYINT NOT NULL DEFAULT 0,
Weekdag TINYINT NOT NULL DEFAULT 0,
Dag_van_maand TINYINT NOT NULL DEFAULT 1,
Laatst_gepland DATETIME NULL,
Bijgewerkt DATETIME NULL,
Bijgewerkt_door VARCHAR(255) NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""",
"""CREATE TABLE IF NOT EXISTS skore_log (
ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
Gestart DATETIME NOT NULL,
Door VARCHAR(255) NULL,
Manier VARCHAR(20) NOT NULL,
Service_ID VARCHAR(100) NULL,
Status VARCHAR(30) NOT NULL,
Melding TEXT NULL,
Bijgewerkt DATETIME NULL,
INDEX idx_skore_gestart (Gestart)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""",
]
class SkoreFout(RuntimeError):
pass
def ensure_tables():
for sql in TABELLEN:
execute_query(sql, commit=True)
execute_query("INSERT IGNORE INTO skore_planning (ID) VALUES (1)", commit=True)
# =====================================================================
# Smartschool
# =====================================================================
def _client():
if not (Config.SS_API_PLATFORM and Config.SS_API_PW):
raise SkoreFout("Smartschool is nog niet ingesteld voor deze school (platform en webservice-wachtwoord "
"via 🔌 Koppelingen van deze school).")
from services.smartschool_api import SmartschoolService
return SmartschoolService(Config.SS_API_PLATFORM, Config.SS_API_PW).client
def _json(raw):
"""Smartschool geeft JSON als tekst terug; geeft een dict (of None)."""
if isinstance(raw, dict):
return raw
try:
data = json.loads(raw) if isinstance(raw, str) and raw.strip().startswith('{') else None
except ValueError:
data = None
return data if isinstance(data, dict) else None
def service_id_uit(raw):
"""Taak-ID uit het antwoord van startSkoreSync, bv. {"serviceId":"db18…","message":"SERVICE STARTED"}.
Werkt ook voor oude logregels waarin het volledige antwoord als ID bewaard werd."""
data = _json(raw)
if data is not None:
return str(data.get('serviceId') or data.get('serviceID') or data.get('id') or '').strip() or None
if raw in (None, ''):
return None
return str(raw).strip() or None
def _is_foutcode(raw):
return isinstance(raw, (int, float)) or (isinstance(raw, str) and raw.strip().lstrip('-').isdigit())
def _foutcode_tekst(client, code):
"""Smartschool geeft bij fouten een getal; probeer de omschrijving op te halen."""
try:
lijst = json.loads(client.service.returnJsonErrorCodes())
tekst = lijst.get(str(code).strip()) if isinstance(lijst, dict) else None
if tekst:
return f"Smartschool-fout {code}: {tekst}"
except Exception:
pass
return (f"Smartschool-fout {code}. Controleer of het webservice-wachtwoord het recht heeft om de "
"Skore-synchronisatie te starten (Smartschool › Beheer › Webservices).")
def _status_uit(raw):
"""Zet het antwoord van checkStatus om naar (status, melding).
Smartschool antwoordt met {"status": <getal>, "message": <tekst of null>}. Wat de getallen betekenen is niet
publiek gedocumenteerd; daarom leiden we 'voltooid' of 'mislukt' enkel af uit de tekst, en tonen we anders
de statuscode zelf (bv. 'code 0')."""
tekst = raw if isinstance(raw, str) else json.dumps(raw, default=str) if raw is not None else ''
data = _json(raw)
code = data.get('status') if data else None
bericht = str((data or {}).get('message') or (data or {}).get('state') or '') if data else tekst
laag = bericht.lower()
if any(w in laag for w in ('error', 'fout', 'failed', 'mislukt')):
status = 'mislukt'
elif any(w in laag for w in ('done', 'finished', 'completed', 'klaar', 'voltooid', 'success')):
status = 'voltooid'
elif any(w in laag for w in ('running', 'busy', 'bezig', 'started', 'queue', 'wacht')):
status = 'bezig'
elif code is not None and str(code).strip().isdigit() and int(code) in STATUSCODES:
status = STATUSCODES[int(code)]
elif code is not None:
status = f"code {code}"
else:
status = 'onbekend'
melding = f"Smartschool: status {code}" + (f", {bericht}" if bericht else " (geen bericht)") if data else tekst
return status[:30], melding[:2000]
def start(door, manier='manueel'):
"""Start de Skore-synchronisatie. Geeft de rij uit skore_log terug."""
ensure_tables()
nu = datetime.now()
try:
client = _client()
raw = client.service.startSkoreSync(Config.SS_API_PW)
if _is_foutcode(raw) and str(raw).strip() not in ('', '0'):
raise SkoreFout(_foutcode_tekst(client, raw))
service_id = service_id_uit(raw)
bericht = (_json(raw) or {}).get('message')
if not service_id:
raise SkoreFout(f"Smartschool startte de synchronisatie niet: {bericht or raw}")
status = 'gestart'
melding = f"Synchronisatie gestart bij Smartschool ({bericht})." if bericht else "Synchronisatie gestart bij Smartschool."
except SkoreFout as e:
service_id, status, melding = None, 'mislukt', str(e)
except Exception as e:
service_id, status, melding = None, 'mislukt', f"Smartschool niet bereikbaar: {e}"
log_id = execute_insert(
"INSERT INTO skore_log (Gestart, Door, Manier, Service_ID, Status, Melding, Bijgewerkt) "
"VALUES (%s, %s, %s, %s, %s, %s, %s)", (nu, door, manier, service_id, status, melding, nu))
rij = execute_query("SELECT * FROM skore_log WHERE ID = %s", (log_id,))[0]
if rij['Service_ID']:
volg_op(log_id)
return rij
def volg_op(log_id):
"""Vraagt op de achtergrond de status op tot de synchronisatie klaar of mislukt is."""
from tenancy import met_school
def loop():
begin = time.time()
while time.time() - begin < OPVOLG_MAX:
time.sleep(OPVOLG_INTERVAL)
try:
rij = controleer(log_id)
except Exception as e:
sys.stderr.write(f"[{Config.SCHOOLNAME_SHORT}] Skore-opvolging: {e}\n")
continue
if not rij or rij['Status'] in EINDSTATUS:
return
threading.Thread(target=met_school(loop), daemon=True).start()
def bezig():
"""True als er een synchronisatie loopt die nog geen eindstatus heeft (laatste 45 minuten)."""
rows = execute_query("SELECT Status, Gestart FROM skore_log WHERE Service_ID IS NOT NULL "
"ORDER BY Gestart DESC, ID DESC LIMIT 1")
return bool(rows) and rows[0]['Status'] not in EINDSTATUS and \
datetime.now() - rows[0]['Gestart'] < timedelta(seconds=OPVOLG_MAX)
def controleer(log_id):
"""Vraagt de status van een gestarte synchronisatie op bij Smartschool en bewaart die."""
rows = execute_query("SELECT * FROM skore_log WHERE ID = %s", (log_id,))
if not rows or not rows[0]['Service_ID']:
return rows[0] if rows else None
try:
client = _client()
sid = service_id_uit(rows[0]['Service_ID'])
if sid != rows[0]['Service_ID']:
execute_query("UPDATE skore_log SET Service_ID = %s WHERE ID = %s", (sid, log_id), commit=True)
raw = client.service.checkStatus(Config.SS_API_PW, sid)
if _is_foutcode(raw) and str(raw).strip() not in ('', '0'):
status, melding = 'onbekend', _foutcode_tekst(client, raw)
else:
status, melding = _status_uit(raw)
except Exception as e:
status, melding = rows[0]['Status'], f"Status opvragen mislukt: {e}"
execute_query("UPDATE skore_log SET Status = %s, Melding = %s, Bijgewerkt = %s WHERE ID = %s",
(status, melding, datetime.now(), log_id), commit=True)
return execute_query("SELECT * FROM skore_log WHERE ID = %s", (log_id,))[0]
def logboek(n=25):
ensure_tables()
return execute_query("SELECT * FROM skore_log ORDER BY Gestart DESC, ID DESC LIMIT %s", (n,))
# =====================================================================
# Planning
# =====================================================================
def planning():
ensure_tables()
return execute_query("SELECT * FROM skore_planning WHERE ID = 1")[0]
def bewaar_planning(frequentie, uur, minuut, weekdag, dag_van_maand, door):
if frequentie not in dict(FREQUENTIES):
raise ValueError("Onbekende frequentie.")
uur, minuut, weekdag, dag = int(uur), int(minuut), int(weekdag), int(dag_van_maand)
if not (0 <= uur <= 23 and 0 <= minuut <= 59 and 0 <= weekdag <= 6 and 1 <= dag <= 28):
raise ValueError("Ongeldig tijdstip of ongeldige dag.")
ensure_tables()
# Laatst_gepland op 'nu' zetten: een planning die je net aanpast, start niet meteen voor een moment in het verleden
execute_query(
"UPDATE skore_planning SET Frequentie = %s, Uur = %s, Minuut = %s, Weekdag = %s, Dag_van_maand = %s, "
"Laatst_gepland = %s, Bijgewerkt = %s, Bijgewerkt_door = %s WHERE ID = 1",
(frequentie, uur, minuut, weekdag, dag, datetime.now(), datetime.now(), door), commit=True)
def laatste_moment(p, nu):
"""Het laatste geplande moment op of vóór `nu` volgens planning `p` (of None als 'uit')."""
f = p['Frequentie']
if f == 'uit':
return None
for terug in range(0, 62):
dag = (nu - timedelta(days=terug)).date()
moment = datetime(dag.year, dag.month, dag.day, int(p['Uur']), int(p['Minuut']))
if moment > nu:
continue
if f == 'dagelijks' \
or (f == 'weekdagen' and dag.weekday() < 5) \
or (f == 'wekelijks' and dag.weekday() == int(p['Weekdag'])) \
or (f == 'maandelijks' and dag.day == int(p['Dag_van_maand'])):
return moment
return None
def volgende_moment(p, nu=None):
"""Het eerstvolgende geplande moment na `nu` (voor op de pagina)."""
nu = nu or datetime.now()
if p['Frequentie'] == 'uit':
return None
for vooruit in range(0, 62):
dag = (nu + timedelta(days=vooruit)).date()
moment = datetime(dag.year, dag.month, dag.day, int(p['Uur']), int(p['Minuut']))
if moment > nu and laatste_moment(p, moment) == moment:
return moment
return None
def geplande_controle(nu=None):
"""Start de synchronisatie als er een gepland moment voorbij is dat nog niet uitgevoerd werd."""
nu = nu or datetime.now()
p = planning()
moment = laatste_moment(p, nu)
if not moment or nu - moment > timedelta(hours=INHAAL_UREN):
return None
if p['Laatst_gepland'] and p['Laatst_gepland'] >= moment:
return None
execute_query("UPDATE skore_planning SET Laatst_gepland = %s WHERE ID = 1", (nu,), commit=True)
rij = start('automatisch', manier='gepland')
print(f"[{Config.SCHOOLNAME_SHORT}] Skore-synchronisatie (gepland): {rij['Status']} – {rij['Melding']}", flush=True)
return rij
_scheduler = None
def start_scheduler():
"""Eén achtergrondtaak die elke minuut alle scholen met een Smartschool-koppeling overloopt."""
global _scheduler
if _scheduler:
return
def loop():
time.sleep(45) # app eerst laten opstarten
while True:
for school in alle_scholen():
if not school.heeft('SS_API_PLATFORM', 'SS_API_PW'):
continue
try:
with in_school(school):
geplande_controle()
except Exception as e:
sys.stderr.write(f"[{school.code}] Skore-planning: {e}\n")
time.sleep(60)
_scheduler = threading.Thread(target=loop, daemon=True)
_scheduler.start()