Files
Pivot/services/sync_service.py
T
2026-10-14 12:29:29 +02:00

550 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import requests
from datetime import datetime, timezone # Essentieel voor de timestamp
from dateutil.relativedelta import relativedelta
from config import Config
from database import execute_query
# Informat geeft de volledige leerlingenlijst soms pas na een minuut of langer terug.
# (verbinden, lezen) in seconden
INFORMAT_TIMEOUT = (15, 300)
ONEROSTER_TIMEOUT = (15, 120)
def informat_get(url, headers, pogingen=2):
"""GET naar Informat met ruime wachttijd; bij een time-out nog één keer opnieuw."""
for poging in range(1, pogingen + 1):
try:
return requests.get(url, headers=headers, timeout=INFORMAT_TIMEOUT)
except requests.exceptions.Timeout:
if poging == pogingen:
raise RuntimeError(
f"Informat antwoordde niet binnen {INFORMAT_TIMEOUT[1] // 60} minuten "
f"({pogingen} pogingen). Probeer het later opnieuw; Informat is soms traag of in onderhoud.")
print(f"[Informat] time-out, nieuwe poging ({poging + 1}/{pogingen})...")
def zorg_vertrokken_kolom():
"""Kolom Leerlingen.Vertrokken_op toevoegen in een bestaande schooldatabase (één keer per school)."""
try:
from tenancy import huidige_school
school = huidige_school()
except Exception:
school = None
if school is not None and school.cache.get('lln_vertrokken_kolom'):
return
kolommen = {r['Field'].lower() for r in execute_query("SHOW COLUMNS FROM Leerlingen") or []}
if 'vertrokken_op' not in kolommen:
execute_query("ALTER TABLE Leerlingen ADD COLUMN Vertrokken_op DATE NULL AFTER Schooljaar_ID", commit=True)
if school is not None:
school.cache['lln_vertrokken_kolom'] = True
def veld_waarden(obj, pad):
"""Alle waarden op een pad met punten, ook door lijsten heen: veld_waarden(st, 'inschrijvingen.vestiging')."""
huidig = [obj]
for deel in pad.split('.'):
volgende = []
for o in huidig:
items = o if isinstance(o, list) else [o]
for it in items:
if isinstance(it, dict) and deel in it:
volgende.append(it[deel])
huidig = volgende
uit = []
for h in huidig:
uit.extend(h if isinstance(h, list) else [h])
return uit
class SyncService:
def __init__(self):
self.conf = Config()
# Informat-gegevens van de huidige school (beheerd via /scholen).
# Een school kan meerdere Informat-instellingsnummers hebben (één per campus).
self.campussen = Config.INFORMAT_CAMPUSSEN or []
self.institute_no = Config.INFORMAT_INST_NUMBER
self.auth_url = Config.INFORMAT_AUTH_URL
self.api_base = Config.INFORMAT_API_BASE
# De scope MOET het instellingsnummer bevatten
self.scope = Config.INFORMAT_FULL_SCOPE
def get_informat_token(self, nummer=None):
"""Haalt een token op met de scope van het (eerste of opgegeven) instellingsnummer."""
from tenancy import informat_scope
scope = informat_scope(Config.INFORMAT_SCOPE, nummer) if nummer else self.scope
payload = {
'grant_type': 'client_credentials',
'client_id': self.conf.INFORMAT_ID,
'client_secret': self.conf.INFORMAT_SECRET,
'scope': scope
}
try:
response = requests.post(self.auth_url, data=payload, timeout=10)
if response.status_code == 200:
return response.json().get('access_token')
except Exception:
return None
return None
def tel_leerlingen(self, refdate, schooljaar, nummer=None):
"""Aantal leerlingen in Informat op `refdate` (YYYY-MM-DD) voor `schooljaar` (bv. 2026-27), voor één
instellingsnummer (standaard het eerste). Geeft (True, aantal) of (False, foutmelding)."""
nummer = nummer or self.institute_no
token = self.get_informat_token(nummer)
if not token:
return False, f"Authenticatie bij Informat mislukt (instellingsnummer {nummer})."
url = f"{self.api_base}/students/?schoolYear={schooljaar}&refdate={refdate}"
headers = {
'Authorization': f'BEARER {token}',
'InstituteNo': nummer,
'Timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000Z'), # verplicht bij Informat
'Api-Version': '1',
'Accept': 'application/json'
}
try:
response = informat_get(url, headers)
except Exception as e:
return False, f"Fout tijdens tellen: {e}"
if response.status_code != 200:
return False, f"API fout {response.status_code} (instellingsnummer {nummer}): {response.text[:300]}"
data = response.json()
lijst = data if isinstance(data, list) else data.get('students', [])
self._laatste_lijst = lijst # voor tellen per campusveld en de veldentest
return True, len(lijst)
def informat_lijst(self, refdate, schooljaar, nummer=None):
"""De volledige leerlingenlijst van Informat (zelfde oproep als het tellen). (True, lijst) of (False, fout)."""
ok, res = self.tel_leerlingen(refdate, schooljaar, nummer)
return (True, self._laatste_lijst) if ok else (False, res)
def tel_locatie(self, refdate, schooljaar, locatie_id):
"""Aantal leerlingen op één schoollocatie (Discimus-nummer) via de Informat Student API
(GET /smartschool-users). Geeft (True, aantal) of (False, foutmelding).
Let op: deze API telt enkel leerlingen met een actieve inschrijving (gerealiseerd of parallel, niet GON)
op een locatie die aan het Smartschool-platform gekoppeld is."""
platform = (Config.SS_API_PLATFORM or '').strip()
if not platform:
return False, ("Voor het tellen per campus moet het Smartschool-platform ingevuld zijn "
"(🔌 Koppelingen › Smartschool).")
token = self.get_informat_token(self.institute_no)
if not token:
return False, f"Authenticatie bij Informat mislukt (instellingsnummer {self.institute_no})."
url = f"{str(Config.INFORMAT_STUDENT_API).rstrip('/')}/smartschool-users"
headers = {'Authorization': f'Bearer {token}', 'instituteNo': self.institute_no, 'Accept': 'application/json'}
# Informat vergelijkt het platformadres letterlijk met wat er in Informat staat. Dat kan met of zonder
# https:// of een / op het einde opgeslagen zijn: probeer de varianten tot er één gekend is (en onthoud die).
varianten = list(dict.fromkeys([v for v in (getattr(self, '_ss_platform_url', None), f"https://{platform}",
platform, f"https://{platform}/", f"http://{platform}") if v]))
response, geprobeerd = None, []
for platform_url in varianten:
params = {'smartschoolPlatformUrl': platform_url, 'schoolYear': schooljaar,
'locationIdDiscimus': int(locatie_id) if str(locatie_id).isdigit() else locatie_id,
'referenceDate': refdate}
try:
for poging in (1, 2):
try:
response = requests.get(url, params=params, headers=headers, timeout=INFORMAT_TIMEOUT)
break
except requests.exceptions.Timeout:
if poging == 2:
raise RuntimeError("Informat antwoordde niet op tijd.")
except Exception as e:
return False, f"Fout tijdens tellen: {e}"
geprobeerd.append(platform_url)
if response.status_code == 409 and 'smartschoolplatform' in response.text.lower():
continue # platform onder dit adres niet gekend: volgende variant
self._ss_platform_url = platform_url
break
if response.status_code != 200:
detail = ''
try:
detail = response.json().get('detail') or ''
except ValueError:
detail = response.text[:200]
if response.status_code == 409 and 'smartschoolplatform' in response.text.lower():
return False, (f"Informat kent het Smartschool-platform niet voor instellingsnummer {self.institute_no} "
f"en schooljaar {schooljaar} (geprobeerd: {', '.join(geprobeerd)}). Is in Informat "
"voor dit instellingsnummer en dit schooljaar een Smartschool-koppeling actief?")
return False, f"Student API fout {response.status_code} (campus {locatie_id}): {detail}"
data = response.json()
lijst = data if isinstance(data, list) else data.get('items', [])
# Eén leerling kan meer dan één inschrijving hebben: uniek op persoon
return True, len({u['ppersoon'] if u.get('ppersoon') is not None else (u.get('persoonId') or id(u))
for u in lijst})
def tel_per_campus(self, refdate, schooljaar):
"""Geeft (True, totaal, detail) of (False, fout, None). detail = [{'naam', 'nummer', 'aantal'}] als de
school meerdere campussen heeft, anders None.
- Campussen (Discimus-nummers) ingesteld → per locatie geteld via de Student API.
- Anders: per instellingsnummer (meestal één) via de leerlingen-API zoals voorheen."""
locaties = Config.INFORMAT_LOCATIES or []
veld = (Config.INFORMAT_CAMPUS_VELD or '').strip()
if veld:
return self._tel_op_veld(refdate, schooljaar, veld, dict(locaties))
if locaties:
telling = [(loc, naam, lambda loc=loc: self.tel_locatie(refdate, schooljaar, loc)) for loc, naam in locaties]
else:
campussen = self.campussen or [(self.institute_no, '')]
telling = [(nr, naam, lambda nr=nr: self.tel_leerlingen(refdate, schooljaar, nr)) for nr, naam in campussen]
detail, totaal = [], 0
for nummer, naam, tel in telling:
ok, res = tel()
if not ok:
return False, (f"{naam}: {res}" if len(telling) > 1 else res), None
detail.append({'naam': naam or nummer, 'nummer': nummer, 'aantal': res})
totaal += res
return True, totaal, (detail if len(detail) > 1 else None)
def _tel_op_veld(self, refdate, schooljaar, veld, namen):
"""Haalt de leerlingenlijst één keer op en telt per waarde van `veld` (pad met punten, bv.
'inschrijvingen.vestiging'). `namen` vertaalt een waarde naar een campusnaam (uit 'Campussen')."""
lijst, gezien = [], set()
for nummer, naam in (self.campussen or [(self.institute_no, '')]): # alle instellingsnummers van de school
ok, deel = self.informat_lijst(refdate, schooljaar, nummer)
if not ok:
return False, (f"{naam}: {deel}" if len(self.campussen) > 1 else deel), None
for st in deel:
sleutel = str(st.get('pPersoon') or st.get('id') or id(st))
if sleutel not in gezien:
gezien.add(sleutel)
lijst.append(st)
per = {}
for st in lijst:
for w in set(str(v) for v in veld_waarden(st, veld) if v not in (None, '')) or {'?'}:
per[w] = per.get(w, 0) + 1
volgorde = [w for w in namen if w in per] + sorted(w for w in per if w not in namen)
detail = [{'naam': namen.get(w) or (f"Onbekend ({veld} leeg)" if w == '?' else f"Campus {w}"),
'nummer': '' if w == '?' else w, 'aantal': per[w]} for w in volgorde]
return True, len(lijst), (detail if len(detail) > 1 else None)
def sync_student_count(self, school_naam=None):
"""Huidig aantal (peildatum vandaag + 1 maand, zodat net ingeschreven leerlingen meetellen)."""
refdate = (datetime.now() + relativedelta(months=1)).strftime('%Y-%m-%d')
ok, res, detail = self.tel_per_campus(refdate, Config.SCHOOLYEAR)
if not ok:
return False, res
# Bewaren bij de school in de centrale database (overzicht scholengroep)
from database import central_query
central_query("UPDATE Scholen SET Leerlingenaantal = %s, Leerlingenaantal_detail = %s, "
"Leerlingenaantal_sync = NOW() WHERE Code = %s",
(res, json.dumps(detail) if detail else None, Config.SCHOOLNAME_SHORT), commit=True)
if detail:
return True, f"Synchronisatie voltooid: {res} leerlingen (" + ", ".join(
f"{d['naam']}: {d['aantal']}" for d in detail) + ")."
return True, f"Synchronisatie voltooid: {res} leerlingen."
def tel_op_peildatum(self, peildatum, door=None):
"""Telt de leerlingen op een gekozen datum en bewaart het resultaat in Leerlingenaantal_tellingen."""
from database import central_query
from tenancy import huidige_school, _automatisch_schooljaar
schooljaar = _automatisch_schooljaar(peildatum)
ok, res, detail = self.tel_per_campus(peildatum.strftime('%Y-%m-%d'), schooljaar)
if not ok:
return False, res
central_query(
"INSERT INTO Leerlingenaantal_tellingen (School_ID, Peildatum, Schooljaar, Aantal, Detail, Opgevraagd_op, Door) "
"VALUES (%s, %s, %s, %s, %s, NOW(), %s) "
"ON DUPLICATE KEY UPDATE Schooljaar = VALUES(Schooljaar), Aantal = VALUES(Aantal), Detail = VALUES(Detail), "
"Opgevraagd_op = NOW(), Door = VALUES(Door)",
(huidige_school().id, peildatum, schooljaar, res, json.dumps(detail) if detail else None, door),
commit=True)
return True, res
#Haal ss token op
def get_smartschool_token(self):
"""Haalt een OAuth2 token op bij Smartschool."""
resp_ss = requests.post(
self.conf.SS_TOKEN_URL,
data={"grant_type": "client_credentials"},
auth=(self.conf.SS_CLIENT_ID, self.conf.SS_CLIENT_SECRET)
)
return resp_ss.json().get("access_token")
#Vergelijkt leerlingenlijst uit informat met namen uit DB en vult eventuele ontbrekende leerlingen aan met gegevens van Informat (Internnummer) en Smartschool (Gebruikersnaam)
def sync_leerlingen_informat(self):
"""
Synchroniseert leerlingen door Informat data te vergelijken met de DB
en ontbrekende data aan te vullen via Smartschool.
"""
try:
# 1. Haal Informat data op
inf_token = self.get_informat_token()
#Ik zet als parameter voor het ophalen huidige datum +1 maand om leerlingen die net ingeschreven zijn en pas na een vakantie starten mee te kunnen rekenen
future_date = datetime.now() + relativedelta(months=1)
refdate = future_date.strftime('%Y-%m-%d')
inf_url = f"{self.api_base}/students/?schoolYear={self.conf.INFORMAT_SCHOOLYEAR}&refdate={refdate}"
inf_headers = {
'Authorization': f'BEARER {inf_token}',
'InstituteNo': self.institute_no,
'Api-Version': '1',
'Accept': 'application/json'
}
inf_data = requests.get(inf_url, headers=inf_headers).json()
if not inf_data or 'students' not in inf_data:
return False, "Geen data van Informat ontvangen"
# 2. Haal Bestaande Database data op
# We gebruiken execute_query om alle bestaande Internnummers op te halen
db_rows = execute_query("SELECT Internnummer FROM Leerlingen")
db_numbers = {str(row['Internnummer']) for row in db_rows} if db_rows else set()
# 3. Zoek ontbrekende ID's (Informat - Database)
json_numbers = {str(s['pPersoon']) for s in inf_data['students'] if 'pPersoon' in s}
missing_ids = json_numbers - db_numbers
if not missing_ids:
return True, "Iedereen is al gesynchroniseerd."
# 4. Haal Smartschool data op voor de match
ss_token = self.get_smartschool_token()
ss_res = requests.get(
f"{self.conf.SS_BASE_URL}/students",
headers={"Authorization": f"Bearer {ss_token}"}
)
ss_students = ss_res.json().get("users", [])
added_count = 0
for m_id in missing_ids:
found_username = None
for ss_student in ss_students:
ss_internal = str(ss_student.get("metadata", {}).get("smsc.internalNumber", ""))
if ss_internal.strip() == m_id.strip():
found_username = ss_student.get("username")
break
if found_username:
# 5. DATABASE INVOER
execute_query(
"INSERT INTO Leerlingen (Internnummer, Gebruikersnaam, ss_nummer) VALUES (%s, %s, %s)",
(m_id, found_username, "Niet ingesteld"),
commit=True
)
added_count += 1
return True, f"Succes! {added_count} leerlingen toegevoegd."
except Exception as e:
return False, f"Fout tijdens synchronisatie: {str(e)}"
#---------------------Nieuwe aanpak------------------------
import requests
from datetime import datetime, timezone
from config import Config
from database import execute_query
class InformatSmartschoolSync:
def __init__(self):
self.conf = Config()
# Informat-gegevens van de huidige school (beheerd via /scholen)
self.institute_no = Config.INFORMAT_INST_NUMBER
self.auth_url = Config.INFORMAT_AUTH_URL
self.api_base = Config.INFORMAT_API_BASE
# De scope MOET het instellingsnummer bevatten
self.scope = Config.INFORMAT_FULL_SCOPE
self.campussen = Config.INFORMAT_CAMPUSSEN or [(self.institute_no, '')]
def get_informat_token(self, nummer=None):
"""Haalt token op met de scope van het (eerste of opgegeven) instellingsnummer."""
from tenancy import informat_scope
payload = {
'grant_type': 'client_credentials',
'client_id': self.conf.INFORMAT_ID,
'client_secret': self.conf.INFORMAT_SECRET,
'scope': informat_scope(Config.INFORMAT_SCOPE, nummer) if nummer else self.scope
}
try:
response = requests.post(self.auth_url, data=payload, timeout=10)
if response.status_code == 200:
return response.json().get('access_token')
except Exception as e:
print(f"Fout bij ophalen Informat token: {e}")
return None
def get_smartschool_oneroster_students(self):
"""Haalt actieve leerlingen op uit de Smartschool OneRoster API (v1p1). Bij een fout: {}."""
mapping, fout = self.smartschool_koppeling()
if fout:
print(f"Fout bij ophalen Smartschool OneRoster data: {fout}")
return mapping
def smartschool_koppeling(self):
"""{internnummer: {'username', 'ss_nummer'}} uit Smartschool OneRoster. Geeft (mapping, None) of ({}, fout)."""
if not (self.conf.SS_CLIENT_ID and self.conf.SS_CLIENT_SECRET):
return {}, "de OneRoster-koppeling van Smartschool is niet ingesteld"
try:
token_url = self.conf.SS_TOKEN_URL.strip()
if not token_url.startswith(("http://", "https://")):
token_url = f"https://{token_url}"
resp_ss = requests.post(
token_url,
data={"grant_type": "client_credentials"},
auth=(self.conf.SS_CLIENT_ID, self.conf.SS_CLIENT_SECRET),
timeout=10
)
resp_ss.raise_for_status()
ss_token = resp_ss.json().get("access_token")
if not ss_token:
return {}, "geen access_token ontvangen van Smartschool"
headers = {
"Authorization": f"Bearer {ss_token}",
"Accept": "application/json"
}
base_url = self.conf.SS_BASE_URL.strip()
if not base_url.startswith(("http://", "https://")):
base_url = f"https://{base_url}"
base_url = base_url.rstrip('/')
endpoint_url = f"{base_url}/students"
response = requests.get(endpoint_url, headers=headers, timeout=ONEROSTER_TIMEOUT)
response.raise_for_status()
json_data = response.json()
all_students = json_data.get("users", [])
ss_mapping = {}
for student in all_students:
metadata = student.get("metadata", {})
internal_number = metadata.get("smsc.internalNumber")
username = student.get("username")
sourced_id = student.get("sourcedId")
if internal_number:
ss_mapping[str(internal_number).strip()] = {
"username": username,
"ss_nummer": sourced_id if sourced_id else "Niet ingesteld"
}
if not ss_mapping:
return {}, "Smartschool gaf geen enkele leerling met een internnummer terug"
return ss_mapping, None
except Exception as e:
return {}, str(e)
def _informat_leerlingen(self, nummer, school_year, refdate):
"""Leerlingenlijst van één instellingsnummer. Geeft (lijst, None) of (None, fout)."""
inf_token = self.get_informat_token(nummer)
if not inf_token:
return None, f"Informat authenticatie mislukt (instellingsnummer {nummer})."
base_url_inf = self.api_base.strip().rstrip('/')
url = f"{base_url_inf}/students/?schoolYear={school_year}&refdate={refdate}"
headers = {
'Authorization': f'BEARER {inf_token}',
'InstituteNo': nummer,
'Timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000Z'),
'Api-Version': '1',
'Accept': 'application/json'
}
response = informat_get(url, headers)
if response.status_code != 200:
return None, f"Informat API fout {response.status_code} (instellingsnummer {nummer}): {response.text}"
json_data = response.json()
return (json_data if isinstance(json_data, list) else json_data.get('students', json_data.get('items', []))), None
def sync_actieve_leerlingen(self):
"""
Haalt actieve leerlingen op bij Informat (alle campussen / instellingsnummers van de school),
koppelt Smartschool OneRoster info en update de database 'Leerlingen'.
"""
school_year = Config.SCHOOLYEAR
# +1 maand: ook leerlingen die net ingeschreven zijn en pas na een vakantie starten
refdate = (datetime.now() + relativedelta(months=1)).strftime('%Y-%m-%d')
students_list, gezien = [], set()
try:
for nummer, naam in self.campussen:
lijst, fout = self._informat_leerlingen(nummer, school_year, refdate)
if fout:
return False, (f"{naam}: {fout}" if len(self.campussen) > 1 else fout)
for st in lijst:
sleutel = str(st.get('pPersoon') or st.get('internNummer') or st.get('id', '')).strip()
if sleutel and sleutel not in gezien:
gezien.add(sleutel)
students_list.append(st)
except Exception as e:
return False, f"Fout opgetreden tijdens de synchronisatie: {str(e)}"
if not students_list:
# Nooit iedereen als vertrokken markeren omdat Informat (tijdelijk) een lege lijst gaf
return False, "Informat gaf geen enkele actieve leerling terug; er is niets aangepast."
# Smartschool (gebruikersnaam en Smartschool-nummer). Lukt dat niet, dan blijven die velden zoals ze waren.
ss_leerlingen, ss_fout = self.smartschool_koppeling()
try:
print(f"[DEBUG] Aantal actieve leerlingen gevonden in Informat: {len(students_list)}")
zorg_vertrokken_kolom()
db_rows = execute_query("SELECT Internnummer FROM Leerlingen")
bestaande_internnummers = {str(row['Internnummer']).strip() for row in db_rows} if db_rows else set()
toegevoegd_teller = bijgewerkt_teller = 0
schooljaar_id = Config.SCHOOLYEAR[:4]
actief = set()
for student in students_list:
internnummer = str(student.get('pPersoon') or student.get('internNummer') or student.get('id', '')).strip()
voornaam = (student.get('voornaam' if 'voornaam' in student else 'firstName') or '').strip()
naam = (student.get('naam' if 'naam' in student else 'lastName') or '').strip()
if not internnummer or internnummer == "None":
continue
actief.add(internnummer)
ss_info = ss_leerlingen.get(internnummer)
standaard_gebruikersnaam = f"{voornaam}.{naam}".replace(" ", "").lower()
if internnummer in bestaande_internnummers:
if ss_info:
execute_query("""
UPDATE Leerlingen SET Gebruikersnaam = %s, Voornaam = %s, Naam = %s, SS_nummer = %s,
Schooljaar_ID = %s, Vertrokken_op = NULL
WHERE Internnummer = %s""",
(ss_info.get("username") or standaard_gebruikersnaam, voornaam, naam,
ss_info.get("ss_nummer") or "Niet ingesteld", schooljaar_id, internnummer), commit=True)
else:
# Niet (of niet bereikbaar) in Smartschool: gebruikersnaam en Smartschool-nummer niet overschrijven
execute_query("""
UPDATE Leerlingen SET Voornaam = %s, Naam = %s, Schooljaar_ID = %s, Vertrokken_op = NULL
WHERE Internnummer = %s""", (voornaam, naam, schooljaar_id, internnummer), commit=True)
bijgewerkt_teller += 1
else:
ss_info = ss_info or {}
execute_query("""
INSERT INTO Leerlingen (Internnummer, Gebruikersnaam, Voornaam, Naam, SS_nummer, Schooljaar_ID)
VALUES (%s, %s, %s, %s, %s, %s)""",
(internnummer, ss_info.get("username") or standaard_gebruikersnaam, voornaam, naam,
ss_info.get("ss_nummer") or "Niet ingesteld", schooljaar_id), commit=True)
bestaande_internnummers.add(internnummer)
toegevoegd_teller += 1
# Leerlingen die niet meer actief zijn in Informat: als vertrokken markeren (niet verwijderen)
nog_actief = execute_query("SELECT Internnummer FROM Leerlingen WHERE Vertrokken_op IS NULL") or []
weg = [r['Internnummer'] for r in nog_actief if str(r['Internnummer']).strip() not in actief]
for k in range(0, len(weg), 500):
deel = weg[k:k + 500]
execute_query(f"UPDATE Leerlingen SET Vertrokken_op = CURDATE() WHERE Internnummer IN "
f"({', '.join(['%s'] * len(deel))})", tuple(deel), commit=True)
melding = f"{toegevoegd_teller} nieuw, {bijgewerkt_teller} bijgewerkt"
if weg:
melding += f", {len(weg)} als vertrokken gemarkeerd"
return True, {'melding': melding + '.', 'smartschool_fout': ss_fout}
except Exception as e:
return False, f"Fout opgetreden tijdens de synchronisatie: {str(e)}"