""" Meerdere scholen in één PIVOT. Elke school staat als één rij in de tabel `Scholen` van de CENTRALE database (config.ini › [database] › centrale_database). Per school worden bijgehouden: - code, naam, e-maildomein(en) en eventueel eigen hostnamen - de eigen PIVOT-database van die school - alle koppelingen (Smartschool, Informat, Google, Signpost, ...) in `instellingen` - het Google-serviceaccount (JSON-sleutel) Geheimen (wachtwoorden, client secrets, de Google-sleutel) worden versleuteld opgeslagen met de sleutel uit de omgevingsvariabele PIVOT_SLEUTEL (of het bestand geheimen/pivot.key). De 'huidige school' wordt per request bepaald (zie app.py) en kan buiten een request gezet worden met `in_school(school)` (achtergrondtaken). `Config.SS_API_PW` e.d. geven automatisch de waarde van de huidige school. """ import contextlib import contextvars import json import re import os import sys import threading import time from dataclasses import dataclass, field from datetime import date from cryptography.fernet import Fernet, InvalidToken BASE_DIR = os.path.dirname(os.path.abspath(__file__)) class GeenSchoolFout(RuntimeError): """Er is geen school gekozen voor deze actie.""" class KoppelingOntbreekt(RuntimeError): """Een koppeling (bv. de Google-sleutel) is voor deze school nog niet ingesteld.""" # ===================================================================== # Velden per school # ===================================================================== @dataclass class Veld: key: str # naam zoals gebruikt in de code (Config.) label: str groep: str soort: str = 'tekst' # tekst | geheim | getal | janee | tekstvak | keuze standaard: object = '' hulp: str = '' keuzes: tuple = () # bij soort 'keuze': ((waarde, label), ...) GROEPEN = [ ('algemeen', '🏫 Algemeen'), ('database', '🗄️ Database'), ('smartschool', '📘 Smartschool'), ('informat', '📗 Informat'), ('google', '🔑 Google Workspace'), ('tweestaps', '🛡️ 2FA-hulp'), ('personeel', '👩‍🏫 Personeel'), ('co2', '🌬️ CO2-sensoren'), ('m4s', '🔧 Signpost / M4S'), ] ONDERWIJSNIVEAUS = (('kleuter', 'Kleuteronderwijs'), ('basis', 'Basisonderwijs'), ('secundair', 'Secundair onderwijs')) VELDEN = [ # --- algemeen --- Veld('ADRES', 'Adres', 'algemeen', hulp='Straat, nummer, postcode en gemeente'), Veld('ONDERWIJSNIVEAU', 'Onderwijsniveau', 'algemeen', 'keuze', keuzes=ONDERWIJSNIVEAUS, hulp='Voor de subtotalen bij de leerlingenaantallen'), Veld('INSTELLINGSNUMMER', 'Instellingsnummer (officieel)', 'algemeen', hulp='Officieel instellingsnummer van de school (Onderwijs Vlaanderen)'), Veld('ADMIN_EMAIL', 'Beheerder (Google-admin)', 'algemeen', hulp='Account waarin het serviceaccount handelt, bv. ict@school.be'), Veld('CONTACT_EMAIL', 'Contactadres op brieven/PDF', 'algemeen', hulp='Leeg = beheerder'), Veld('SCHOOLYEAR', 'Schooljaar', 'algemeen', hulp='bv. 2026-27. Leeg = automatisch (wisselt op 1 september)'), Veld('STANDAARD_WACHTWOORD', 'Startwachtwoord bij Google-reset', 'algemeen', 'tekst', 'Welkom01!', hulp='Leeg = Welkom01!. Gebruikers moeten het bij de eerste login wijzigen'), # --- database --- Veld('DB_HOST', 'Server', 'database', hulp='Leeg = dezelfde server als de centrale database'), Veld('DB_USER', 'Gebruiker', 'database', hulp='Leeg = standaardgebruiker uit config.ini'), Veld('DB_PASSWORD', 'Wachtwoord', 'database', 'geheim', hulp='Leeg = standaardwachtwoord uit config.ini'), # --- smartschool --- Veld('SS_API_PLATFORM', 'Platform', 'smartschool', hulp='bv. school.smartschool.be'), Veld('SS_API_PW', 'Webservice-wachtwoord', 'smartschool', 'geheim', hulp='Smartschool › Beheer › Webservices'), Veld('SS_CLIENT_ID', 'OneRoster client-ID', 'smartschool'), Veld('SS_CLIENT_SECRET', 'OneRoster client-secret', 'smartschool', 'geheim'), Veld('AFWEZIGHEID_CODES', 'Afwezigheidscodes bewaren', 'smartschool', standaard='B', hulp='Komma-gescheiden, bv. B of B,R'), Veld('LAATKOMER_CODES', 'Codes voor te laat', 'smartschool', standaard='L', hulp='Afwezigheidscodes in Smartschool die "te laat" betekenen (voor Laatkomers), komma-gescheiden, bv. L'), Veld('AFWEZIGHEID_SYNC_UUR', 'Uur dagelijkse synchronisatie', 'smartschool', 'getal', 6), Veld('SS_SGR_GROEP', 'Hoofdgroep SGR groepen', 'smartschool', standaard='groepen GO-Antwerpen', hulp='Naam of code van de Smartschool-groep waarvan PIVOT de subgroepen en hun leden toont (Smartschool › SGR groepen)'), # --- informat --- Veld('INFORMAT_INST_NUMBER', 'Instellingsnummer Informat', 'informat', hulp='Nummer waarmee PIVOT zich bij Informat aanmeldt, bv. 040923. Haalt de school haar leerlingen uit ' 'meer dan één instellingsnummer, zet ze dan allemaal hier met een naam: nummer=naam, gescheiden door ' 'komma\'s, bv. 040774=Atheneum Irishof, 040791=Middenschool. PIVOT telt ze samen en toont ze apart.'), Veld('INFORMAT_CAMPUSSEN_RAW', 'Campussen', 'informat', hulp='Enkel voor een school met meerdere campussen: code=naam per campus, bv. 1=Campus Kapellen, ' '3=Campus Stabroek. De code is de waarde in het Campusveld hieronder (of anders het Discimus-nummer uit ' 'iLeerlingen › Algemeen › Structuur).'), Veld('INFORMAT_ID', 'Client-ID', 'informat'), Veld('INFORMAT_SECRET', 'Client-secret', 'informat', 'geheim'), Veld('INFORMAT_SCOPE', 'Scope', 'informat', standaard='api_informat_sas_leerlingen.leerlingen', hulp='Het instellingsnummer wordt er automatisch achter gezet'), Veld('INFORMAT_AUTH_URL', 'Token-URL', 'informat', standaard='https://www.identityserver.be/connect/token'), Veld('INFORMAT_API_BASE', 'API-URL', 'informat', standaard='https://leerlingenapi.informatsoftware.be/1'), Veld('INFORMAT_CAMPUS_VELD', 'Campusveld in de leerlingenlijst', 'informat', hulp='Veld in de Informat-leerlingenlijst dat de campus aangeeft, bv. vestnrDiscimus. Ingevuld: PIVOT telt per waarde van dit veld; de namen haalt ' 'het uit Campussen (waarde=naam).'), Veld('INFORMAT_STUDENT_API', 'API-URL Student API (campussen)', 'informat', standaard='https://studentapi.informatsoftware.be/api/v1'), Veld('INFORMAT_PERSONEEL_API', 'API-URL Personeel API (GO! Pro)', 'informat', standaard='https://personeelsapi.informatsoftware.be'), Veld('INFORMAT_PERSONEEL_SCOPE', 'Scope Personeel API', 'informat', standaard='api_informat_sas_personeel.personeel', hulp='Het instellingsnummer wordt er automatisch achter gezet. De Informat-client moet deze scope hebben'), Veld('INFORMAT_FACTUUR_API', 'API-URL Rekeningen API (leerlingfacturatie)', 'informat', standaard='https://api.informatsoftware.be/invoices/v1', hulp='GraphQL op /graphql, PDF\'s op /download/...'), Veld('INFORMAT_INTERNAAT_NUMMER', 'Instellingsnummer internaat', 'informat', hulp='Instellingsnummer van het internaat in Informat (bv. 123456). Meer dan één: 123456=Internaat A, 234567=Internaat B. ' 'Leeg = het instellingsnummer van de Informat-koppeling hierboven'), Veld('INFORMAT_INTERNAAT_API', 'API-URL Internaat API', 'informat', standaard='https://internaatapi.informatsoftware.be'), Veld('INFORMAT_INTERNAAT_SCOPE', 'Scope Internaat API', 'informat', standaard='api_informat_sas_internaat', hulp='Het instellingsnummer van het internaat wordt er automatisch achter gezet'), Veld('INFORMAT_FACTUUR_GROEP', 'Toegangsgroep Rekeningen', 'informat', hulp='Naam van de toegangsgroep in Rekeningen, zoals in Informat (bv. 000448 BS Irishof Kapellen). PIVOT zoekt ' 'daarmee zelf de juiste scope. Meer dan één: scheiden met een puntkomma'), Veld('INFORMAT_FACTUUR_SCOPE', 'Scope Rekeningen API', 'informat', hulp='De volledige scope, bv. api_informat_invoices.invoices.read.2949957b-5dc1-4acc-a7cf-ba322b62cb12_123456 ' '(toegangsgroep-GUID _ installatie-ID). Informat mailt die naar de eigenaar van de app zodra de school ' 'toegang geeft tot de rekeningen. Leeg = PIVOT probeert hem zelf te vinden. Meer dan één: komma ertussen'), # --- google --- Veld('GOOGLE_SSO_CLIENTID', 'Google-login client-ID', 'google', hulp='Leeg = de gedeelde Google-login uit config.ini'), Veld('GOOGLE_SSO_CLIENTSECRET', 'Google-login client-secret', 'google', 'geheim'), Veld('STUDENT_OU', 'OU nieuwe leerlingen', 'google', standaard='Leerlingen/Leerlingen', hulp='Het schooljaar wordt er automatisch achter gezet'), Veld('OU_NIEUWE_LEERKRACHTEN', 'OU nieuwe leerkrachten', 'google', standaard='/Leerkrachten'), Veld('CHROMEBOOK_OUS', 'OU\'s van de Chromebooks', 'google', hulp='Voor "Chromebooks ophalen uit Google": volledige paden, gescheiden door komma\'s (sub-OU\'s tellen mee), ' 'bv. /ChromeBooks Medewerkers, /ChromeBooks Leerlingen. Leeg = alle Chromebooks van het domein.'), Veld('GOOGLE_SMTP_SERVER', 'SMTP-server', 'google', standaard='smtp-relay.gmail.com'), Veld('GOOGLE_SMTP_PORT', 'SMTP-poort', 'google', 'getal', 587), Veld('GOOGLE_SMTP_SENDER_adres', 'Afzender e-mails', 'google', hulp='bv. noreply@school.be'), Veld('GOOGLE_SMTP_EMAIL_PASSWORD', 'SMTP-wachtwoord', 'google', 'geheim', hulp='Leeg bij een relay die op IP-adres werkt'), Veld('GOOGLE_API_SCOPES_RAW', 'API-scopes', 'google', 'tekstvak', '\n'.join([ 'https://www.googleapis.com/auth/admin.directory.user', 'https://www.googleapis.com/auth/admin.directory.device.chromeos', 'https://www.googleapis.com/auth/admin.directory.device.mobile', 'https://www.googleapis.com/auth/admin.directory.device.chromeos.readonly', 'https://www.googleapis.com/auth/admin.directory.device.mobile.readonly', 'https://www.googleapis.com/auth/admin.directory.orgunit', ])), # --- 2FA --- Veld('TWEESTAPS_UITZONDERING_GROEP', 'Uitzonderingsgroep', 'tweestaps', hulp='Groep waarvoor 2FA NIET wordt afgedwongen'), Veld('TWEESTAPS_OUS', 'OU\'s voor "nog geen 2FA"', 'tweestaps', standaard='/Leerkrachten'), # --- personeel --- Veld('PERSONEEL_OUS', 'Personeels-OU\'s', 'personeel', standaard='/Leerkrachten'), Veld('PERSONEEL_DIRECTIE', 'Directie (e-mails)', 'personeel'), Veld('PERSONEEL_SECRETARIAAT', 'Secretariaat (e-mails)', 'personeel'), Veld('PERSONEEL_SECRETARIAAT_MAIL', 'Gedeeld secretariaatsadres', 'personeel'), Veld('PERSONEEL_MAILS', 'E-mails versturen', 'personeel', 'janee', True), # --- CO2 --- Veld('CO2_NETWORK', 'Netwerk (prefix)', 'co2', hulp='bv. 10.166.0. — moet bereikbaar zijn vanaf de PIVOT-server'), Veld('CO2_START_ADDR', 'Beginadres', 'co2', 'getal', 80), Veld('CO2_END_ADDR', 'Eindadres', 'co2', 'getal', 155), Veld('CO2_THRESHOLD', 'Drempel (ppm)', 'co2', 'getal', 900), Veld('CO2_INTERVAL', 'Scaninterval (min)', 'co2', 'getal', 5), # --- M4S / Signpost --- Veld('M4S_CLIENT_ID', 'Client-ID', 'm4s'), Veld('M4S_CLIENT_SECRET', 'Client-secret', 'm4s', 'geheim'), Veld('M4S_INSTITUTIONGUID', 'Institution GUID', 'm4s'), Veld('M4S_AUTH_URL', 'Token-URL', 'm4s', standaard='https://idp.signpost.eu/oauth2/token'), Veld('M4S_BASE_URL', 'API-URL', 'm4s', standaard='https://byod-api.signpost.be/v1/field-service'), Veld('M4S_REPAIR_LOCATION_TYPE', 'Locatietype herstelling', 'm4s', standaard='100'), Veld('M4S_REPAIR_STATUS', 'Status nieuwe herstelling', 'm4s', standaard='new'), Veld('M4S_TRUTH_STATEMENT', 'Truth statement', 'm4s', standaard='True'), Veld('M4S_REPAIR_AT_INTITUTION', 'Herstelling op school', 'm4s', standaard='True'), Veld('M4S_LANGUAGE_CODE', 'Taalcode', 'm4s', standaard='nl'), Veld('M4S_CONTACT_FIRST_NAME', 'Contact voornaam', 'm4s'), Veld('M4S_CONTACT_LAST_NAME', 'Contact achternaam', 'm4s'), Veld('M4S_CONTACT_EMAIL', 'Contact e-mail', 'm4s'), Veld('M4S_CONTACT_PHONE', 'Contact telefoon', 'm4s'), Veld('M4S_REPAIR_STREET', 'Straat', 'm4s'), Veld('M4S_REPAIR_STREETNUMBER', 'Huisnummer', 'm4s'), Veld('M4S_REPAIR_ZIPCODE', 'Postcode', 'm4s'), Veld('M4S_REPAIIR_CITY', 'Gemeente', 'm4s'), Veld('M4S_REPAIR_COUNTRYCODE', 'Landcode', 'm4s', standaard='BE'), Veld('M4S_BILLING_FIRST_NAME', 'Facturatie voornaam', 'm4s'), Veld('M4S_BILLING_LAST_NAME', 'Facturatie achternaam', 'm4s'), Veld('M4S_BILLING_EMAIL', 'Facturatie e-mail', 'm4s'), Veld('M4S_BILLING_PHONE', 'Facturatie telefoon', 'm4s'), ] VELD = {v.key: v for v in VELDEN} def informat_campussen(waarde): """'040923=Campus Centrum, 041000=Campus Hoogboom' → [('040923', 'Campus Centrum'), ('041000', 'Campus Hoogboom')]. Zonder naam krijgt een campus 'Campus '; met één nummer is de naam leeg.""" delen = [d.strip() for d in str(waarde or '').replace(';', ',').split(',') if d.strip()] uit = [] for d in delen: nummer, _, naam = d.partition('=') nummer, naam = nummer.strip(), naam.strip() if nummer and nummer not in [n for n, _ in uit]: uit.append((nummer, naam or (f"Campus {nummer}" if len(delen) > 1 else ''))) return uit def informat_scope(basis, nummer): """Informat-scope voor één instellingsnummer: '.'. Eindigt de ingestelde scope al op '.' (een instellingsnummer), dan wordt dat vervangen.""" basis = re.sub(r'\.\d+$', '', str(basis or '').strip()) return f"{basis}.{nummer}" def _automatisch_schooljaar(vandaag=None): vandaag = vandaag or date.today() start = vandaag.year if vandaag.month >= 9 else vandaag.year - 1 return f"{start}-{str(start + 1)[-2:]}" def _lijst(waarde): return [x.strip() for x in str(waarde or '').replace(';', ',').split(',') if x.strip()] # Afgeleide waarden: oude Config-namen die uit andere velden berekend worden def _afgeleid(): return { 'SCHOOL_DOMAIN': lambda s: s.domein, 'SCHOOLNAME': lambda s: s.naam, 'SCHOOLNAME_SHORT': lambda s: s.code, 'GOOGLE_ADMIN_EMAIL': lambda s: s.get('ADMIN_EMAIL'), 'CONTACT_EMAIL': lambda s: s.waarden.get('CONTACT_EMAIL') or s.get('ADMIN_EMAIL'), 'SCHOOLYEAR': lambda s: s.waarden.get('SCHOOLYEAR') or _automatisch_schooljaar(), 'INFORMAT_SCHOOLYEAR': lambda s: s.get('SCHOOLYEAR'), 'OU_NEW_STUDENTS': lambda s: f"{s.get('STUDENT_OU')} {s.get('SCHOOLYEAR')}", 'GOOGLE_API_SCOPES': lambda s: [x.strip() for x in str(s.get('GOOGLE_API_SCOPES_RAW')).split() if x.strip()], 'SS_API_PLATFORM': lambda s: (s.waarden.get('SS_API_PLATFORM') or '').replace('https://', '') .replace('http://', '').split('/')[0], 'SS_WSDL_URL': lambda s: f"https://{s.get('SS_API_PLATFORM')}/Webservices/V3?wsdl", 'SS_TOKEN_URL': lambda s: f"https://{s.get('SS_API_PLATFORM')}/ims/oneroster/token", 'SS_BASE_URL': lambda s: f"https://{s.get('SS_API_PLATFORM')}/ims/oneroster/v1p1", # Eén of meer Informat-instellingsnummers: [(nummer, naam), ...] 'INFORMAT_CAMPUSSEN': lambda s: informat_campussen(s.waarden.get('INFORMAT_INST_NUMBER')), # Campussen (vestigingen) binnen het instellingsnummer, op Discimus-nummer: [(discimus_id, naam), ...] 'INFORMAT_LOCATIES': lambda s: informat_campussen(s.waarden.get('INFORMAT_CAMPUSSEN_RAW')), # Eerste nummer: voor alles wat maar één nummer kent 'INFORMAT_INST_NUMBER': lambda s: (informat_campussen(s.waarden.get('INFORMAT_INST_NUMBER')) or [('', '')])[0][0], 'INFORMAT_FULL_SCOPE': lambda s: informat_scope(s.get('INFORMAT_SCOPE'), s.get('INFORMAT_INST_NUMBER')), 'DB_NAME': lambda s: s.db_naam, } AFGELEID = _afgeleid() # ===================================================================== # Versleuteling # ===================================================================== _fernet = None _ENC = 'enc:' def _sleutelbestand(): return os.environ.get('PIVOT_SLEUTEL_BESTAND', os.path.join(os.environ.get('PIVOT_GEHEIMEN', os.path.join(BASE_DIR, 'geheimen')), 'pivot.key')) def fernet(): global _fernet if _fernet is None: sleutel = os.environ.get('PIVOT_SLEUTEL', '').strip() if not sleutel: pad = _sleutelbestand() if os.path.exists(pad): with open(pad) as f: sleutel = f.read().strip() else: os.makedirs(os.path.dirname(pad), exist_ok=True) sleutel = Fernet.generate_key().decode() with open(pad, 'w') as f: f.write(sleutel) try: os.chmod(pad, 0o600) except OSError: pass sys.stderr.write(f"PIVOT: nieuwe versleutelingssleutel aangemaakt in {pad}. " "MAAK HIER EEN BACK-UP VAN: zonder deze sleutel zijn de opgeslagen geheimen onleesbaar.\n") _fernet = Fernet(sleutel.encode()) return _fernet def versleutel(tekst): if tekst in (None, ''): return '' return _ENC + fernet().encrypt(str(tekst).encode()).decode() def ontsleutel(waarde): if not isinstance(waarde, str) or not waarde.startswith(_ENC): return waarde try: return fernet().decrypt(waarde[len(_ENC):].encode()).decode() except InvalidToken: sys.stderr.write("PIVOT: kan een geheim niet ontsleutelen (verkeerde PIVOT_SLEUTEL?).\n") return '' # ===================================================================== # School # ===================================================================== class School: def __init__(self, row): self.id = row['School_ID'] self.code = row['Code'] self.naam = row['School_naam'] self.domeinen = [d.lower().lstrip('@') for d in _lijst(row.get('Domein'))] self.hostnamen = [h.lower() for h in _lijst(row.get('Hostnamen'))] self.actief = bool(row.get('Actief', 1)) self.db_naam = row['Db_naam'] self.versie = str(row.get('Bijgewerkt') or '') self.leerlingenaantal = row.get('Leerlingenaantal') self.leerlingenaantal_sync = row.get('Leerlingenaantal_sync') self.leerlingenaantal_detail = _json_of_none(row.get('Leerlingenaantal_detail')) ruw = json.loads(row.get('Instellingen') or '{}') self.waarden = {k: ontsleutel(v) for k, v in ruw.items()} for key, kolom in KOLOM_VELDEN.items(): if row.get(kolom): self.waarden[key] = row[kolom] sleutel = ontsleutel(row.get('Google_sleutel') or '') self.google_sleutel = json.loads(sleutel) if sleutel else None self.cache = {} # per-school objecten (services, caches); leeg na elke wijziging self._lock = threading.RLock() # herbruikbaar: een object kan bij aanmaak zelf een object vragen @property def domein(self): return self.domeinen[0] if self.domeinen else '' def get(self, key): if key in AFGELEID: return AFGELEID[key](self) veld = VELD.get(key) if veld is None: raise AttributeError(f"Onbekende instelling '{key}'") waarde = self.waarden.get(key, '') if waarde in (None, ''): waarde = veld.standaard if veld.soort == 'getal': try: return int(waarde) except (TypeError, ValueError): return int(veld.standaard or 0) if veld.soort == 'janee': return str(waarde).lower() in ('1', 'true', 'ja', 'yes', 'on') return waarde def heeft(self, *keys): """True als alle opgegeven instellingen ingevuld zijn.""" return all(str(self.waarden.get(k) or '').strip() for k in keys) def db_config(self): from config import Config return { 'host': self.waarden.get('DB_HOST') or Config.DB_HOST, 'user': self.waarden.get('DB_USER') or Config.DB_USER, 'password': self.waarden.get('DB_PASSWORD') or Config.DB_PASSWORD, 'database': self.db_naam, } def object(self, sleutel, maak): """Eén object per school (bv. een service) bijhouden; `maak()` loopt binnen de schoolcontext.""" obj = self.cache.get(sleutel) if obj is None: with self._lock: obj = self.cache.get(sleutel) if obj is None: with in_school(self): obj = maak() self.cache[sleutel] = obj return obj def __repr__(self): return f"" # ===================================================================== # Register (uit de centrale database) # ===================================================================== _register = {'t': 0, 'scholen': {}} _register_lock = threading.Lock() CACHE_SECONDEN = 30 # Tabel Scholen (zie de PDF-structuur): de velden School_naam, Smartschool_platform_url, Adres, # Instellingsnummer en Domein staan als echte kolommen; alle andere koppelingen in `Instellingen` (JSON). SCHEMA = """ CREATE TABLE IF NOT EXISTS Scholen ( School_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, Code VARCHAR(32) NOT NULL, School_naam VARCHAR(255) NOT NULL, Smartschool_platform_url VARCHAR(255) NULL, Adres VARCHAR(255) NULL, Instellingsnummer VARCHAR(20) NULL, Informat_instellingsnummer VARCHAR(500) NULL, Onderwijsniveau VARCHAR(20) NULL, Domein VARCHAR(500) NOT NULL DEFAULT '', Hostnamen VARCHAR(500) NOT NULL DEFAULT '', Actief TINYINT NOT NULL DEFAULT 1, Db_naam VARCHAR(128) NOT NULL, Instellingen MEDIUMTEXT NULL, Google_sleutel MEDIUMTEXT NULL, Leerlingenaantal INT NULL, Leerlingenaantal_sync DATETIME NULL, Leerlingenaantal_detail TEXT NULL, Bijgewerkt DATETIME(6) NULL, Bijgewerkt_door VARCHAR(255) NULL, UNIQUE KEY uq_scholen_code (Code) ) ENGINE=InnoDB""" # Velden die als eigen kolom in Scholen staan i.p.v. in de JSON `Instellingen` KOLOM_VELDEN = { 'SS_API_PLATFORM': 'Smartschool_platform_url', 'INSTELLINGSNUMMER': 'Instellingsnummer', # officieel nummer 'INFORMAT_INST_NUMBER': 'Informat_instellingsnummer', # nummer voor de Informat-koppeling 'ADRES': 'Adres', 'ONDERWIJSNIVEAU': 'Onderwijsniveau', # kleuter | basis | secundair } def ensure_schema(): """Centrale database: Scholen + gebruikers, rechten, logs en bugs (zie rechten.py).""" from database import central_query import rechten # Database die van Windows komt (tabelnamen in kleine letters) op een Linux-server: eerst de namen herstellen, # anders maakt PIVOT hieronder lege tabellen met hoofdletters aan naast de echte try: from services.tabelnamen import herstel_centraal herstel_centraal() except Exception as e: sys.stderr.write(f"PIVOT: tabelnamen centrale database nakijken mislukt: {e}\n") controleer_oude_tabellen() # eerst controleren, dan pas aanmaken central_query(SCHEMA, commit=True) _migreer_informat_nummer() rechten.ensure_schema() _kolommen_bijwerken() def _json_of_none(t): try: return json.loads(t) if t else None except ValueError: return None def _kolommen_bijwerken(): """Kolommen die later bijkwamen of groter werden (bestaande installaties).""" from database import central_query kol = {(r['TABLE_NAME'].lower(), r['COLUMN_NAME'].lower()): r for r in central_query( "SELECT TABLE_NAME, COLUMN_NAME, CHARACTER_MAXIMUM_LENGTH FROM information_schema.COLUMNS " "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ('Scholen', 'Leerlingenaantal_tellingen')")} r = kol.get(('scholen', 'informat_instellingsnummer')) if r and (r['CHARACTER_MAXIMUM_LENGTH'] or 0) < 500: central_query("ALTER TABLE Scholen MODIFY Informat_instellingsnummer VARCHAR(500) NULL", commit=True) if ('scholen', 'leerlingenaantal_detail') not in kol: central_query("ALTER TABLE Scholen ADD COLUMN Leerlingenaantal_detail TEXT NULL AFTER Leerlingenaantal_sync", commit=True) if ('scholen', 'onderwijsniveau') not in kol: central_query("ALTER TABLE Scholen ADD COLUMN Onderwijsniveau VARCHAR(20) NULL AFTER Informat_instellingsnummer", commit=True) if ('leerlingenaantal_tellingen', 'detail') not in kol: central_query("ALTER TABLE Leerlingenaantal_tellingen ADD COLUMN Detail TEXT NULL AFTER Aantal", commit=True) def _migreer_informat_nummer(): """Vroeger stond het Informat-nummer in de kolom Instellingsnummer. Die kolom is nu voor het officiële nummer; het Informat-nummer krijgt een eigen kolom. Eénmalig: enkel als die kolom nog niet bestaat.""" from database import central_query bestaat = central_query("SELECT COUNT(*) AS n FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() " "AND TABLE_NAME = 'Scholen' AND COLUMN_NAME = 'Informat_instellingsnummer'")[0]['n'] if bestaat: return central_query("ALTER TABLE Scholen ADD COLUMN Informat_instellingsnummer VARCHAR(500) NULL AFTER Instellingsnummer", commit=True) central_query("UPDATE Scholen SET Informat_instellingsnummer = Instellingsnummer, Instellingsnummer = NULL, " "Bijgewerkt = NOW(6) WHERE Instellingsnummer IS NOT NULL", commit=True) # Tabel → kolom die in de nieuwe structuur moet bestaan. Ontbreekt die, dan staat er nog een # oude tabel met dezelfde naam (MySQL op Windows maakt geen onderscheid tussen hoofd- en kleine letters). NIEUWE_KOLOMMEN = {'Scholen': 'School_ID', 'Gebruikers': 'Gebruiker_ID', 'Logs': 'Log_ID', 'Status': 'Bugstatus_ID', 'Bugs': 'Bug_ID'} def controleer_oude_tabellen(): """Stopt als er een tabel met dezelfde naam bestaat, maar zonder de nieuwe structuur (en geen nieuwe ernaast).""" from database import central_query oud = [] for tabel, kolom in NIEUWE_KOLOMMEN.items(): rows = central_query( "SELECT COUNT(*) AS n, COALESCE(SUM(LOWER(COLUMN_NAME) = LOWER(%s)), 0) AS heeft " "FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) = LOWER(%s)", (kolom, tabel)) if rows and rows[0]['n'] and not int(rows[0]['heeft']): oud.append(tabel) if oud: raise RuntimeError( f"De centrale database bevat nog oude tabellen met dezelfde naam: {', '.join(oud)}. " "Gebruik een nieuwe, lege database (config.ini › centrale_database) of hernoem die tabellen eerst.") def herlaad(force=False): """Leest de scholen opnieuw in. Ongewijzigde scholen behouden hun object (en dus hun caches).""" from database import central_query with _register_lock: if not force and time.time() - _register['t'] < CACHE_SECONDEN: return _register['scholen'] try: rows = central_query("SELECT * FROM Scholen ORDER BY School_naam") except Exception as e: sys.stderr.write(f"PIVOT: scholen inlezen mislukt: {e}\n") return _register['scholen'] oud = _register['scholen'] nieuw = {} for r in rows: s = oud.get(r['Code']) if s is None or s.versie != str(r.get('Bijgewerkt') or ''): try: s = School(r) except Exception as e: sys.stderr.write(f"PIVOT: school {r.get('Code')} kan niet geladen worden: {e}\n") continue else: s.leerlingenaantal = r.get('Leerlingenaantal') s.leerlingenaantal_sync = r.get('Leerlingenaantal_sync') s.leerlingenaantal_detail = _json_of_none(r.get('Leerlingenaantal_detail')) nieuw[r['Code']] = s _register.update(t=time.time(), scholen=nieuw) return nieuw def alle_scholen(ook_inactief=False): return [s for s in herlaad().values() if ook_inactief or s.actief] def school_op_code(code, ook_inactief=False): s = herlaad().get(code or '') return s if s and (ook_inactief or s.actief) else None def scholen_voor_email(email): """Alle (actieve) scholen met het e-maildomein van `email`. Meerdere scholen mogen hetzelfde domein delen.""" domein = (email or '').lower().rsplit('@', 1)[-1] return [s for s in alle_scholen() if domein and domein in s.domeinen] def school_voor_email(email): """De (eerste) school met dit e-maildomein; zie scholen_voor_email als het domein gedeeld wordt.""" lijst = scholen_voor_email(email) return lijst[0] if lijst else None def school_voor_host(host): host = (host or '').lower().split(':')[0] for s in alle_scholen(): if host in s.hostnamen: return s return None def opslaan(code, naam, domeinen, hostnamen, db_naam, actief, waarden, google_sleutel=None, gebruiker=None, nieuw=False): """Schrijft een school weg. `waarden` bevat de leesbare waarden; geheimen worden hier versleuteld.""" from database import central_query opgeslagen, kolommen = {}, {k: None for k in KOLOM_VELDEN.values()} for k, v in waarden.items(): if k not in VELD or v in (None, ''): continue if k in KOLOM_VELDEN: kolommen[KOLOM_VELDEN[k]] = v continue opgeslagen[k] = versleutel(v) if VELD[k].soort == 'geheim' else v sleutel_enc = versleutel(json.dumps(google_sleutel)) if google_sleutel else None kol = list(kolommen) if nieuw: central_query( "INSERT INTO Scholen (Code, School_naam, Domein, Hostnamen, Db_naam, Actief, Instellingen, Google_sleutel, " f"{', '.join(kol)}, Bijgewerkt, Bijgewerkt_door) " f"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,{','.join(['%s'] * len(kol))},NOW(6),%s)", (code, naam, domeinen, hostnamen, db_naam, int(bool(actief)), json.dumps(opgeslagen), sleutel_enc, *[kolommen[k] for k in kol], gebruiker), commit=True) else: central_query( "UPDATE Scholen SET School_naam=%s, Domein=%s, Hostnamen=%s, Db_naam=%s, Actief=%s, Instellingen=%s, " f"Google_sleutel=COALESCE(%s, Google_sleutel), {', '.join(k + '=%s' for k in kol)}, " "Bijgewerkt=NOW(6), Bijgewerkt_door=%s WHERE Code=%s", (naam, domeinen, hostnamen, db_naam, int(bool(actief)), json.dumps(opgeslagen), sleutel_enc, *[kolommen[k] for k in kol], gebruiker, code), commit=True) herlaad(force=True) def wis_google_sleutel(code, gebruiker=None): from database import central_query central_query("UPDATE Scholen SET Google_sleutel=NULL, Bijgewerkt=NOW(6), Bijgewerkt_door=%s WHERE Code=%s", (gebruiker, code), commit=True) herlaad(force=True) # ===================================================================== # Huidige school # ===================================================================== _huidige = contextvars.ContextVar('pivot_school', default=None) def huidige_school(verplicht=True): s = _huidige.get() if s is None: try: from flask import g, has_request_context if has_request_context(): s = g.get('school') except ImportError: pass if s is None and verplicht: raise GeenSchoolFout("Er is geen school gekozen. Log opnieuw in.") return s @contextlib.contextmanager def in_school(school): token = _huidige.set(school) try: yield school finally: _huidige.reset(token) def met_school(fn): """Verpakt `fn` zodat hij in een andere thread in de huidige school loopt.""" school = huidige_school() def wrapper(*a, **kw): with in_school(school): return fn(*a, **kw) return wrapper def voor_elke_school(fn, *, vereist=()): """Voert fn() uit voor elke actieve school (die de opgegeven instellingen heeft).""" for s in alle_scholen(): if vereist and not s.heeft(*vereist): continue try: with in_school(s): fn() except Exception as e: sys.stderr.write(f"[{s.code}] {getattr(fn, '__qualname__', fn)}: {e}\n") class per_school: """ Vervanger voor een module-niveau object (bv. `service = per_school(GoogleAdminService)`). Elke school krijgt een eigen instantie, aangemaakt bij het eerste gebruik. """ def __init__(self, maak): self._maak = maak def get(self): return huidige_school().object(('per_school', id(self)), self._maak) def __getattr__(self, naam): return getattr(self.get(), naam) def __setattr__(self, naam, waarde): if naam.startswith('_'): object.__setattr__(self, naam, waarde) else: setattr(self.get(), naam, waarde) def school_singleton(cls, init): """Eén instantie van `cls` per school. `init(inst)` zet de begintoestand.""" def maak(): inst = object.__new__(cls) init(inst) return inst return huidige_school().object(('singleton', cls.__qualname__), maak) class SchoolLijst: """Lijst die bij elk gebruik uit de huidige school gelezen wordt (bv. toegangsgroepen).""" def __init__(self, key): self.key = key def _w(self): return huidige_school().get(self.key) def __contains__(self, x): return x in self._w() def __iter__(self): return iter(self._w()) # ===================================================================== # Google-serviceaccount # ===================================================================== _GEEN = object() def google_credentials(scopes, subject=_GEEN): """Credentials van het serviceaccount van de huidige school, gedelegeerd naar `subject`. Wordt een subject gevraagd maar is het leeg (bv. 'Beheerder (Google-admin)' niet ingevuld), dan stoppen we met een duidelijke melding: zonder subject geeft Google enkel een vaag 'Invalid Input'.""" from google.oauth2 import service_account s = huidige_school() if not s.google_sleutel: raise KoppelingOntbreekt(f"Voor {s.naam} is nog geen Google-serviceaccount (JSON-sleutel) ingesteld. " "Een beheerder kan die toevoegen via 🔌 Koppelingen van deze school.") if subject is not _GEEN and not (subject or '').strip(): raise KoppelingOntbreekt(f"Voor {s.naam} is 'Beheerder (Google-admin)' nog niet ingevuld " "(🔌 Koppelingen van deze school › Algemeen, bv. ict@school.be). " "PIVOT werkt in Google namens dat account.") if subject is _GEEN: subject = None creds = service_account.Credentials.from_service_account_info(s.google_sleutel, scopes=list(scopes)) return creds.with_subject(subject) if subject else creds