Initial commit

This commit is contained in:
Kristof committed 2026-10-14 12:29:29 +02:00
commit 536ff43ce4
158 files changed
+30355

No files matched your search

+747
View File
@@ -0,0 +1,747 @@
"""
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.<key>)
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 <API-URL>/graphql, PDF\'s op <API-URL>/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 <nummer>'; 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: '<scope>.<nummer>'. Eindigt de ingestelde scope al op
'.<cijfers>' (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"<School {self.code}>"
# =====================================================================
# 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