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)}"