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