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