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

+66
View File
@@ -0,0 +1,66 @@
# CIP_inleveren.py – toestel terug inleveren (en eventueel herstelling registreren)
from database import execute_query
from datetime import date
from services import toestellen as tst
foutmelding = ""
def Inleveren(internnummer, toestelnummer, action_type, tab2_schade_text):
global foutmelding
# 1. Zoek leerling op (zet om naar officieel Internnummer)
leerling = tst.zoek_leerling(internnummer)
if not leerling:
foutmelding = "Error: Internnummer niet gevonden."
return
# 2. Zoek het toestel en de huidige toekenning aan deze leerling
toestel = tst.zoek_toestel(toestelnummer)
if not toestel:
foutmelding = "Error: Toestelnummer niet gevonden in de toestellenlijst."
return
eigendom = execute_query(
"SELECT Eigendom_ID FROM Eigendom WHERE Toestel_ID = %s AND Internnummer = %s AND Datum_inlevering IS NULL",
(toestel['Toestel_ID'], leerling['Internnummer']))
if not eigendom:
foutmelding = "Error: Dit toestel staat niet als 'uitgeleend' aan deze leerling in de database."
return
# 3. Verwerk de inlevering
try:
_verwerk_inlevering(eigendom[0]['Eigendom_ID'], leerling, toestel['Toestel_ID'], action_type, tab2_schade_text)
foutmelding = "Toestel succesvol ingeleverd."
except Exception as e:
foutmelding = f"Systeemfout: {str(e)}"
def _verwerk_inlevering(eigendom_id, leerling, toestel_id, action_type, schade_text):
vandaag = date.today()
# A. Toekenning afsluiten (blijft bewaard als geschiedenis)
execute_query("UPDATE Eigendom SET Datum_inlevering = %s WHERE Eigendom_ID = %s", (vandaag, eigendom_id), commit=True)
# B. Status en eventueel herstelling
if action_type == "Schade":
execute_query(
"INSERT INTO Signpost_herstellingen_DB (Toestel_ID, Omschrijving, Status_herstelling, Datum, Leerling_ID) "
"VALUES (%s, %s, 'geregistreerd', %s, %s)",
(toestel_id, schade_text, vandaag, leerling['Leerling_ID']), commit=True)
execute_query("UPDATE Toestellen SET Status = 'Defect' WHERE Toestel_ID = %s", (toestel_id,), commit=True)
else:
execute_query("UPDATE Toestellen SET Status = 'Beschikbaar' WHERE Toestel_ID = %s", (toestel_id,), commit=True)
def Ingeven_herstelling(toestelnummer, soort_herstelling):
"""Herstelling registreren zonder inlevering door een leerling."""
global foutmelding
toestel = tst.zoek_toestel(toestelnummer)
if not toestel:
foutmelding = f"Toestel {toestelnummer} niet gevonden."
return
execute_query(
"INSERT INTO Signpost_herstellingen_DB (Toestel_ID, Omschrijving, Status_herstelling, Datum) "
"VALUES (%s, %s, 'In afwachting', %s)",
(toestel['Toestel_ID'], soort_herstelling, date.today()), commit=True)
foutmelding = "Herstelling ingegeven"
+51
View File
@@ -0,0 +1,51 @@
# CIP_uitdelen.py – toestel uitdelen aan een leerling
from database import execute_query
from datetime import date
from services import toestellen as tst
foutmelding = ""
def uitdelen(internnummer, toestelnummer):
"""`toestelnummer` = eigen toestelnummer van de school (Schooleigen_ID) of serienummer."""
global foutmelding
# 1. Validatie van input
if not internnummer or not toestelnummer:
foutmelding = "Error: Vul zowel het Internnummer als het toestelnummer in."
return False
# 2. Check of leerling bestaat
leerling = tst.zoek_leerling(internnummer)
if not leerling:
foutmelding = "Error: Internnummer niet gevonden in de leerlingenlijst."
return False
# 3. Check of toestel bestaat
toestel = tst.zoek_toestel(toestelnummer)
if not toestel:
foutmelding = "Error: Toestelnummer niet gevonden in de toestellenlijst."
return False
# 4. Check of toestel al is uitgegeven
huidig = tst.actieve_eigendom(toestel['Toestel_ID'])
if huidig:
foutmelding = f"Error: Toestel is al toegekend aan {huidig['Internnummer']}"
return False
# 5. Alles OK -> Uitvoeren
try:
_verwerk_uitdeling(leerling['Internnummer'], toestel['Toestel_ID'])
foutmelding = "Toestel succesvol uitgedeeld!"
return True
except Exception as e:
foutmelding = f"Systeemfout: {str(e)}"
return False
def _verwerk_uitdeling(internnummer, toestel_id):
"""Nieuwe rij in Eigendom (zonder Datum_inlevering) en toestel op 'Uitgeleend'."""
execute_query(
"INSERT INTO Eigendom (Internnummer, Toestel_ID, Status, Datum_toekenning) VALUES (%s, %s, %s, %s)",
(internnummer, toestel_id, 1 if tst.is_geblokkeerd(toestel_id) else 0, date.today()), commit=True)
execute_query("UPDATE Toestellen SET Status = 'Uitgeleend' WHERE Toestel_ID = %s", (toestel_id,), commit=True)
View File
Whitespace-only changes.
+69
View File
@@ -0,0 +1,69 @@
"""
Internaat › Aanwezigheden: per intern, per dag een ochtend- en een avondcontrole (aanwezig / afwezig / nog niet
gecontroleerd). De internen komen uit Informat (Internaat-API); de registraties staan in de schooldatabase
(tabel internaat_aanwezigheid) en zijn dus gedeeld met alle opvoeders.
"""
from datetime import date, timedelta
from database import execute_query
MOMENTEN = ('ochtend', 'avond')
STATUSSEN = ('aanwezig', 'afwezig') # 'leeg' = geen rij
DAGEN = ['Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za', 'Zo']
DAGEN_VOLUIT = ['Maandag', 'Dinsdag', 'Woensdag', 'Donderdag', 'Vrijdag', 'Zaterdag', 'Zondag']
def ensure_table():
execute_query("""
CREATE TABLE IF NOT EXISTS internaat_aanwezigheid (
Datum DATE NOT NULL,
Persoon_ID VARCHAR(64) NOT NULL,
Moment VARCHAR(10) NOT NULL,
Status VARCHAR(10) NOT NULL,
Naam VARCHAR(200) NULL,
Bijgewerkt DATETIME NOT NULL,
Door VARCHAR(255) NULL,
PRIMARY KEY (Datum, Persoon_ID, Moment),
KEY idx_aanw_persoon (Persoon_ID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True)
def maandag_van(d):
return d - timedelta(days=d.weekday())
def schooljaar_van(d):
start = d.year if d.month >= 9 else d.year - 1
return f"{start}-{str(start + 1)[-2:]}"
def week(maandag):
"""{'<persoon>:<dagindex>:<moment>': status} voor de week vanaf `maandag`."""
ensure_table()
rijen = execute_query("SELECT Datum, Persoon_ID, Moment, Status FROM internaat_aanwezigheid "
"WHERE Datum BETWEEN %s AND %s", (maandag, maandag + timedelta(days=6))) or []
return {f"{r['Persoon_ID']}:{(r['Datum'] - maandag).days}:{r['Moment']}": r['Status'] for r in rijen}
def zet(datum, persoon_id, moment, status, naam=None, door=None):
"""Bewaart één controle. status 'leeg' wist de registratie."""
if moment not in MOMENTEN:
raise ValueError("Onbekend moment.")
ensure_table()
if status == 'leeg':
execute_query("DELETE FROM internaat_aanwezigheid WHERE Datum = %s AND Persoon_ID = %s AND Moment = %s",
(datum, persoon_id, moment), commit=True)
return
if status not in STATUSSEN:
raise ValueError("Onbekende status.")
execute_query("INSERT INTO internaat_aanwezigheid (Datum, Persoon_ID, Moment, Status, Naam, Bijgewerkt, Door) "
"VALUES (%s, %s, %s, %s, %s, NOW(), %s) ON DUPLICATE KEY UPDATE Status = VALUES(Status), "
"Naam = VALUES(Naam), Bijgewerkt = NOW(), Door = VALUES(Door)",
(datum, persoon_id, moment, status, (naam or '')[:200] or None, door), commit=True)
def groep_van(r, meerdere_internaten=False):
"""Groep in het register: het blok van de kamer, anders de vestiging (met het internaat ervoor als er meerdere zijn)."""
blok = (r.get('kamer') or '').split(' verd.')[0].split(' nr.')[0]
groep = blok.replace('blok', 'Blok') if blok.startswith('blok') else (r.get('vestiging') or 'Zonder kamer')
return f"{r['internaat']} · {groep}" if meerdere_internaten else groep
+296
View File
@@ -0,0 +1,296 @@
"""
Afwezigheden (o.a. B-codes) uit Smartschool ophalen en bewaren.
Werking
- Per schooldag wordt `getAbsentsWithInternalNumberByDate` aangeroepen.
Smartschool geeft per leerling (internnummer) de code van de voormiddag (am)
en namiddag (pm) terug.
- Enkel de codes uit config.ini (standaard: B) worden bewaard in de tabel
`smartschool_afwezigheden`. Andere codes worden genegeerd.
- Eén keer per dag (standaard 06:00) synchroniseert een achtergrondtaak.
De laatste 14 dagen worden telkens opnieuw opgehaald, zodat correcties
in Smartschool ook in PIVOT terechtkomen.
Vereist in Smartschool:
Beheer › Webservices › het webservice-wachtwoord moet het recht
'Afwezigheden' hebben.
"""
import json
import sys
import threading
import time
from datetime import date, datetime, timedelta
from config import Config
from database import execute_query
from tenancy import school_singleton, alle_scholen, in_school, met_school
HERHAAL_DAGEN = 14 # laatste X dagen altijd opnieuw ophalen
DAGDEEL = {'am': 'VM', 'pm': 'NM'}
def schooljaar_start(vandaag=None):
vandaag = vandaag or date.today()
jaar = vandaag.year if vandaag.month >= 9 else vandaag.year - 1
return date(jaar, 9, 1)
class AfwezighedenService:
_scheduler = None # één achtergrondtaak voor alle scholen
def __new__(cls):
# één instantie per school (eigen status, lock en Smartschool-client)
def init(inst):
inst._lock = threading.Lock()
inst._client = None
inst.status = {'bezig': False, 'gestart': None, 'voortgang': 0, 'totaal': 0,
'laatste_fout': None, 'laatste_resultaat': None}
return school_singleton(cls, init)
# ---------------- configuratie ----------------
@property
def codes(self):
return [c.strip().upper() for c in Config.AFWEZIGHEID_CODES.split(',') if c.strip()]
@property
def laat_codes(self):
"""Codes die 'te laat' betekenen (Laatkomers). Ze worden samen met de afwezigheidscodes opgehaald."""
return [c.strip().upper() for c in str(Config.LAATKOMER_CODES or '').split(',') if c.strip()]
def _ss(self):
if self._client is None:
from services.smartschool_api import SmartschoolService
if not (Config.SS_API_PLATFORM and Config.SS_API_PW):
raise RuntimeError("Smartschool is nog niet ingesteld voor deze school "
"(platform en webservice-wachtwoord via 🔌 Koppelingen van deze school).")
self._client = SmartschoolService(platform=Config.SS_API_PLATFORM,
webservice_pwd=Config.SS_API_PW)
return self._client
# ---------------- database ----------------
@staticmethod
def ensure_tables():
execute_query("""
CREATE TABLE IF NOT EXISTS smartschool_afwezigheden (
Datum DATE NOT NULL,
Internnummer VARCHAR(32) NOT NULL,
Dagdeel CHAR(2) NOT NULL,
Code VARCHAR(8) NOT NULL,
PRIMARY KEY (Datum, Internnummer, Dagdeel),
INDEX idx_intern (Internnummer),
INDEX idx_code (Code)
)""", commit=True)
execute_query("""
CREATE TABLE IF NOT EXISTS smartschool_afwezigheden_sync (
Datum DATE NOT NULL PRIMARY KEY,
Opgehaald DATETIME NOT NULL,
Aantal INT NOT NULL DEFAULT 0
)""", commit=True)
# ---------------- Smartschool ----------------
def fetch_day(self, dag):
"""Geeft {internnummer: {'VM': code, 'NM': code}} voor één dag (alleen de gezochte codes)."""
raw = self._ss().client.service.getAbsentsWithInternalNumberByDate(
Config.SS_API_PW, dag.strftime('%Y-%m-%d'))
if raw is None or raw == '':
return {}
if isinstance(raw, (int, float)) or (isinstance(raw, str) and raw.strip().lstrip('-').isdigit()):
# Smartschool geeft bij fouten een numerieke foutcode terug
raise RuntimeError(f"Smartschool foutcode {raw} (controleer het recht 'Afwezigheden' "
"voor het webservice-wachtwoord).")
data = json.loads(raw) if isinstance(raw, str) else raw
if not isinstance(data, dict):
return {}
gezocht = set(self.codes) | set(self.laat_codes)
result = {}
for intern, delen in data.items():
if not isinstance(delen, dict):
continue
for key, label in DAGDEEL.items():
val = delen.get(key)
if isinstance(val, dict): # variant met alias: {'code': 'B', 'alias': ...}
val = val.get('code')
code = (val or '').strip().upper()
if code in gezocht:
result.setdefault(str(intern).strip(), {})[label] = code
return result
def _store_day(self, dag, per_leerling):
execute_query("DELETE FROM smartschool_afwezigheden WHERE Datum = %s", (dag,), commit=True)
aantal = 0
for intern, delen in per_leerling.items():
for dagdeel, code in delen.items():
execute_query(
"INSERT INTO smartschool_afwezigheden (Datum, Internnummer, Dagdeel, Code) "
"VALUES (%s, %s, %s, %s)", (dag, intern, dagdeel, code), commit=True)
aantal += 1
execute_query(
"INSERT INTO smartschool_afwezigheden_sync (Datum, Opgehaald, Aantal) VALUES (%s, NOW(), %s) "
"ON DUPLICATE KEY UPDATE Opgehaald = NOW(), Aantal = VALUES(Aantal)", (dag, aantal), commit=True)
return aantal
# ---------------- synchronisatie ----------------
def dagen_te_synchroniseren(self, vandaag=None, volledig=False):
vandaag = vandaag or date.today()
start = schooljaar_start(vandaag)
alle = [start + timedelta(days=i) for i in range((vandaag - start).days + 1)]
schooldagen = [d for d in alle if d.weekday() < 5] # ma–vr
if volledig:
return schooldagen
rows = execute_query("SELECT Datum FROM smartschool_afwezigheden_sync WHERE Datum >= %s", (start,))
gehad = {r['Datum'] for r in rows}
grens = vandaag - timedelta(days=HERHAAL_DAGEN)
return [d for d in schooldagen if d not in gehad or d >= grens]
def sync(self, volledig=False):
"""Synchroniseert (blokkerend). Geeft (ok, bericht) terug."""
if not self._lock.acquire(blocking=False):
return False, "Er loopt al een synchronisatie."
try:
self.ensure_tables()
dagen = self.dagen_te_synchroniseren(volledig=volledig)
self.status.update(bezig=True, gestart=datetime.now(), voortgang=0,
totaal=len(dagen), laatste_fout=None)
gevonden, fouten = 0, 0
for i, dag in enumerate(dagen, 1):
try:
gevonden += self._store_day(dag, self.fetch_day(dag))
except Exception as e:
fouten += 1
self.status['laatste_fout'] = str(e)
sys.stderr.write(f"Afwezigheden {dag}: {e}\n")
self.status['voortgang'] = i
if fouten >= 3 and fouten == i: # eerste 3 dagen allemaal fout → stoppen
break
if fouten and fouten == self.status['voortgang']:
msg = f"Synchronisatie mislukt: {self.status['laatste_fout']}"
ok = False
else:
msg = (f"{len(dagen)} schooldag(en) gecontroleerd, {gevonden} halve dag(en) met code "
f"{'/'.join(self.codes)} op die dagen" + (f", {fouten} dag(en) met fouten." if fouten else "."))
ok = True
self.status['laatste_resultaat'] = msg
return ok, msg
finally:
self.status['bezig'] = False
self._lock.release()
def sync_in_background(self, volledig=False):
if self.status['bezig']:
return False
self.status.update(bezig=True, voortgang=0, totaal=0) # meteen tonen op de pagina
threading.Thread(target=met_school(self.sync), kwargs={'volledig': volledig}, daemon=True).start()
return True
def dagelijkse_controle(self, nu=None):
"""Start de synchronisatie als het sync-uur van deze school voorbij is en er vandaag nog niet gesynchroniseerd werd."""
nu = nu or datetime.now()
if nu.hour < Config.AFWEZIGHEID_SYNC_UUR:
return
self.ensure_tables()
rows = execute_query("SELECT MAX(Opgehaald) AS m FROM smartschool_afwezigheden_sync")
laatst = rows[0]['m'] if rows else None
if not laatst or laatst.date() < nu.date():
print(f"[{Config.SCHOOLNAME_SHORT}] Afwezigheden: dagelijkse synchronisatie gestart.", flush=True)
ok, msg = self.sync()
print(f"[{Config.SCHOOLNAME_SHORT}] Afwezigheden: {msg}", flush=True)
@classmethod
def start_scheduler(cls):
"""Eén achtergrondtaak die elk kwartier alle scholen met een Smartschool-koppeling overloopt."""
if AfwezighedenService._scheduler:
return
def loop():
time.sleep(30) # app eerst laten opstarten
gedaan = {} # school → laatste dag waarop gecontroleerd
while True:
nu = datetime.now()
for school in alle_scholen():
if not school.heeft('SS_API_PLATFORM', 'SS_API_PW') or gedaan.get(school.code) == nu.date():
continue
try:
with in_school(school):
if nu.hour >= Config.AFWEZIGHEID_SYNC_UUR:
cls().dagelijkse_controle(nu)
gedaan[school.code] = nu.date()
except Exception as e:
sys.stderr.write(f"[{school.code}] Afwezigheden scheduler: {e}\n")
time.sleep(15 * 60)
AfwezighedenService._scheduler = threading.Thread(target=loop, daemon=True)
AfwezighedenService._scheduler.start()
# ---------------- opvragen ----------------
def laatste_sync(self):
rows = execute_query("SELECT MAX(Opgehaald) AS m, MAX(Datum) AS d FROM smartschool_afwezigheden_sync")
return rows[0] if rows else {'m': None, 'd': None}
def overzicht(self, van, tot, code=None, zoek=None, minimum=1):
params = [van, tot]
where = "a.Datum BETWEEN %s AND %s"
codes = [code] if code else self.codes # laatkomerscodes horen niet bij de afwezigheden
where += f" AND a.Code IN ({', '.join(['%s'] * len(codes))})"
params.extend(codes)
sql = f"""
SELECT a.Internnummer,
MAX(l.Voornaam) AS Voornaam, MAX(l.Naam) AS Naam, MAX(l.Gebruikersnaam) AS Gebruikersnaam,
COUNT(*) AS Halve_dagen,
COUNT(DISTINCT a.Datum) AS Dagen,
MIN(a.Datum) AS Eerste, MAX(a.Datum) AS Laatste
FROM smartschool_afwezigheden a
LEFT JOIN Leerlingen l ON l.Internnummer = a.Internnummer
WHERE {where}
GROUP BY a.Internnummer
HAVING COUNT(*) >= %s
ORDER BY Halve_dagen DESC, Laatste DESC
"""
params.append(int(minimum or 1))
rows = execute_query(sql, tuple(params))
if zoek:
z = zoek.lower()
rows = [r for r in rows if z in ' '.join(str(r.get(k) or '') for k in
('Internnummer', 'Voornaam', 'Naam', 'Gebruikersnaam')).lower()]
return rows
def detail(self, internnummer, van, tot, code=None):
params = [internnummer, van, tot]
codes = [code] if code else self.codes
extra = f" AND Code IN ({', '.join(['%s'] * len(codes))})"
params.extend(codes)
rows = execute_query(
"SELECT Datum, Dagdeel, Code FROM smartschool_afwezigheden "
f"WHERE Internnummer = %s AND Datum BETWEEN %s AND %s{extra} ORDER BY Datum DESC, Dagdeel",
tuple(params))
# samenvoegen per dag
per_dag = {}
for r in rows:
d = per_dag.setdefault(r['Datum'], {'Datum': r['Datum'], 'VM': None, 'NM': None})
d[r['Dagdeel']] = r['Code']
return list(per_dag.values())
def laatkomers(self, van, tot):
"""Alle leerlingen die tussen van en tot te laat waren: aantal keer, eerste en laatste datum en de lijst
van data (nieuwste eerst). Eén keer te laat = één halve dag met een laatkomerscode."""
codes = self.laat_codes
if not codes:
return []
rows = execute_query(
f"""SELECT a.Internnummer, a.Datum, a.Dagdeel,
l.Voornaam, l.Naam, l.Gebruikersnaam
FROM smartschool_afwezigheden a
LEFT JOIN Leerlingen l ON l.Internnummer = a.Internnummer
WHERE a.Datum BETWEEN %s AND %s AND a.Code IN ({', '.join(['%s'] * len(codes))})
ORDER BY a.Datum DESC, a.Dagdeel""", (van, tot, *codes))
per = {}
for r in rows:
l = per.setdefault(r['Internnummer'], {
'Internnummer': r['Internnummer'], 'Voornaam': r['Voornaam'], 'Naam': r['Naam'],
'Gebruikersnaam': r['Gebruikersnaam'], 'Aantal': 0, 'Laatste': r['Datum'], 'Eerste': r['Datum'],
'Data': []})
l['Aantal'] += 1
l['Eerste'] = r['Datum']
l['Data'].append((r['Datum'], r['Dagdeel']))
return sorted(per.values(), key=lambda x: (-x['Aantal'], -x['Laatste'].toordinal()))
+186
View File
@@ -0,0 +1,186 @@
"""
Basisonderwijs › Bingel: leerlingenlijst uit Informat (alle instellingsnummers/campussen van de school) voor de import
in Bingel. Kolommen: Klas, Voornaam, Familienaam, Wachtwoord (leeg), Klasnummer, Geslacht (M/V).
Informat-leerlingen-API:
GET <API-URL>/students?schoolYear=&refdate= → de leerlingen (naam, voornaam, geslacht, inschrijvingsId)
GET <API-URL>/registrations?schoolYear=&refdate= → de inschrijvingen, met de klas
Het klasnummer haalt PIVOT niet uit Informat: binnen elke klas nummert het alfabetisch (familienaam, voornaam).
Gekoppeld op inschrijvingsId (anders op pPersoon). De velden worden op verschillende plaatsen en namen gezocht
(zie KANDIDATEN), bij een lijst (bv. klassen in de loop van het jaar) eerst de lopende.
"""
import json
import time
from datetime import date
from config import Config
KANDIDATEN = {
'klas': ('klascode', 'klasCode', 'klas', 'klasnaam', 'klasNaam', 'className', 'class'),
'geslacht': ('geslacht', 'gender', 'sex'),
'voornaam': ('voornaam', 'firstName', 'roepnaam'),
'naam': ('naam', 'lastName', 'familienaam'),
}
CACHE_SECONDEN = 10 * 60
class BingelFout(Exception):
pass
def _datum(w):
try:
return date.fromisoformat(str(w)[:10])
except (TypeError, ValueError):
return None
def _lopend(item, vandaag):
"""Is deze inschrijving lopend op `vandaag` (begin/einde onder verschillende namen)?"""
if not isinstance(item, dict):
return True
begin = next((_datum(item[k]) for k in ('begindatum', 'beginDatum', 'startDate', 'van') if item.get(k)), None)
einde = next((_datum(item[k]) for k in ('einddatum', 'eindDatum', 'endDate', 'tot') if item.get(k)), None)
return (not begin or begin <= vandaag) and (not einde or einde >= vandaag)
def _zoek(obj, namen, vandaag, diepte=0):
"""Eerste niet-lege waarde voor een van `namen`: eerst op dit niveau, dan in (lopende) geneste lijsten/dicts."""
if not isinstance(obj, dict) or diepte > 3:
return None
for n in namen:
w = obj.get(n)
if isinstance(w, dict): # bv. "klas": {"code": "3A", "naam": "3de leerjaar A"}
w = w.get('code') or w.get('naam') or w.get('name')
if w not in (None, '') and not isinstance(w, (dict, list)):
return w
for w in obj.values():
kinderen = [x for x in w if isinstance(x, dict)] if isinstance(w, list) else ([w] if isinstance(w, dict) else [])
kinderen.sort(key=lambda x: not _lopend(x, vandaag)) # lopende inschrijving eerst
for k in kinderen:
gevonden = _zoek(k, namen, vandaag, diepte + 1)
if gevonden not in (None, ''):
return gevonden
return None
def _geslacht(w):
t = str(w or '').strip().upper()
if t in ('M', 'MAN', 'MANNELIJK', 'JONGEN', 'MALE', '1'):
return 'M'
if t in ('V', 'F', 'VROUW', 'VROUWELIJK', 'MEISJE', 'FEMALE', '2'):
return 'V'
return t[:1]
def _nummer(w):
try:
return int(str(w).strip())
except (TypeError, ValueError):
return str(w).strip() if w not in (None, '') else None
def _inschrijvingen(svc, nummer, refdate):
"""Inschrijvingen (met klas) van één instellingsnummer. Geeft (lijst, fout)."""
import requests
from datetime import datetime, timezone
token = svc.get_informat_token(nummer)
if not token:
return [], f"Informat-authenticatie mislukt ({nummer})"
headers = {'Authorization': f'BEARER {token}', 'InstituteNo': nummer, 'Api-Version': '1', 'Accept': 'application/json',
'Timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000Z')}
url = f"{svc.api_base.strip().rstrip('/')}/registrations"
params = {'schoolYear': Config.SCHOOLYEAR, 'refdate': refdate}
try:
r = requests.get(url, params=params, headers=headers, timeout=60)
if r.status_code == 405:
r = requests.post(url, params=params, headers=headers, timeout=60)
except Exception as e:
return [], f"inschrijvingen niet bereikbaar: {e}"
if r.status_code == 404:
return [], None
if r.status_code != 200:
return [], f"inschrijvingen: HTTP {r.status_code} {r.text[:150]}"
data = r.json()
return (data if isinstance(data, list) else (data.get('registrations') or data.get('items') or data.get('data') or [])), None
def leerlingen(vernieuw=False):
"""[{'klas','voornaam','naam','klasnummer','geslacht'}] gesorteerd op klas en klasnummer (alfabetisch), plus een voorbeeld
van de ruwe velden van de eerste leerling (om te controleren). Bewaard voor 10 minuten."""
from tenancy import huidige_school
from services.sync_service import SyncService
school = huidige_school()
bewaard = school.cache.get('bingel')
if bewaard and not vernieuw and time.time() - bewaard[0] < CACHE_SECONDEN:
return bewaard[1], bewaard[2], bewaard[3]
if not school.heeft('INFORMAT_INST_NUMBER', 'INFORMAT_ID', 'INFORMAT_SECRET'):
raise BingelFout("Informat is nog niet ingesteld voor deze school (🔌 Koppelingen › Informat).")
svc, vandaag = SyncService(), date.today()
ruw, gezien, inschr, waarschuwing = [], set(), {}, None
for nummer, naam in (svc.campussen or [(svc.institute_no, '')]):
ok, lijst = svc.informat_lijst(vandaag.isoformat(), Config.SCHOOLYEAR, nummer)
if not ok:
raise BingelFout(f"{naam + ': ' if naam else ''}{lijst}")
reg, fout = _inschrijvingen(svc, nummer, vandaag.isoformat())
if fout:
waarschuwing = f"Klassen konden niet opgehaald worden ({fout})."
for ins in sorted(reg, key=lambda x: _lopend(x, vandaag)): # lopende inschrijving wint
for k in ('inschrijvingsId', 'pInschrijving', 'id'):
if ins.get(k) not in (None, ''):
inschr[('i', str(ins[k]))] = ins
if ins.get('pPersoon') not in (None, ''):
inschr[('p', str(ins['pPersoon']))] = ins
for st in lijst:
sleutel = str(st.get('pPersoon') or st.get('internNummer') or st.get('id') or id(st))
if sleutel not in gezien:
gezien.add(sleutel)
ruw.append(st)
uit = []
for st in ruw:
ins = inschr.get(('i', str(st.get('inschrijvingsId')))) or inschr.get(('p', str(st.get('pPersoon')))) or {}
uit.append({'klas': str(_zoek(ins, KANDIDATEN['klas'], vandaag) or _zoek(st, KANDIDATEN['klas'], vandaag) or '').strip(),
'voornaam': str(_zoek(st, KANDIDATEN['voornaam'], vandaag) or '').strip(),
'naam': str(_zoek(st, KANDIDATEN['naam'], vandaag) or '').strip(),
'klasnummer': None,
'geslacht': _geslacht(_zoek(st, KANDIDATEN['geslacht'], vandaag))})
# Klasnummer: alfabetisch binnen de klas (familienaam, dan voornaam)
uit.sort(key=lambda r: (not r['klas'], r['klas'].lower(), r['naam'].lower(), r['voornaam'].lower()))
vorige, nr = None, 0
for r in uit:
nr = nr + 1 if r['klas'] == vorige else 1
vorige = r['klas']
r['klasnummer'] = nr if r['klas'] else None
# Om na te kijken: de velden van een inschrijving en een voorbeeld van een klas-item (eerste niet-lege)
alle_ins = list({id(v): v for v in inschr.values()}.values())
met_klas = next((i for i in alle_ins if _klas_items(i, vandaag)), None)
klas_item = _klas_items(met_klas, vandaag)[0] if met_klas else None
voorbeeld = {'leerling': _velden(ruw[0]) if ruw else [],
'inschrijving': _velden(met_klas or (alle_ins[0] if alle_ins else {})),
'klas_item': json.dumps(klas_item, ensure_ascii=False, default=str)[:600] if klas_item else ''}
school.cache['bingel'] = (time.time(), uit, voorbeeld, waarschuwing)
return uit, voorbeeld, waarschuwing
def _klas_items(ins, vandaag):
"""De klas-items van een inschrijving (bv. inschrKlassen), lopende eerst."""
items = []
for k, v in (ins or {}).items():
if 'klas' in k.lower() and isinstance(v, list):
items += [x for x in v if isinstance(x, dict)]
return sorted(items, key=lambda x: not _lopend(x, vandaag))
def _velden(obj, prefix=''):
"""Namen van de velden van één leerling (met geneste velden als a.b), om te zien wat Informat teruggeeft."""
uit = []
if isinstance(obj, dict):
for k, v in obj.items():
pad = f"{prefix}{k}"
if isinstance(v, dict):
uit += _velden(v, pad + '.')
elif isinstance(v, list) and v and isinstance(v[0], dict):
uit += _velden(v[0], pad + '.')
else:
uit.append(pad)
return uit
+81
View File
@@ -0,0 +1,81 @@
import threading
import time
import requests
import re
from database import execute_query
from config import Config
from tenancy import school_singleton, met_school
def lokaal_id(naam):
"""Lokaal_ID voor een lokaalnaam; maakt het lokaal aan (zonder campus) als het nog niet bestaat."""
from database import execute_insert
rows = execute_query("SELECT Lokaal_ID FROM Lokalen WHERE Naam = %s ORDER BY Lokaal_ID LIMIT 1", (naam,))
if rows:
return rows[0]['Lokaal_ID']
return execute_insert("INSERT INTO Lokalen (Naam) VALUES (%s)", (naam,))
class CO2Scanner:
def __new__(cls):
# één scanner per school (elke school heeft een eigen netwerkbereik)
def init(inst):
inst.active = False
inst.thread = None
inst.auto_delete = False
inst.conf = Config()
return school_singleton(cls, init)
#start service
def start(self):
if not self.active:
self.active = True
self.thread = threading.Thread(target=met_school(self._run_scan_loop), daemon=True)
self.thread.start()
print("CO2 Scanner Service gestart.")
#killswitch
def stop(self):
self.active = False
print("CO2 Scanner Service gestopt.")
#houd service in actie
def _run_scan_loop(self):
while self.active:
self.perform_scan()
# Wacht het aantal minuten uit je config.ini (bijv. 5)
time.sleep(self.conf.CO2_INTERVAL * 60)
#functie die effectieve taak uitvoerd
def perform_scan(self):
"""De logica uit je originele script om IP's af te gaan."""
start_ip = self.conf.CO2_START_ADDR
end_ip = self.conf.CO2_END_ADDR
network = self.conf.CO2_NETWORK
if not network:
return
for i in range(start_ip, end_ip + 1):
if not self.active and threading.current_thread() == self.thread:
break
ip = f"{network}{i}"
url = f"http://{ip}/v1/constellation"
try:
# Time-out kort houden zodat de scan niet uren duurt
response = requests.get(url, timeout=1.5)
if response.status_code == 200:
data = response.text
# Regex uit je originele script
conc_match = re.search(r'"concentration":\s*{\s*"unit":\s*"ppm",\s*"value":\s*(\d+)', data)
name_match = re.search(r'"device_name":\s*{\s*"value":\s*"(.*?)"', data)
if conc_match:
ppm = int(conc_match.group(1))
lokaal = name_match.group(1) if name_match else ip
# Opslaan in DB (lokaal wordt aangemaakt als het nog niet bestaat)
execute_query(
"INSERT INTO CO2_Metingen (Lokaal_ID, ppm, Timestamp) VALUES (%s, %s, NOW())",
(lokaal_id(lokaal), ppm), commit=True
)
except:
continue
+200
View File
@@ -0,0 +1,200 @@
"""
Co-accounts van alle leerlingen uit Smartschool, met klas en laatste aanmelding.
Werking (achtergrondtaak, per school):
1. getClassListJson → de officiële klassen
2. getAllAccountsExtended → de leerlingen per klas (met hun co-accountvelden, als Smartschool die meegeeft)
3. getUserDetailsByUsername → enkel voor leerlingen waarvan de co-accountvelden ontbraken
Het resultaat komt in de tabel smartschool_coaccounts (telkens volledig vervangen).
Wordt opnieuw opgehaald als de gegevens ouder zijn dan VERVERS_UREN, of met de knop op de pagina.
"""
import json
import sys
import threading
from datetime import datetime
from config import Config
from database import execute_query
from tenancy import school_singleton, met_school
VERVERS_UREN = 24
MAX_CO = 6
def _json(raw):
if raw is None or raw == '':
return None
if isinstance(raw, (int, float)) or (isinstance(raw, str) and raw.strip().lstrip('-').isdigit()):
raise RuntimeError(f"Smartschool foutcode {raw}")
return json.loads(raw) if isinstance(raw, str) else raw
def _lijst(data):
if isinstance(data, dict):
for k in ('accounts', 'users', 'items'):
if isinstance(data.get(k), list):
return data[k]
return list(data.values())
return data or []
def _login(waarde):
"""'2026-09-12 08:01:22' → datetime; leeg, '0000-00-00…' of ongeldig → None."""
t = str(waarde or '').strip()
if not t or t.startswith('0000') or t.lower() in ('none', 'nooit', 'never'):
return None
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M', '%Y-%m-%d', '%d-%m-%Y %H:%M:%S', '%d/%m/%Y %H:%M'):
try:
return datetime.strptime(t[:19], fmt)
except ValueError:
continue
return None
def co_accounts_uit(data):
"""Co-accounts (1-6) uit de gegevens van één leerling. Lege lijst als er geen co-accountvelden zijn."""
uit = []
for i in range(1, MAX_CO + 1):
voornaam = str(data.get(f"voornaam_coaccount{i}") or '').strip()
naam = str(data.get(f"naam_coaccount{i}") or '').strip()
if not (voornaam or naam):
continue
uit.append({'nr': i, 'voornaam': voornaam, 'naam': naam,
'type': str(data.get(f"type_coaccount{i}") or '').strip(),
'email': str(data.get(f"email_coaccount{i}") or '').strip(),
'login': _login(data.get(f"last_successful_login_coaccount{i}"))})
return uit
def heeft_co_velden(data):
return any(k.startswith('voornaam_coaccount') for k in data)
class CoAccountsService:
def __new__(cls):
def init(inst):
inst._lock = threading.Lock()
inst.status = {'bezig': False, 'voortgang': 0, 'totaal': 0, 'fout': None, 'resultaat': None}
return school_singleton(cls, init)
@staticmethod
def ensure_tables():
execute_query("""
CREATE TABLE IF NOT EXISTS smartschool_coaccounts (
Internnummer VARCHAR(32) NOT NULL,
Nr TINYINT NOT NULL,
Gebruikersnaam VARCHAR(100) NULL,
Leerling_naam VARCHAR(100) NULL,
Leerling_voornaam VARCHAR(100) NULL,
Klas VARCHAR(50) NULL,
Voornaam VARCHAR(100) NULL,
Naam VARCHAR(100) NULL,
Type VARCHAR(50) NULL,
Email VARCHAR(255) NULL,
Laatste_login DATETIME NULL,
Opgehaald DATETIME NOT NULL,
PRIMARY KEY (Internnummer, Nr),
KEY idx_coacc_klas (Klas)
)""", commit=True)
# ---------------- opvragen ----------------
def laatst_opgehaald(self):
rows = execute_query("SELECT MAX(Opgehaald) AS m FROM smartschool_coaccounts")
return rows[0]['m'] if rows else None
def alle(self):
return execute_query(
"SELECT * FROM smartschool_coaccounts ORDER BY Klas, Leerling_naam, Leerling_voornaam, Nr") or []
def verouderd(self):
laatst = self.laatst_opgehaald()
return not laatst or (datetime.now() - laatst).total_seconds() > VERVERS_UREN * 3600
# ---------------- ophalen uit Smartschool ----------------
def _leerlingen(self, ss):
"""[(account-dict, klasnaam)] voor alle leerlingen in een officiële klas (elke leerling één keer)."""
klassen = []
for k in _lijst(_json(ss.client.service.getClassListJson(Config.SS_API_PW))):
if not isinstance(k, dict):
continue
if str(k.get('isKlas', k.get('isOfficial', True))).lower() in ('false', '0', 'none'):
continue
code = str(k.get('code') or '').strip()
if code:
klassen.append((code, str(k.get('name') or code).strip()))
klassen.sort(key=lambda x: x[1])
self.status.update(totaal=len(klassen), voortgang=0)
gezien, uit = set(), []
for i, (code, naam) in enumerate(klassen, 1):
try:
leden = _lijst(_json(ss.client.service.getAllAccountsExtended(Config.SS_API_PW, code, '0')))
except Exception as e:
sys.stderr.write(f"Co-accounts: klas {naam}: {e}\n")
leden = []
for a in leden:
if not isinstance(a, dict):
continue
rol = str(a.get('basisrol') or a.get('basisRol') or '').lower()
if rol and rol not in ('1', 'leerling', 'student'):
continue
sleutel = str(a.get('internnummer') or a.get('gebruikersnaam') or '').strip()
if sleutel and sleutel not in gezien:
gezien.add(sleutel)
uit.append((a, naam))
self.status['voortgang'] = i
return uit
def sync(self):
if not self._lock.acquire(blocking=False):
return False, "Er loopt al een ophaling."
try:
from services.smartschool_api import SmartschoolService
self.status.update(bezig=True, voortgang=0, totaal=0, fout=None)
self.ensure_tables()
ss = SmartschoolService(Config.SS_API_PLATFORM, Config.SS_API_PW)
leerlingen = self._leerlingen(ss)
# Leerlingen zonder co-accountvelden in de klaslijst: details apart opvragen
zonder = [i for i, (a, _) in enumerate(leerlingen) if not heeft_co_velden(a)]
self.status.update(totaal=self.status['totaal'] + len(zonder))
for n, i in enumerate(zonder, 1):
a, klas = leerlingen[i]
try:
d = _json(ss.client.service.getUserDetailsByUsername(Config.SS_API_PW, a.get('gebruikersnaam')))
if isinstance(d, dict):
leerlingen[i] = ({**a, **d}, klas)
except Exception as e:
sys.stderr.write(f"Co-accounts: {a.get('gebruikersnaam')}: {e}\n")
self.status['voortgang'] += 1
nu = datetime.now()
rijen = []
for a, klas in leerlingen:
intern = str(a.get('internnummer') or a.get('gebruikersnaam') or '').strip()
for c in co_accounts_uit(a):
rijen.append((intern, c['nr'], a.get('gebruikersnaam'), a.get('naam'), a.get('voornaam'), klas,
c['voornaam'], c['naam'], c['type'], c['email'], c['login'], nu))
if not leerlingen:
raise RuntimeError("Smartschool gaf geen leerlingen terug. Heeft het webservice-wachtwoord de "
"rechten voor gebruikers en klassen?")
execute_query("DELETE FROM smartschool_coaccounts", commit=True)
for r in rijen:
execute_query("INSERT INTO smartschool_coaccounts (Internnummer, Nr, Gebruikersnaam, Leerling_naam, "
"Leerling_voornaam, Klas, Voornaam, Naam, Type, Email, Laatste_login, Opgehaald) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", r, commit=True)
nooit = sum(1 for r in rijen if not r[10])
msg = (f"{len(leerlingen)} leerlingen en {len(rijen)} co-accounts opgehaald; "
f"{nooit} co-account(s) nog nooit aangemeld.")
self.status['resultaat'] = msg
return True, msg
except Exception as e:
self.status['fout'] = str(e)
return False, f"Ophalen mislukt: {e}"
finally:
self.status['bezig'] = False
self._lock.release()
def sync_op_achtergrond(self):
if self.status['bezig']:
return False
self.status.update(bezig=True, voortgang=0, totaal=0)
threading.Thread(target=met_school(self.sync), daemon=True).start()
return True
+391
View File
@@ -0,0 +1,391 @@
"""
Gmail › Contacten: twee soorten contactbeheer in Google Workspace.
1. Adresboek van de organisatie (Directory API, admin.directory.user)
Per gebruiker 'includeInGlobalAddressList': zichtbaar of verborgen in het adresboek en bij het aanvullen van adressen.
2. Contactlijsten in de mailboxen (People API, https://www.googleapis.com/auth/contacts)
Een lijst (label) met contacten die PIVOT in de persoonlijke contacten van elke gebruiker in de gekozen OU's zet en
bijhoudt. PIVOT herkent zijn eigen contacten aan een eigen veld (userDefined 'PIVOT'), en past enkel die aan.
"""
import hashlib
import json
import sys
import threading
import time
from datetime import datetime
from config import Config
from database import execute_query
from tenancy import school_singleton, met_school, google_credentials, huidige_school
SCOPE_PEOPLE = 'https://www.googleapis.com/auth/contacts'
PIVOT_SLEUTEL = 'PIVOT'
class ContactFout(Exception):
pass
def vriendelijk(e, scope=None):
t = str(e)
laag = t.lower()
if 'unauthorized_client' in laag or 'access_denied' in laag or 'invalid_scope' in laag:
return ("Google weigert de toegang: voeg in de Admin-console › Beveiliging › API-beheer › Domeinbrede delegatie "
f"het bereik {scope or '(zie handleiding)'} toe bij het serviceaccount van PIVOT.")
if 'has not been used' in laag or 'is disabled' in laag or 'accessnotconfigured' in laag or 'service_disabled' in laag:
return ("De nodige API staat niet aan in het Google Cloud-project van het serviceaccount "
"(voor contactlijsten: People API).")
return t[:300]
def _directory():
from googleapiclient.discovery import build
creds = google_credentials(Config.GOOGLE_API_SCOPES, subject=Config.GOOGLE_ADMIN_EMAIL)
return build('admin', 'directory_v1', credentials=creds, static_discovery=False, cache_discovery=False)
def ou_lijst():
try:
resp = _directory().orgunits().list(customerId='my_customer', type='all').execute()
except Exception as e:
raise ContactFout(f"OU's ophalen mislukt: {vriendelijk(e)}")
return sorted([o['orgUnitPath'] for o in resp.get('organizationUnits', [])] + ['/'], key=str.lower)
def gebruikers_in(ous, met_sub=True, velden='primaryEmail,name,orgUnitPath,includeInGlobalAddressList,suspended'):
api, uit, gezien = _directory(), [], set()
for ou in ous:
token = None
while True:
params = dict(customer='my_customer', maxResults=500, pageToken=token,
fields=f'nextPageToken,users({velden})')
if ou != '/':
params['query'] = "orgUnitPath='" + ou.replace("\\", "\\\\").replace("'", "\\'") + "'"
try:
resp = api.users().list(**params).execute()
except Exception as e:
raise ContactFout(f"Gebruikers van {ou} ophalen mislukt: {vriendelijk(e)}")
for u in resp.get('users', []):
pad = u.get('orgUnitPath') or '/'
if (not met_sub and pad != ou) or u['primaryEmail'].lower() in gezien:
continue
gezien.add(u['primaryEmail'].lower())
n = u.get('name') or {}
uit.append({'email': u['primaryEmail'].lower(), 'naam': n.get('fullName') or u['primaryEmail'],
'achternaam': n.get('familyName', ''), 'ou': pad, 'geschorst': bool(u.get('suspended')),
'zichtbaar': u.get('includeInGlobalAddressList', True) is not False})
token = resp.get('nextPageToken')
if not token:
break
return sorted(uit, key=lambda x: (x['achternaam'].lower(), x['naam'].lower()))
# =====================================================================
# 1. Adresboek: zichtbaar of verborgen
# =====================================================================
def zet_zichtbaar(emails, zichtbaar):
api, fouten = _directory(), []
for e in emails:
try:
api.users().patch(userKey=e, body={'includeInGlobalAddressList': bool(zichtbaar)}).execute()
except Exception as ex:
fouten.append(f"{e}: {vriendelijk(ex)}")
return fouten
# =====================================================================
# 2. Contactlijsten in de mailboxen (People API)
# =====================================================================
def ensure_tables():
execute_query("""
CREATE TABLE IF NOT EXISTS contact_lijsten (
ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
Naam VARCHAR(100) NOT NULL,
OUs TEXT NULL,
Met_sub_ous TINYINT NOT NULL DEFAULT 1,
Bijgewerkt DATETIME NULL,
Door VARCHAR(255) NULL,
Laatste_sync DATETIME NULL,
Resultaat VARCHAR(500) NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True)
execute_query("""
CREATE TABLE IF NOT EXISTS contact_lijst_items (
ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
Lijst_ID INT NOT NULL,
Voornaam VARCHAR(100) NULL,
Achternaam VARCHAR(100) NULL,
Organisatie VARCHAR(150) NULL,
Functie VARCHAR(150) NULL,
Email VARCHAR(255) NULL,
Telefoon VARCHAR(60) NULL,
Notitie VARCHAR(500) NULL,
KEY idx_cli_lijst (Lijst_ID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True)
def _schoon(c):
uit = {k: (str(c.get(k) or '').strip())[:500] for k in
('voornaam', 'achternaam', 'organisatie', 'functie', 'email', 'telefoon', 'notitie')}
if not (uit['voornaam'] or uit['achternaam'] or uit['organisatie']):
raise ContactFout("Geef bij elk contact minstens een naam of een organisatie.")
if uit['email'] and '@' not in uit['email']:
raise ContactFout(f"Ongeldig e-mailadres: {uit['email']}")
return uit
def _ous(r):
try:
return [o for o in json.loads(r.get('OUs') or '[]') if isinstance(o, str)]
except ValueError:
return []
def lijsten():
ensure_tables()
rijen = execute_query("SELECT l.*, (SELECT COUNT(*) FROM contact_lijst_items i WHERE i.Lijst_ID = l.ID) AS aantal "
"FROM contact_lijsten l ORDER BY l.Naam") or []
for r in rijen:
r['ous'] = _ous(r)
return rijen
def lijst(lijst_id):
ensure_tables()
r = execute_query("SELECT * FROM contact_lijsten WHERE ID = %s", (lijst_id,))
if not r:
return None
r = r[0]
r['ous'] = _ous(r)
r['items'] = execute_query("SELECT * FROM contact_lijst_items WHERE Lijst_ID = %s ORDER BY Achternaam, Voornaam, "
"Organisatie", (lijst_id,)) or []
return r
def lijst_bewaren(lijst_id, naam, ous, met_sub, items, door):
ensure_tables()
naam = (naam or '').strip()[:100]
if not naam:
raise ContactFout("Geef de lijst een naam (die wordt het label in Google Contacten).")
schoon = []
for i in items:
if any(str(i.get(k) or '').strip() for k in i if k != 'id'):
c = _schoon(i)
c['id'] = int(i['id']) if str(i.get('id') or '').isdigit() else None
schoon.append(c)
if lijst_id:
execute_query("UPDATE contact_lijsten SET Naam = %s, OUs = %s, Met_sub_ous = %s, Bijgewerkt = NOW(), Door = %s "
"WHERE ID = %s", (naam, json.dumps(sorted(set(ous))), 1 if met_sub else 0, door, lijst_id),
commit=True)
else:
from database import execute_insert
lijst_id = execute_insert("INSERT INTO contact_lijsten (Naam, OUs, Met_sub_ous, Bijgewerkt, Door) "
"VALUES (%s, %s, %s, NOW(), %s)",
(naam, json.dumps(sorted(set(ous))), 1 if met_sub else 0, door))
# Bestaande contacten behouden hun ID (zo herkent PIVOT ze in de mailboxen en past het ze aan i.p.v. opnieuw te maken)
bestaand = {r['ID'] for r in execute_query("SELECT ID FROM contact_lijst_items WHERE Lijst_ID = %s", (lijst_id,)) or []}
behouden = set()
velden = (lambda i: (i['voornaam'], i['achternaam'], i['organisatie'], i['functie'], i['email'], i['telefoon'],
i['notitie']))
for i in schoon:
if i['id'] in bestaand:
execute_query("UPDATE contact_lijst_items SET Voornaam = %s, Achternaam = %s, Organisatie = %s, Functie = %s, "
"Email = %s, Telefoon = %s, Notitie = %s WHERE ID = %s AND Lijst_ID = %s",
velden(i) + (i['id'], lijst_id), commit=True)
behouden.add(i['id'])
else:
execute_query("INSERT INTO contact_lijst_items (Lijst_ID, Voornaam, Achternaam, Organisatie, Functie, Email, "
"Telefoon, Notitie) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)", (lijst_id,) + velden(i), commit=True)
for weg in bestaand - behouden:
execute_query("DELETE FROM contact_lijst_items WHERE ID = %s", (weg,), commit=True)
return lijst_id
def lijst_verwijderen_db(lijst_id):
execute_query("DELETE FROM contact_lijst_items WHERE Lijst_ID = %s", (lijst_id,), commit=True)
execute_query("DELETE FROM contact_lijsten WHERE ID = %s", (lijst_id,), commit=True)
def _people(mailbox):
from googleapiclient.discovery import build
creds = google_credentials([SCOPE_PEOPLE], subject=mailbox)
try:
return build('people', 'v1', credentials=creds, static_discovery=True, cache_discovery=False)
except Exception:
return build('people', 'v1', credentials=creds, static_discovery=False, cache_discovery=False)
def _persoon(item, merk, groep):
p = {'names': [{'givenName': item['Voornaam'] or '', 'familyName': item['Achternaam'] or ''}],
'userDefined': [{'key': PIVOT_SLEUTEL, 'value': merk}],
'memberships': [{'contactGroupMembership': {'contactGroupResourceName': groep}}]}
if not (item['Voornaam'] or item['Achternaam']):
p['names'] = [{'givenName': item['Organisatie'] or item['Email'] or ''}]
if item['Email']:
p['emailAddresses'] = [{'value': item['Email'], 'type': 'work'}]
if item['Telefoon']:
p['phoneNumbers'] = [{'value': item['Telefoon'], 'type': 'work'}]
if item['Organisatie'] or item['Functie']:
p['organizations'] = [{'name': item['Organisatie'] or '', 'title': item['Functie'] or ''}]
if item['Notitie']:
p['biographies'] = [{'value': item['Notitie'], 'contentType': 'TEXT_PLAIN'}]
return p
def _hash(item):
return hashlib.sha1(json.dumps([item[k] for k in ('Voornaam', 'Achternaam', 'Organisatie', 'Functie', 'Email',
'Telefoon', 'Notitie')]).encode()).hexdigest()[:12]
PERSON_FIELDS = 'names,emailAddresses,phoneNumbers,organizations,biographies,userDefined,memberships'
def _herhaal(fn, pogingen=5):
for poging in range(pogingen):
try:
return fn()
except Exception as e:
status = getattr(getattr(e, 'resp', None), 'status', None)
if status in (429, 500, 503) and poging < pogingen - 1:
time.sleep(2 ** poging + 1)
continue
raise
def _pivot_contacten(svc, lijst_id):
"""{item-sleutel: person} van de contacten die PIVOT voor deze lijst in deze mailbox zette."""
uit, token = {}, None
prefix = f"lijst{lijst_id}:"
while True:
resp = _herhaal(lambda: svc.people().connections().list(
resourceName='people/me', pageSize=1000, pageToken=token, personFields=PERSON_FIELDS).execute())
for p in resp.get('connections', []):
merk = next((u.get('value', '') for u in p.get('userDefined', []) if u.get('key') == PIVOT_SLEUTEL), '')
if merk.startswith(prefix):
uit[merk.split('#')[0]] = (merk, p)
token = resp.get('nextPageToken')
if not token:
return uit
def _groep(svc, naam, maak=True):
resp = _herhaal(lambda: svc.contactGroups().list(pageSize=1000).execute())
for g in resp.get('contactGroups', []):
if g.get('groupType') == 'USER_CONTACT_GROUP' and g.get('name') == naam:
return g['resourceName']
if not maak:
return None
return _herhaal(lambda: svc.contactGroups().create(body={'contactGroup': {'name': naam}}).execute())['resourceName']
def sync_mailbox(email, l, verwijderen=False, oude_naam=None):
"""Zet de contacten van lijst `l` in de mailbox (of haalt ze weg). Geeft (toegevoegd, gewijzigd, verwijderd)."""
svc = _people(email)
bestaand = _pivot_contacten(svc, l['ID'])
toegevoegd = gewijzigd = verwijderd = 0
gewenst = {} if verwijderen else {f"lijst{l['ID']}:{i['ID']}": i for i in l['items']}
groep = None
if gewenst:
groep = _groep(svc, l['Naam'])
if oude_naam and oude_naam != l['Naam']:
oud = _groep(svc, oude_naam, maak=False)
if oud and oud != groep:
_herhaal(lambda: svc.contactGroups().delete(resourceName=oud, deleteContacts=False).execute())
for sleutel, item in gewenst.items():
merk = f"{sleutel}#{_hash(item)}"
if sleutel in bestaand:
oud_merk, p = bestaand[sleutel]
in_groep = any(m.get('contactGroupMembership', {}).get('contactGroupResourceName') == groep
for m in p.get('memberships', []))
if oud_merk == merk and in_groep:
continue
body = _persoon(item, merk, groep)
body['etag'] = p.get('etag')
body['memberships'] = [m for m in p.get('memberships', [])
if m.get('contactGroupMembership', {}).get('contactGroupResourceName') != groep] \
+ body['memberships']
_herhaal(lambda: svc.people().updateContact(
resourceName=p['resourceName'], updatePersonFields=PERSON_FIELDS, body=body).execute())
gewijzigd += 1
else:
_herhaal(lambda: svc.people().createContact(body=_persoon(item, merk, groep)).execute())
toegevoegd += 1
for sleutel, (merk, p) in bestaand.items():
if sleutel not in gewenst:
_herhaal(lambda: svc.people().deleteContact(resourceName=p['resourceName']).execute())
verwijderd += 1
if verwijderen:
g = _groep(svc, l['Naam'], maak=False)
if g:
try:
_herhaal(lambda: svc.contactGroups().delete(resourceName=g, deleteContacts=False).execute())
except Exception:
pass
return toegevoegd, gewijzigd, verwijderd
class LijstSync:
def __new__(cls):
def init(inst):
inst._lock = threading.Lock()
inst.status = {'bezig': False, 'lijst': None, 'voortgang': 0, 'totaal': 0, 'fouten': 0, 'fout': None,
'resultaat': None, 'foutvoorbeelden': []}
return school_singleton(cls, init)
def run(self, lijst_id, verwijderen=False, oude_naam=None, oude_ous=None):
if not self._lock.acquire(blocking=False):
return False, "Er loopt al een opdracht."
s = self.status
try:
l = lijst(lijst_id)
if not l:
raise ContactFout("Lijst niet gevonden.")
s.update(bezig=True, lijst=l['Naam'], voortgang=0, totaal=0, fouten=0, fout=None, resultaat=None,
foutvoorbeelden=[])
ontvangers = gebruikers_in(l['ous'], bool(l['Met_sub_ous']), 'primaryEmail,name,orgUnitPath,suspended')
ontvangers = [u for u in ontvangers if not u['geschorst']]
# Wie niet meer in de gekozen OU's zit (OU's gewijzigd): de contacten van deze lijst weghalen
weg = []
if oude_ous is not None:
nu = {u['email'] for u in ontvangers}
weg = [u for u in gebruikers_in(oude_ous, True, 'primaryEmail,name,orgUnitPath,suspended')
if u['email'] not in nu and not u['geschorst']]
s['totaal'] = len(ontvangers) + len(weg)
t = {'toegevoegd': 0, 'gewijzigd': 0, 'verwijderd': 0}
for u, enkel_weg in [(u, verwijderen) for u in ontvangers] + [(u, True) for u in weg]:
try:
a, b, c = sync_mailbox(u['email'], l, verwijderen=enkel_weg, oude_naam=oude_naam)
t['toegevoegd'] += a; t['gewijzigd'] += b; t['verwijderd'] += c
except Exception as e:
s['fouten'] += 1
if len(s['foutvoorbeelden']) < 6:
s['foutvoorbeelden'].append(f"{u['email']}: {vriendelijk(e, SCOPE_PEOPLE)[:180]}")
sys.stderr.write(f"Contactlijst {l['Naam']} → {u['email']}: {e}\n")
s['voortgang'] += 1
if verwijderen:
msg = f"Lijst '{l['Naam']}' uit {len(ontvangers)} mailbox(en) gehaald ({t['verwijderd']} contacten)"
else:
msg = (f"Lijst '{l['Naam']}' in {len(ontvangers)} mailbox(en): {t['toegevoegd']} toegevoegd, "
f"{t['gewijzigd']} bijgewerkt, {t['verwijderd']} verwijderd")
if s['fouten']:
msg += f", {s['fouten']} mailbox(en) mislukt"
s['resultaat'] = msg + '.'
if verwijderen and not s['fouten']:
lijst_verwijderen_db(lijst_id)
else:
execute_query("UPDATE contact_lijsten SET Laatste_sync = NOW(), Resultaat = %s WHERE ID = %s",
(s['resultaat'][:500], lijst_id), commit=True)
return True, s['resultaat']
except Exception as e:
s['fout'] = vriendelijk(e, SCOPE_PEOPLE)
return False, s['fout']
finally:
s['bezig'] = False
self._lock.release()
def start(self, *args, **kw):
if self.status['bezig']:
return False
self.status.update(bezig=True, voortgang=0, totaal=0, fout=None, resultaat=None)
threading.Thread(target=met_school(lambda: self.run(*args, **kw)), daemon=True).start()
return True
+405
View File
@@ -0,0 +1,405 @@
"""
Leerlingfacturatie: de facturen en creditnota's van de leerlingen uit Informat (Rekeningen API / Invoice API).
- Token: zelfde identityserver en client-ID/secret als de leerlingen-API, met scope
api_informat_invoices.invoices.read.<toegangsgroep-GUID>_<installatie-ID>
Die scope krijgt de client zodra de school in Informat de app toegang geeft tot de rekeningen (toegangsgroep);
Informat stuurt dan een e-mail naar de eigenaar van de app. De accessGroupIdentifier in de bevragingen en
PDF-adressen is het deel na "read.": <toegangsgroep-GUID>_<installatie-ID>.
Staat ze niet in de schoolinstellingen, dan vraagt PIVOT een token zonder scope en leest de toegekende scopes.
- API: GraphQL op <API-URL>/graphql (standaard https://api.informatsoftware.be/invoices/v1/graphql). Per schooljaar de factuurgroepen (enkel type STUDENT) en per groep de
geboekte facturen en creditnota's (totaal, betaald, open, OGM, leerling).
- PDF: <API-URL>/download/<accessGroupIdentifier>/<invoiceId>/pdf (400 = nog niet aangemaakt: dan eerst .../create/...).
Het resultaat wordt 10 minuten per school en schooljaar bewaard.
"""
import re
import time
from datetime import date
import requests
from config import Config
STANDAARD_API = 'https://api.informatsoftware.be/invoices/v1' # centraal APIM-adres uit de implementatiegids
SCOPE_RE = re.compile(r'api_informat_invoices\.invoices\.read\.([0-9a-fA-F-]{36})_(\w+)')
CACHE_SECONDEN = 600
TIMEOUT = (15, 120)
Q_GROEPEN = """
query groepen($schoolYear: String!, $accessGroupIdentifier: String!) {
accessGroup(accessGroupIdentifier: $accessGroupIdentifier) {
invoiceGroups(schoolYear: $schoolYear, where: {invoiceGroupType: {eq: STUDENT}}, order: [{name: ASC}]) {
id name invoiceGroupType schoolYear fromDate toDate
}
}
}"""
Q_FACTUREN = """
query facturen($schoolYear: String!, $accessGroupIdentifier: String!, $invoiceGroupId: Int!) {
accessGroup(accessGroupIdentifier: $accessGroupIdentifier) {
invoiceGroups(schoolYear: $schoolYear, where: {id: {eq: $invoiceGroupId}}) {
id name
invoices(order: [{printDate: DESC}]) {
invoiceId invoiceType printDate dueDate total paid open paymentReference paymentReferenceInvoice
recipient { databaseId firstName lastName stamNumber class classNumber }
}
}
}
}"""
Q_NAAM = """
query naam($accessGroupIdentifier: String!) {
accessGroup(accessGroupIdentifier: $accessGroupIdentifier) { guid name }
}"""
MAX_PROBEER = 400 # meer kandidaat-scopes dan dit proberen we niet één voor één
class FacturatieFout(Exception):
pass
def schooljaren(aantal=3):
"""['2026-27', '2025-26', ...] vanaf het huidige schooljaar."""
vandaag = date.today()
start = vandaag.year if vandaag.month >= 9 else vandaag.year - 1
return [f"{j}-{str(j + 1)[-2:]}" for j in range(start, start - aantal, -1)]
def _datum(w):
if not w:
return None
try:
return date.fromisoformat(str(w)[:10])
except ValueError:
return None
def _bedrag(w):
try:
return round(float(w or 0), 2)
except (TypeError, ValueError):
return 0.0
def status_van(f):
if f['type'] == 'CREDIT_NOTE':
return 'credit'
if abs(f['open']) < 0.005:
return 'betaald'
if abs(f['betaald']) >= 0.005:
return 'gedeeltelijk'
return 'open'
def _scopes_uit(js):
"""Scopes uit een token-antwoord: het veld 'scope' of, als dat ontbreekt, de claim 'scope' in het JWT."""
import base64, json
scope = js.get('scope')
if not scope:
try:
deel = js['access_token'].split('.')[1]
claims = json.loads(base64.urlsafe_b64decode(deel + '=' * (-len(deel) % 4)))
scope = claims.get('scope')
except Exception:
scope = None
if isinstance(scope, str):
scope = scope.split()
return [str(x) for x in (scope or [])]
class FacturatieService:
def __init__(self):
from tenancy import huidige_school
self.school = huidige_school()
self.base = (self.school.get('INFORMAT_FACTUUR_API') or STANDAARD_API).rstrip('/')
# ---------------- authenticatie ----------------
def _token_aanvraag(self, scope=None):
data = {'grant_type': 'client_credentials', 'client_id': Config.INFORMAT_ID,
'client_secret': Config.INFORMAT_SECRET}
if scope:
data['scope'] = scope
try:
r = requests.post(Config.INFORMAT_AUTH_URL, data=data, timeout=15)
except Exception as e:
raise FacturatieFout(f"Informat (identityserver) is niet bereikbaar: {e}")
try:
js = r.json()
except Exception:
js = {}
if r.status_code != 200 or not js.get('access_token'):
fout = js.get('error') or f"HTTP {r.status_code}"
if fout == 'invalid_scope':
raise FacturatieFout(f"Informat weigert de scope {scope or '(geen)'} (invalid_scope). Heeft de school in "
"Informat de app toegang gegeven tot de rekeningen (toegangsgroep)?")
if fout == 'invalid_client':
raise FacturatieFout("Informat kent deze client-ID/secret niet (invalid_client). Kijk Koppelingen › Informat na.")
raise FacturatieFout(f"Aanmelden bij Informat mislukt: {fout}")
return js
def scopes(self):
"""[(scope, toegangsgroep-GUID)] uit de instellingen, of opgezocht bij Informat."""
ingesteld = [x.strip() for x in re.split(r'[,\s]+', self.school.get('INFORMAT_FACTUUR_SCOPE') or '') if x.strip()]
if ingesteld:
uit = []
for sc in ingesteld:
m = SCOPE_RE.search(sc)
if not m:
raise FacturatieFout(f"'{sc}' is geen geldige scope voor de Rekeningen API "
"(verwacht: api_informat_invoices.invoices.read.<toegangsgroep>_<installatie>).")
uit.append((m.group(0), f"{m.group(1)}_{m.group(2)}")) # accessGroupIdentifier = <GUID>_<installatie>
return uit
bewaard = self.school.cache.get('factuur_scopes')
if bewaard and time.time() - bewaard[0] < 3600:
return bewaard[1]
uit = self._bewaarde_scopes()
if not uit:
uit, info = self.zoek_scopes()
if not uit:
raise FacturatieFout("PIVOT kon de scope van de Rekeningen API niet zelf vinden. " + info + " Vul bij "
"Koppelingen › Informat de 'Scope Rekeningen API' in "
"(api_informat_invoices.invoices.read.<toegangsgroep-GUID>_<installatie-ID>). "
"Informat mailt die scope naar de eigenaar van de app zodra de school toegang geeft.")
self._bewaar_scopes(uit)
self.school.cache['factuur_scopes'] = (time.time(), uit)
return uit
# ---------------- scope zelf zoeken ----------------
def _groepnamen(self):
"""De ingevulde naam/namen van de toegangsgroep(en) in Rekeningen (veld 'Toegangsgroep Rekeningen')."""
return [x.strip() for x in str(self.school.get('INFORMAT_FACTUUR_GROEP') or '').split(';') if x.strip()]
def _instelling_sleutel(self):
# Hangt ook af van de ingevulde toegangsgroep: pas je die aan, dan zoekt PIVOT opnieuw
import zlib
groep = ';'.join(n.lower() for n in self._groepnamen())
return f"factuur_scope_{self.school.code}"[:40] + f"_{zlib.crc32(groep.encode()):08x}"
def _bewaarde_scopes(self):
"""Eerder gevonden scopes van deze school (centrale tabel PIVOT_instellingen)."""
try:
from database import central_query
rij = central_query("SELECT Waarde FROM PIVOT_instellingen WHERE Sleutel = %s", (self._instelling_sleutel(),))
except Exception:
return []
waarde = (rij[0]['Waarde'] if rij else '') or ''
return [(f"api_informat_invoices.invoices.read.{g}_{i}", f"{g}_{i}") for g, i in SCOPE_RE.findall(waarde)]
def _bewaar_scopes(self, uit):
try:
from database import central_query
from services import ipblokkade
ipblokkade.ensure_table()
central_query("INSERT INTO PIVOT_instellingen (Sleutel, Waarde, Bijgewerkt, Door) VALUES (%s, %s, NOW(), 'PIVOT') "
"ON DUPLICATE KEY UPDATE Waarde = VALUES(Waarde), Bijgewerkt = NOW()",
(self._instelling_sleutel(), ' '.join(sc for sc, _ in uit)[:255]), commit=True)
except Exception:
pass
def vergeet_scopes(self):
self.school.cache.pop('factuur_scopes', None)
try:
from database import central_query
central_query("DELETE FROM PIVOT_instellingen WHERE Sleutel = %s", (self._instelling_sleutel(),), commit=True)
except Exception:
pass
def _discovery_scopes(self):
"""Alle rekeningen-scopes die de identityserver publiek bekendmaakt (.well-known/openid-configuration)."""
basis = Config.INFORMAT_AUTH_URL.split('/connect/')[0].rstrip('/')
r = requests.get(f"{basis}/.well-known/openid-configuration", timeout=30)
r.raise_for_status()
return [sc for sc in (r.json().get('scopes_supported') or []) if SCOPE_RE.fullmatch(str(sc))]
def _mag(self, scope):
"""True als deze client een token voor `scope` krijgt (loopt in een aparte thread: school meegeven)."""
from tenancy import in_school
try:
with in_school(self.school):
self._token(scope)
return True
except FacturatieFout:
return False
except Exception:
return False
def _naam_toegangsgroep(self, scope, ag):
try:
data = self._gql(scope, Q_NAAM, {'accessGroupIdentifier': ag})
return ((data.get('accessGroup') or {}).get('name') or '').strip()
except FacturatieFout:
return ''
def zoek_scopes(self):
"""Zoekt de rekeningen-scope(s) van deze school. Geeft ([(scope, accessGroupIdentifier)], uitleg).
1) token zonder scope: soms staan de toegekende scopes in het antwoord of in het token;
2) anders: de publieke lijst van scopes van de identityserver, en daarvan enkel die waarvoor deze client
een token krijgt (de toegangsgroepen die scholen aan deze app gaven);
3) heeft de client toegang tot meer dan één school: de toegangsgroep waarvan de naam het instellingsnummer
(of de naam) van deze school bevat, bv. '000448 BS Irishof Kapellen'."""
info = ''
kandidaten = []
try:
kandidaten = [sc for sc in _scopes_uit(self._token_aanvraag(None)) if SCOPE_RE.fullmatch(sc)]
except FacturatieFout:
pass
if not kandidaten:
try:
alle = self._discovery_scopes()
except Exception as e:
return [], f"De lijst van scopes van Informat kon niet gelezen worden ({e})."
if not alle:
return [], "Informat maakt de rekeningen-scopes niet publiek bekend."
if len(alle) > MAX_PROBEER:
return [], f"Informat kent {len(alle)} rekeningen-scopes; te veel om ze allemaal te proberen."
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=8) as pool:
kandidaten = [sc for sc, ok in zip(alle, pool.map(self._mag, alle)) if ok]
if not kandidaten:
return [], f"Deze Informat-client krijgt voor geen enkele van de {len(alle)} rekeningen-scopes toegang."
paren = [(sc, '_'.join(SCOPE_RE.fullmatch(sc).groups())) for sc in dict.fromkeys(kandidaten)]
gevraagd = [n.lower() for n in self._groepnamen()]
if gevraagd:
# Toegangsgroep ingevuld: enkel de groep(en) met precies die naam (of die de naam bevatten)
namen, juist = [], []
for sc, ag in paren:
gnaam = self._naam_toegangsgroep(sc, ag)
namen.append(gnaam or ag)
laag = ' '.join(gnaam.lower().split())
if any(laag == g or g in laag for g in (' '.join(x.split()) for x in gevraagd)):
juist.append((sc, ag))
if juist:
return juist, ''
return [], ("Geen toegangsgroep gevonden met de naam '" + '; '.join(self._groepnamen()) + "'. Deze client heeft "
"toegang tot: " + ', '.join(namen) + ".")
if len(paren) == 1:
return paren, ''
# Meerdere toegangsgroepen: die van deze school kiezen op naam
from tenancy import informat_campussen
nummers = {n for n, _ in informat_campussen(self.school.waarden.get('INFORMAT_INST_NUMBER'))}
nummers |= {str(self.school.get('INSTELLINGSNUMMER') or '').strip()} - {''}
naam = (self.school.naam or '').lower()
eigen, namen = [], []
for sc, ag in paren:
gnaam = self._naam_toegangsgroep(sc, ag)
namen.append(gnaam or ag)
laag = gnaam.lower()
if any(n and (n in laag or n.lstrip('0') and re.search(rf'\b0*{re.escape(n.lstrip("0"))}\b', laag)) for n in nummers) \
or (naam and naam in laag):
eigen.append((sc, ag))
if eigen:
return eigen, ''
return [], ("Deze client heeft toegang tot meerdere toegangsgroepen (" + ', '.join(namen) + "), maar geen "
"enkele naam bevat het instellingsnummer van deze school.")
def _token(self, scope):
sleutel = ('factuur_token', scope)
bewaard = self.school.cache.get(sleutel)
if bewaard and time.time() < bewaard[0]:
return bewaard[1]
js = self._token_aanvraag(scope)
self.school.cache[sleutel] = (time.time() + int(js.get('expires_in') or 3600) - 60, js['access_token'])
return js['access_token']
# ---------------- GraphQL ----------------
def _gql(self, scope, query, variabelen):
headers = {'Authorization': f"BEARER {self._token(scope)}", 'Content-Type': 'application/json',
'Accept': 'application/json'}
try:
r = requests.post(f"{self.base}/graphql", json={'query': query, 'variables': variabelen},
headers=headers, timeout=TIMEOUT)
except requests.exceptions.Timeout:
raise FacturatieFout("De Rekeningen API van Informat antwoordde niet op tijd. Probeer later opnieuw.")
except Exception as e:
raise FacturatieFout(f"De Rekeningen API van Informat is niet bereikbaar: {e}")
if r.status_code == 429:
raise FacturatieFout("Informat meldt te veel aanvragen (429). Probeer over een minuut opnieuw.")
if r.status_code in (401, 403):
raise FacturatieFout(f"Informat weigert de toegang tot de rekeningen ({r.status_code}).")
if not r.text.strip():
return {}
try:
js = r.json()
except Exception:
raise FacturatieFout(f"Onverwacht antwoord van de Rekeningen API (HTTP {r.status_code}): {r.text[:200]}")
if js.get('errors'):
raise FacturatieFout("Rekeningen API: " + '; '.join(str(e.get('message', e)) for e in js['errors'])[:400])
return js.get('data') or {}
def groepen(self, schooljaar):
"""Factuurgroepen van type STUDENT: [{scope, toegangsgroep, id, naam}]."""
uit = []
for scope, ag in self.scopes():
data = self._gql(scope, Q_GROEPEN, {'schoolYear': schooljaar, 'accessGroupIdentifier': ag})
for g in ((data.get('accessGroup') or {}).get('invoiceGroups') or []):
if str(g.get('invoiceGroupType') or '').upper() == 'STUDENT':
uit.append({'scope': scope, 'toegangsgroep': ag, 'id': g.get('id'), 'naam': g.get('name') or f"Groep {g.get('id')}"})
uit.sort(key=lambda g: str(g['naam']).lower())
return uit
def facturen(self, schooljaar, vernieuw=False):
"""(facturen, groepen, opgehaald) voor alle leerlingen-factuurgroepen van het schooljaar."""
sleutel = ('facturen', schooljaar)
bewaard = self.school.cache.get(sleutel)
if bewaard and not vernieuw and time.time() - bewaard[0] < CACHE_SECONDEN:
return bewaard[1], bewaard[2], bewaard[0]
groepen = self.groepen(schooljaar)
rijen = []
for g in groepen:
data = self._gql(g['scope'], Q_FACTUREN, {'schoolYear': schooljaar, 'accessGroupIdentifier': g['toegangsgroep'],
'invoiceGroupId': g['id']})
for grp in ((data.get('accessGroup') or {}).get('invoiceGroups') or []):
for f in grp.get('invoices') or []:
ont = f.get('recipient') or {}
rij = {
'id': f.get('invoiceId'),
'toegangsgroep': g['toegangsgroep'],
'groep_id': g['id'], 'groep': g['naam'],
'type': str(f.get('invoiceType') or 'INVOICE').upper(),
'datum': _datum(f.get('printDate')), 'vervaldag': _datum(f.get('dueDate')),
'totaal': _bedrag(f.get('total')), 'betaald': _bedrag(f.get('paid')), 'open': _bedrag(f.get('open')),
'ogm': f.get('paymentReference') or '',
'ogm_factuur': f.get('paymentReferenceInvoice') or '', # bij een creditnota: die van de factuur
'klas': ont.get('class') or '', 'klasnummer': ont.get('classNumber'),
'voornaam': (ont.get('firstName') or '').strip(), 'naam': (ont.get('lastName') or '').strip(),
'stamnummer': ont.get('stamNumber') or '', 'leerling_id': ont.get('databaseId'),
}
rij['status'] = status_van(rij)
rij['teken'] = -1 if rij['type'] == 'CREDIT_NOTE' else 1 # creditnota's: positief bedrag in Informat
rijen.append(rij)
rijen.sort(key=lambda r: (r['naam'].lower(), r['voornaam'].lower(), r['datum'] or date.min))
nu = time.time()
self.school.cache[sleutel] = (nu, rijen, groepen)
return rijen, groepen, nu
# ---------------- PDF ----------------
def pdf(self, toegangsgroep, invoice_id, pogingen=8):
"""De PDF van één factuur (bytes), via .../download/{accessGroupIdentifier}/{invoiceId}/pdf.
Bestaat die nog niet (Informat antwoordt dan 400), dan eerst .../create/... (202) en even wachten."""
scope = next((sc for sc, ag in self.scopes() if ag == toegangsgroep), None)
if not scope:
raise FacturatieFout("Onbekende toegangsgroep voor deze factuur.")
auth = {'Authorization': f"BEARER {self._token(scope)}"}
download = f"{self.base}/download/{toegangsgroep}/{invoice_id}/pdf"
aanmaken = f"{self.base}/create/{toegangsgroep}/{invoice_id}/pdf"
aangevraagd = False
for poging in range(pogingen + 1):
r = requests.get(download, headers=auth, timeout=TIMEOUT)
if r.status_code == 200 and r.content:
return r.content
if r.status_code not in (400, 404):
raise FacturatieFout(f"De PDF kon niet opgehaald worden (HTTP {r.status_code}).")
if not aangevraagd:
c = requests.post(aanmaken, headers=auth, timeout=TIMEOUT)
if c.status_code == 405:
c = requests.get(aanmaken, headers=auth, timeout=TIMEOUT)
if c.status_code not in (200, 201, 202, 204):
raise FacturatieFout(f"Informat kon de PDF niet aanmaken (HTTP {c.status_code}). Controleer of het "
"factuuradres van deze factuur een titel en naam heeft.")
aangevraagd = True
if poging < pogingen:
time.sleep(3)
raise FacturatieFout("Informat is de PDF nog aan het aanmaken (dat kan tot 2 minuten duren). "
"Probeer zo dadelijk opnieuw.")
+280
View File
@@ -0,0 +1,280 @@
"""
Google › Gedeelde drives: alle gedeelde drives van het domein, wie er rechten op heeft, en leden beheren.
Google Drive API v3 namens de beheerder (domeinbrede delegatie), met useDomainAdminAccess=True zodat ook drives
zichtbaar zijn waar de beheerder zelf geen lid van is:
drives.list / drives.create
permissions.list / create / update / delete (supportsAllDrives=True)
Vereist: https://www.googleapis.com/auth/drive (en de Google Drive API aan in het Cloud-project).
"""
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from config import Config
from tenancy import google_credentials, huidige_school, in_school
SCOPE = 'https://www.googleapis.com/auth/drive'
CACHE_SECONDEN = 10 * 60
ROLLEN = [('organizer', 'Beheerder'), ('fileOrganizer', 'Inhoudsbeheerder'), ('writer', 'Bijdrager'),
('commenter', 'Reageerder'), ('reader', 'Lezer')]
ROL_NAAM = dict(ROLLEN)
PERM_VELDEN = 'nextPageToken,permissions(id,type,role,emailAddress,domain,displayName,deleted)'
class DriveFout(Exception):
pass
def vriendelijk(e):
t = str(e)
laag = t.lower()
if 'unauthorized_client' in laag or 'access_denied' in laag:
return ("Google weigert de toegang: voeg in de Admin-console › Beveiliging › API-beheer › Domeinbrede delegatie "
f"het bereik {SCOPE} toe bij het serviceaccount van PIVOT.")
if 'has not been used' in laag or 'is disabled' in laag or 'accessnotconfigured' in laag:
return "De Google Drive API staat niet aan in het Google Cloud-project van het serviceaccount."
if 'insufficientadministratorprivileges' in laag or 'domain administrator' in laag:
return "De beheerder (Koppelingen › Algemeen) heeft geen beheerdersrechten voor Drive in het Admin-console."
if 'cannotremovelastorganizer' in laag or 'last organizer' in laag:
return "Dit is de laatste beheerder van de drive; voeg eerst een andere beheerder toe."
if 'invalidsharingrequest' in laag or 'shareoutnotpermitted' in laag or 'sharingrateLimitExceeded'.lower() in laag:
return "Google laat dit delen niet toe (bv. delen buiten het domein staat uit voor deze drive of organisatie)."
if 'notfound' in laag.replace(' ', '') or ' 404' in t:
return "Niet gevonden (bestaat de drive, de gebruiker of de groep nog?)."
return t[:300]
def _drive():
from googleapiclient.discovery import build
creds = google_credentials([SCOPE], subject=Config.GOOGLE_ADMIN_EMAIL)
try:
return build('drive', 'v3', credentials=creds, static_discovery=True, cache_discovery=False)
except Exception:
return build('drive', 'v3', credentials=creds, static_discovery=False, cache_discovery=False)
def _eigen_domeinen():
s = huidige_school()
d = {x.lower() for x in (getattr(s, 'domeinen', None) or [s.domein]) if x}
if Config.GOOGLE_ADMIN_EMAIL:
d.add(Config.GOOGLE_ADMIN_EMAIL.lower().partition('@')[2])
return d
def _is_extern(p, domeinen):
if p.get('type') == 'anyone':
return True
if p.get('type') == 'domain':
return (p.get('domain') or '').lower() not in domeinen
mail = (p.get('emailAddress') or '').lower()
return bool(mail) and mail.partition('@')[2] not in domeinen
def _leden(svc, drive_id):
uit, token = [], None
while True:
resp = svc.permissions().list(fileId=drive_id, supportsAllDrives=True, useDomainAdminAccess=True,
pageSize=100, pageToken=token, fields=PERM_VELDEN).execute()
uit += resp.get('permissions', [])
token = resp.get('nextPageToken')
if not token:
return uit
def _samenvatting(d, leden, domeinen):
return {'id': d['id'], 'naam': d.get('name', ''), 'aangemaakt': (d.get('createdTime') or '')[:10],
'verborgen': bool(d.get('hidden')), 'leden': len(leden),
'beheerders': sum(1 for p in leden if p.get('role') == 'organizer'),
'extern': sum(1 for p in leden if _is_extern(p, domeinen)),
'groepen': sum(1 for p in leden if p.get('type') == 'group'),
'fout': None}
def overzicht(vernieuw=False):
"""[{id, naam, aangemaakt, leden, beheerders, extern, groepen, verborgen, fout}] gesorteerd op naam."""
school = huidige_school()
bewaard = school.cache.get('gedeelde_drives')
if bewaard and not vernieuw and time.time() - bewaard[0] < CACHE_SECONDEN:
return bewaard[1], bewaard[0]
try:
svc = _drive()
drives, token = [], None
while True:
resp = svc.drives().list(useDomainAdminAccess=True, pageSize=100, pageToken=token,
fields='nextPageToken,drives(id,name,createdTime,hidden)').execute()
drives += resp.get('drives', [])
token = resp.get('nextPageToken')
if not token:
break
except Exception as e:
raise DriveFout(vriendelijk(e))
domeinen = _eigen_domeinen()
lokaal = threading.local()
alle_leden = {}
def info(d):
with in_school(school):
if not hasattr(lokaal, 'svc'):
lokaal.svc = _drive()
try:
leden = _leden(lokaal.svc, d['id'])
alle_leden[d['id']] = leden
return _samenvatting(d, leden, domeinen)
except Exception as e:
r = _samenvatting(d, [], domeinen)
r['fout'] = vriendelijk(e)
return r
with ThreadPoolExecutor(max_workers=6) as pool:
uit = list(pool.map(info, drives))
uit.sort(key=lambda d: d['naam'].lower())
nu = time.time()
school.cache['gedeelde_drives'] = (nu, uit)
school.cache['gedeelde_drives_leden'] = alle_leden
return uit, nu
def _vergeet():
huidige_school().cache.pop('gedeelde_drives', None)
def drive(drive_id):
"""(drive-info, leden) van één drive. Leden gesorteerd: beheerders eerst, dan op naam."""
svc = _drive()
try:
d = svc.drives().get(driveId=drive_id, useDomainAdminAccess=True, fields='id,name,createdTime,hidden').execute()
leden = _leden(svc, drive_id)
except Exception as e:
raise DriveFout(vriendelijk(e))
domeinen = _eigen_domeinen()
volgorde = {r: i for i, (r, _) in enumerate(ROLLEN)}
rijen = [{'id': p['id'], 'type': p.get('type'), 'rol': p.get('role'), 'rol_naam': ROL_NAAM.get(p.get('role'), p.get('role')),
'email': p.get('emailAddress') or (p.get('domain') if p.get('type') == 'domain' else ''),
'naam': p.get('displayName') or '', 'extern': _is_extern(p, domeinen), 'verwijderd': bool(p.get('deleted'))}
for p in leden]
rijen.sort(key=lambda r: (volgorde.get(r['rol'], 9), (r['naam'] or r['email']).lower()))
return _samenvatting(d, leden, domeinen), rijen
def _soort(email):
"""'group' als het adres een Google-groep is, anders 'user'."""
try:
from googleapiclient.discovery import build
creds = google_credentials(Config.GOOGLE_API_SCOPES, subject=Config.GOOGLE_ADMIN_EMAIL)
build('admin', 'directory_v1', credentials=creds, static_discovery=False, cache_discovery=False) \
.groups().get(groupKey=email, fields='id').execute()
return 'group'
except Exception:
return 'user'
def lid_toevoegen(drive_id, email, rol, melding=False):
email = (email or '').strip().lower()
if '@' not in email:
raise DriveFout("Geef een geldig e-mailadres (persoon of groep).")
if rol not in ROL_NAAM:
raise DriveFout("Onbekende rol.")
try:
_drive().permissions().create(fileId=drive_id, supportsAllDrives=True, useDomainAdminAccess=True,
sendNotificationEmail=bool(melding), fields='id',
body={'type': _soort(email), 'role': rol, 'emailAddress': email}).execute()
except Exception as e:
raise DriveFout(vriendelijk(e))
_vergeet()
def lid_wijzigen(drive_id, perm_id, rol):
if rol not in ROL_NAAM:
raise DriveFout("Onbekende rol.")
try:
_drive().permissions().update(fileId=drive_id, permissionId=perm_id, supportsAllDrives=True,
useDomainAdminAccess=True, body={'role': rol}, fields='id').execute()
except Exception as e:
raise DriveFout(vriendelijk(e))
_vergeet()
def lid_verwijderen(drive_id, perm_id):
try:
_drive().permissions().delete(fileId=drive_id, permissionId=perm_id, supportsAllDrives=True,
useDomainAdminAccess=True).execute()
except Exception as e:
raise DriveFout(vriendelijk(e))
_vergeet()
def drive_maken(naam, beheerder=None):
"""Nieuwe gedeelde drive (de PIVOT-beheerder wordt eerst beheerder); optioneel meteen een andere beheerder."""
naam = (naam or '').strip()
if not naam:
raise DriveFout("Geef de drive een naam.")
try:
d = _drive().drives().create(requestId=str(uuid.uuid4()), body={'name': naam[:100]}, fields='id,name').execute()
except Exception as e:
raise DriveFout(vriendelijk(e))
_vergeet()
if beheerder:
lid_toevoegen(d['id'], beheerder, 'organizer')
return d['id']
# ---------------------------------------------------------------------
# Een gebruiker op alle gedeelde drives vervangen door een andere
# ---------------------------------------------------------------------
def drives_van(email, vernieuw=True):
"""Alle gedeelde drives waar `email` lid van is: [{drive, naam, perm, rol, rol_naam}] (vers opgehaald)."""
email = (email or '').strip().lower()
drives, _ = overzicht(vernieuw=vernieuw)
leden = huidige_school().cache.get('gedeelde_drives_leden') or {}
uit = []
for d in drives:
for p in leden.get(d['id'], []):
adres = (p.get('emailAddress') or '').lower()
# Met een @: exact dat adres; zonder @: een deel van het adres of de naam (bv. 'peeters')
if adres == email or ('@' not in email and email and
(email in adres or email in (p.get('displayName') or '').lower())):
uit.append({'drive': d['id'], 'naam': d['naam'], 'perm': p['id'], 'rol': p.get('role'),
'rol_naam': ROL_NAAM.get(p.get('role'), p.get('role')), 'email': adres,
'wie': p.get('displayName') or adres})
return uit
def vervang(oud, nieuw, drive_ids, oud_verwijderen=True, melding=False):
"""Op elke gekozen drive: `nieuw` krijgt exact de rol van `oud` (ook als hij al lid was), daarna wordt `oud`
verwijderd (optioneel). Geeft [{naam, ok, melding}]."""
oud, nieuw = (oud or '').strip().lower(), (nieuw or '').strip().lower()
if '@' not in nieuw:
raise DriveFout("Geef een geldig e-mailadres voor de nieuwe gebruiker.")
if oud == nieuw:
raise DriveFout("De oude en de nieuwe gebruiker zijn dezelfde.")
svc, soort, uit = _drive(), _soort(nieuw), []
kies = set(drive_ids)
for t in drives_van(oud, vernieuw=False):
if t['drive'] not in kies:
continue
try:
leden = _leden(svc, t['drive'])
bestaand = next((p for p in leden if (p.get('emailAddress') or '').lower() == nieuw), None)
if bestaand is None:
svc.permissions().create(fileId=t['drive'], supportsAllDrives=True, useDomainAdminAccess=True,
sendNotificationEmail=bool(melding), fields='id',
body={'type': soort, 'role': t['rol'], 'emailAddress': nieuw}).execute()
tekst = f"{nieuw} toegevoegd als {t['rol_naam']}"
elif bestaand.get('role') != t['rol']: # altijd exact dezelfde rechten als de oude gebruiker
svc.permissions().update(fileId=t['drive'], permissionId=bestaand['id'], supportsAllDrives=True,
useDomainAdminAccess=True, body={'role': t['rol']}, fields='id').execute()
tekst = f"{nieuw} was al lid, nu {t['rol_naam']}"
else:
tekst = f"{nieuw} was al lid ({ROL_NAAM.get(bestaand.get('role'))})"
if oud_verwijderen:
svc.permissions().delete(fileId=t['drive'], permissionId=t['perm'], supportsAllDrives=True,
useDomainAdminAccess=True).execute()
tekst += f", {oud} verwijderd"
uit.append({'naam': t['naam'], 'ok': True, 'melding': tekst})
except Exception as e:
uit.append({'naam': t['naam'], 'ok': False, 'melding': vriendelijk(e)})
_vergeet()
return uit
+169
View File
@@ -0,0 +1,169 @@
"""
Overzicht van alle lopende e-mailmachtigingen (Gmail-delegaties) van de school.
Google heeft geen lijst "alle delegaties van het domein": je kan ze enkel per mailbox opvragen
(users.settings.delegates.list, in naam van de eigenaar). Daarom loopt er een achtergrondtaak die
alle actieve (niet-geschorste) gebruikers afgaat, met een paar parallelle verzoeken.
Het resultaat komt in de tabel gmail_delegaties en wordt:
- volledig vernieuwd met de knop op de pagina, of vanzelf als het ouder is dan VERVERS_UREN;
- meteen bijgewerkt als in PIVOT een machtiging toegevoegd of ingetrokken wordt, of als je een mailbox opent.
"""
import sys
import threading
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from database import execute_query
from tenancy import school_singleton, met_school, huidige_school, in_school
VERVERS_UREN = 24
PARALLEL = 8
class DelegatieOverzicht:
def __new__(cls):
def init(inst):
inst._lock = threading.Lock()
inst.status = {'bezig': False, 'voortgang': 0, 'totaal': 0, 'fout': None, 'resultaat': None}
return school_singleton(cls, init)
@staticmethod
def ensure_table():
execute_query("""
CREATE TABLE IF NOT EXISTS gmail_delegaties (
Mailbox VARCHAR(255) NOT NULL,
Mailbox_naam VARCHAR(255) NULL,
Gedelegeerde VARCHAR(255) NOT NULL,
Gedelegeerde_naam VARCHAR(255) NULL,
Status VARCHAR(20) NULL,
Opgehaald DATETIME NOT NULL,
PRIMARY KEY (Mailbox, Gedelegeerde),
KEY idx_gmail_deleg_gedelegeerde (Gedelegeerde)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True)
execute_query("CREATE TABLE IF NOT EXISTS gmail_delegaties_scan (ID TINYINT NOT NULL PRIMARY KEY, "
"Opgehaald DATETIME NULL, Mailboxen INT NULL, Fouten INT NULL) ENGINE=InnoDB", commit=True)
# ---------------- opvragen ----------------
def alle(self):
return execute_query("SELECT * FROM gmail_delegaties ORDER BY Mailbox, Gedelegeerde") or []
def laatst_opgehaald(self):
rij = execute_query("SELECT Opgehaald, Mailboxen, Fouten FROM gmail_delegaties_scan WHERE ID = 1")
return rij[0] if rij else None
def verouderd(self):
laatst = self.laatst_opgehaald()
return not laatst or not laatst['Opgehaald'] or \
(datetime.now() - laatst['Opgehaald']).total_seconds() > VERVERS_UREN * 3600
# ---------------- één mailbox bijwerken (na toevoegen/intrekken/openen in PIVOT) ----------------
def bewaar_mailbox(self, mailbox, naam, delegates, namen=None):
"""Vervangt de rijen van één mailbox door de opgegeven gedelegeerden [{'email','status'}]."""
try:
self.ensure_table()
# Namen die we al kennen (uit de volledige ophaling) behouden
bekend = {}
for r in execute_query("SELECT Mailbox, Mailbox_naam, Gedelegeerde, Gedelegeerde_naam FROM gmail_delegaties") or []:
if r['Mailbox_naam']:
bekend[r['Mailbox'].lower()] = r['Mailbox_naam']
if r['Gedelegeerde_naam']:
bekend[r['Gedelegeerde'].lower()] = r['Gedelegeerde_naam']
namen = {**bekend, **(namen or {})}
execute_query("DELETE FROM gmail_delegaties WHERE Mailbox = %s", (mailbox,), commit=True)
for d in delegates:
execute_query("INSERT INTO gmail_delegaties (Mailbox, Mailbox_naam, Gedelegeerde, Gedelegeerde_naam, "
"Status, Opgehaald) VALUES (%s, %s, %s, %s, %s, NOW())",
(mailbox, naam, d['email'], namen.get(d['email'].lower()) or d.get('naam'),
d.get('status')), commit=True)
except Exception as e:
sys.stderr.write(f"Delegaties: mailbox {mailbox} niet bewaard: {e}\n")
# ---------------- alles ophalen uit Google ----------------
def _gebruikers(self, directory):
uit, token = [], None
while True:
resp = directory.users().list(customer='my_customer', maxResults=500, pageToken=token,
fields='nextPageToken,users(primaryEmail,name/fullName,suspended)').execute()
for u in resp.get('users', []):
uit.append({'email': u['primaryEmail'].lower(), 'naam': (u.get('name') or {}).get('fullName', ''),
'geschorst': bool(u.get('suspended'))})
token = resp.get('nextPageToken')
if not token:
return uit
def sync(self):
if not self._lock.acquire(blocking=False):
return False, "Er loopt al een ophaling."
try:
from services.gmail_delegation_service import GmailDelegationService, DelegationError
self.status.update(bezig=True, voortgang=0, totaal=0, fout=None, resultaat=None)
self.ensure_table()
svc = GmailDelegationService()
gebruikers = self._gebruikers(svc._directory_api())
namen = {u['email']: u['naam'] for u in gebruikers}
actief = [u for u in gebruikers if not u['geschorst']]
self.status['totaal'] = len(actief)
school = huidige_school()
fouten, rechtenfout, gelukt = [], [], []
def een(u):
# Krijgt Google telkens dezelfde rechtenfout, dan niet alle gebruikers blijven proberen
if len(rechtenfout) >= 3 and not gelukt:
self.status['voortgang'] += 1
return u, None
with in_school(school):
try:
delegates = svc.list_delegates(u['email'])
gelukt.append(1)
return u, delegates
except DelegationError as e:
tekst = str(e)
if 'serviceaccount mist een Gmail-recht' in tekst or 'Gmail API staat nog niet aan' in tekst:
rechtenfout.append(tekst)
fouten.append(u['email'])
return u, None
except Exception:
fouten.append(u['email'])
return u, None
finally:
self.status['voortgang'] += 1
resultaten = []
with ThreadPoolExecutor(max_workers=PARALLEL) as pool:
for u, delegates in pool.map(een, actief):
if delegates:
resultaten.append((u, delegates))
if rechtenfout and not gelukt:
raise RuntimeError(rechtenfout[0])
nu = datetime.now()
execute_query("DELETE FROM gmail_delegaties", commit=True)
aantal = 0
for u, delegates in resultaten:
for d in delegates:
execute_query("INSERT INTO gmail_delegaties (Mailbox, Mailbox_naam, Gedelegeerde, Gedelegeerde_naam, "
"Status, Opgehaald) VALUES (%s, %s, %s, %s, %s, %s)",
(u['email'], u['naam'], d['email'], namen.get(d['email'].lower()), d['status'], nu),
commit=True)
aantal += 1
execute_query("INSERT INTO gmail_delegaties_scan (ID, Opgehaald, Mailboxen, Fouten) VALUES (1, %s, %s, %s) "
"ON DUPLICATE KEY UPDATE Opgehaald = VALUES(Opgehaald), Mailboxen = VALUES(Mailboxen), "
"Fouten = VALUES(Fouten)", (nu, len(actief), len(fouten)), commit=True)
msg = f"{len(actief)} mailboxen nagekeken: {aantal} machtiging(en) bij {len(resultaten)} mailbox(en)."
if fouten:
msg += f" {len(fouten)} mailbox(en) konden niet bevraagd worden (bv. zonder Gmail-licentie)."
self.status['resultaat'] = msg
return True, msg
except Exception as e:
self.status['fout'] = str(e)
return False, f"Ophalen mislukt: {e}"
finally:
self.status['bezig'] = False
self._lock.release()
def sync_op_achtergrond(self):
if self.status['bezig']:
return False
self.status.update(bezig=True, voortgang=0, totaal=0, fout=None)
threading.Thread(target=met_school(self.sync), daemon=True).start()
return True
+199
View File
@@ -0,0 +1,199 @@
"""
Gmail-delegatie: andere gebruikers toegang geven tot een mailbox.
Werkt via de Gmail API (users.settings.delegates). Het service-account handelt
in naam van de EIGENAAR van de mailbox (de delegator).
Vereist (eenmalig):
1. Admin-console › Beveiliging › API-beheer › Domeinbrede delegatie:
scope toevoegen aan het service-account:
https://www.googleapis.com/auth/gmail.settings.sharing
2. Admin-console › Apps › Google Workspace › Gmail › Gebruikersinstellingen:
'E-maildelegatie' inschakelen voor de betrokken organisatie-eenheden.
Beperkingen van Google:
- de gedelegeerde moet in dezelfde Google Workspace-organisatie zitten
- max. 25 gedelegeerden per mailbox, max. 10 mailboxen per gedelegeerde
- het kan tot 1 minuut duren voor de toegang actief is
"""
import sys
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from config import Config
from database import execute_query
from tenancy import google_credentials
# Toevoegen/verwijderen van gedelegeerden: gmail.settings.sharing.
# Opvragen (delegates.list/get): gmail.settings.basic (sharing volstaat daar NIET).
GMAIL_SCOPE = 'https://www.googleapis.com/auth/gmail.settings.sharing'
GMAIL_LEES_SCOPE = 'https://www.googleapis.com/auth/gmail.settings.basic'
MAX_DELEGATES = 25
STATUS_LABELS = {
'accepted': 'Actief',
'pending': 'In afwachting',
'rejected': 'Geweigerd',
'expired': 'Verlopen',
}
class DelegationError(Exception):
pass
class GmailDelegationService:
def __init__(self, directory_service=None):
# Bestaande Directory-client hergebruiken om adressen/aliassen op te zoeken
self._directory = directory_service
# ---------- clients ----------
def _gmail_for(self, mailbox, scope=GMAIL_SCOPE):
creds = google_credentials([scope], subject=mailbox)
try: # meegeleverde API-beschrijving: geen extra verzoek naar Google per mailbox
return build('gmail', 'v1', credentials=creds, static_discovery=True, cache_discovery=False)
except Exception:
return build('gmail', 'v1', credentials=creds, static_discovery=False, cache_discovery=False)
def _directory_api(self):
if self._directory is None:
from services.google_service import GoogleAdminService
self._directory = GoogleAdminService().service
return self._directory
# ---------- hulpfuncties ----------
@staticmethod
def _friendly_error(e, mailbox=None, delegate=None):
msg = str(e)
status = getattr(getattr(e, 'resp', None), 'status', None)
if 'unauthorized_client' in msg or 'access_denied' in msg or 'insufficient authentication scopes' in msg.lower():
return ("Google weigert de toegang: het serviceaccount mist een Gmail-recht. Voeg in admin.google.com › "
"Beveiliging › Toegangs- en gegevensbeheer › API-beheer › Domeinbrede delegatie bij het serviceaccount "
f"deze twee bereiken toe: {GMAIL_LEES_SCOPE} en {GMAIL_SCOPE}. Het kan enkele minuten duren voor dit werkt.")
if status == 409:
return f"{delegate} heeft al toegang tot {mailbox}."
laag = msg.lower()
reden = msg.split('returned', 1)[-1].strip(' ">')[:200] if 'returned' in msg else msg[:200]
if 'has not been used' in laag or 'is disabled' in laag or 'accessnotconfigured' in laag:
return ("De Gmail API staat nog niet aan in het Google Cloud-project van het serviceaccount "
"(console.cloud.google.com › API's en services › Bibliotheek › Gmail API › Inschakelen). "
f"Google: {reden}")
if 'mail service not enabled' in laag or 'failedprecondition' in laag or 'precondition' in laag:
return (f"{mailbox or 'Deze gebruiker'} heeft (nog) geen werkende Gmail-mailbox: controleer of Gmail aanstaat "
"en of de gebruiker een licentie heeft. Een net aangemaakt account kan tot 24 uur nodig hebben. "
f"Google: {reden}")
if 'delegation denied' in laag or 'not allowed' in laag or 'delegation is not allowed' in laag:
return ("Delegatie is niet toegestaan voor deze gebruiker. Controleer of 'E-maildelegatie' aanstaat "
"(Admin-console › Apps › Google Workspace › Gmail › Gebruikersinstellingen) voor de OU van de "
"mailbox én van de gedelegeerde. Een gewijzigde instelling kan tot 24 uur nodig hebben. "
f"Google: {reden}")
if status == 403:
return f"Google weigert deze actie (403). Google: {reden}"
if status == 400 and 'limit' in msg.lower():
return "Limiet bereikt: max. 25 gedelegeerden per mailbox en max. 10 mailboxen per gedelegeerde."
if status == 400:
return f"Google weigert deze delegatie: {getattr(e, 'reason', '') or msg}"
if status == 404:
return "Gebruiker of delegatie niet gevonden."
return msg
def resolve_user(self, email):
"""Zoekt de gebruiker op in Google en geeft het PRIMAIRE adres terug (aliassen werken niet bij delegatie)."""
email = (email or '').strip().lower()
if not email or '@' not in email:
raise DelegationError("Geef een volledig e-mailadres op.")
try:
u = self._directory_api().users().get(userKey=email).execute()
except HttpError as e:
if getattr(e.resp, 'status', None) == 404:
raise DelegationError(f"{email} bestaat niet in jullie Google Workspace. "
"Delegeren naar externe (bv. @gmail.com) adressen kan niet.")
raise DelegationError(self._friendly_error(e))
return {
'email': u['primaryEmail'].lower(),
'naam': (u.get('name') or {}).get('fullName', ''),
'ou': u.get('orgUnitPath', ''),
'geschorst': bool(u.get('suspended')),
'is_alias': u['primaryEmail'].lower() != email,
}
def search_users(self, query):
"""Gebruikers zoeken op naam of adres (voor de zoekbox)."""
q = query.strip()
try:
resp = self._directory_api().users().list(
customer='my_customer', query=q, maxResults=15, orderBy='email').execute()
except Exception as e:
sys.stderr.write(f"Zoeken mislukt: {e}\n")
return []
return [{'email': u['primaryEmail'], 'naam': (u.get('name') or {}).get('fullName', ''),
'ou': u.get('orgUnitPath', '')} for u in resp.get('users', [])]
# ---------- delegaties ----------
def list_delegates(self, mailbox):
try:
resp = self._gmail_for(mailbox, GMAIL_LEES_SCOPE).users().settings().delegates().list(userId='me').execute()
except HttpError as e:
raise DelegationError(self._friendly_error(e, mailbox))
except Exception as e:
raise DelegationError(self._friendly_error(e, mailbox))
result = []
for d in resp.get('delegates', []):
status = d.get('verificationStatus', '')
result.append({'email': d.get('delegateEmail', ''),
'status': status,
'status_label': STATUS_LABELS.get(status, status)})
result.sort(key=lambda d: d['email'])
return result
def add_delegate(self, mailbox, delegate):
if mailbox == delegate:
raise DelegationError("Een mailbox kan niet aan zichzelf gedelegeerd worden.")
try:
self._gmail_for(mailbox).users().settings().delegates().create(
userId='me', body={'delegateEmail': delegate}).execute()
except HttpError as e:
raise DelegationError(self._friendly_error(e, mailbox, delegate))
except Exception as e:
raise DelegationError(self._friendly_error(e, mailbox, delegate))
def remove_delegate(self, mailbox, delegate):
try:
self._gmail_for(mailbox).users().settings().delegates().delete(
userId='me', delegateEmail=delegate).execute()
except HttpError as e:
raise DelegationError(self._friendly_error(e, mailbox, delegate))
except Exception as e:
raise DelegationError(self._friendly_error(e, mailbox, delegate))
# ---------- logboek ----------
@staticmethod
def ensure_table():
execute_query("""
CREATE TABLE IF NOT EXISTS gmail_delegatie_log (
ID INT AUTO_INCREMENT PRIMARY KEY,
Tijdstip DATETIME NOT NULL,
Gebruiker VARCHAR(100) NULL,
Actie VARCHAR(20) NOT NULL,
Mailbox VARCHAR(255) NOT NULL,
Gedelegeerde VARCHAR(255) NOT NULL,
INDEX idx_mailbox (Mailbox),
INDEX idx_tijdstip (Tijdstip)
)""", commit=True)
@staticmethod
def log(gebruiker, actie, mailbox, gedelegeerde):
execute_query(
"INSERT INTO gmail_delegatie_log (Tijdstip, Gebruiker, Actie, Mailbox, Gedelegeerde) "
"VALUES (NOW(), %s, %s, %s, %s)",
(gebruiker, actie, mailbox, gedelegeerde), commit=True)
@staticmethod
def history(mailbox=None, limit=25):
if mailbox:
return execute_query(
"SELECT Tijdstip, Gebruiker, Actie, Mailbox, Gedelegeerde FROM gmail_delegatie_log "
"WHERE Mailbox = %s OR Gedelegeerde = %s ORDER BY Tijdstip DESC LIMIT %s",
(mailbox, mailbox, int(limit)))
return execute_query(
"SELECT Tijdstip, Gebruiker, Actie, Mailbox, Gedelegeerde FROM gmail_delegatie_log "
"ORDER BY Tijdstip DESC LIMIT %s", (int(limit),))
+237
View File
@@ -0,0 +1,237 @@
"""
Google › E-mails met een label verplaatsen: alle berichten met een bepaald label uit een mailbox kopiëren of
verplaatsen naar een andere mailbox van dezelfde Google Workspace.
Werking (Gmail API, het serviceaccount werkt in naam van de mailbox via domeinbrede delegatie):
1. bron: users.messages.list(labelIds=[label]) → alle berichten met het label
2. bron: users.messages.get(format=raw) → het volledige bericht (met bijlagen, datum, afzender ...)
3. doel: users.messages.import(raw, labelIds=[...]) → in de doelmailbox, onder hetzelfde label
(de originele datum blijft, geen spamfilter; gelezen/ongelezen en ster gaan mee)
4. bij 'verplaatsen': bron: users.messages.trash → in de bronmailbox naar de prullenbak (30 dagen terug te halen)
Elk bericht dat al gekopieerd werd naar dezelfde doelmailbox, wordt overgeslagen (tabel gmail_label_items),
zodat je een onderbroken opdracht gewoon opnieuw kan starten.
Vereist (eenmalig, Admin-console › Beveiliging › API-beheer › Domeinbrede delegatie, bij het serviceaccount):
https://www.googleapis.com/auth/gmail.modify
"""
import sys
import threading
import time
from datetime import datetime
from database import execute_query
from tenancy import school_singleton, met_school, google_credentials
SCOPE = 'https://www.googleapis.com/auth/gmail.modify'
MEEGAAN = ('UNREAD', 'STARRED', 'IMPORTANT') # systeemlabels die mee naar de doelmailbox gaan
class LabelFout(Exception):
pass
def _gmail(mailbox):
from googleapiclient.discovery import build
creds = google_credentials([SCOPE], subject=mailbox)
try:
return build('gmail', 'v1', credentials=creds, static_discovery=True, cache_discovery=False)
except Exception:
return build('gmail', 'v1', credentials=creds, static_discovery=False, cache_discovery=False)
def vriendelijk(e, mailbox=None):
tekst = str(e)
laag = tekst.lower()
if 'unauthorized_client' in laag or 'insufficient' in laag or 'access_denied' in laag:
return ("Google weigert de toegang: het serviceaccount mist het Gmail-recht om berichten te lezen en te "
"importeren. Voeg in admin.google.com › Beveiliging › Toegangs- en gegevensbeheer › API-beheer › "
f"Domeinbrede delegatie bij het serviceaccount dit bereik toe: {SCOPE} (het kan enkele minuten duren).")
if 'has not been used' in laag or 'is disabled' in laag or 'accessnotconfigured' in laag:
return ("De Gmail API staat nog niet aan in het Google Cloud-project van het serviceaccount "
"(console.cloud.google.com › API's en services › Bibliotheek › Gmail API › Inschakelen).")
if 'mail service not enabled' in laag or 'failedprecondition' in laag:
return f"{mailbox or 'Deze gebruiker'} heeft geen werkende Gmail-mailbox (geen licentie of Gmail uitgeschakeld)."
if 'notfound' in laag.replace(' ', '') or ' 404' in tekst:
return "Niet gevonden (bestaat de mailbox of het label nog?)."
return tekst[:300]
def ensure_tables():
execute_query("""
CREATE TABLE IF NOT EXISTS gmail_label_log (
ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
Gestart DATETIME NOT NULL,
Klaar DATETIME NULL,
Door VARCHAR(255) NULL,
Bron VARCHAR(255) NOT NULL,
Label VARCHAR(255) NOT NULL,
Doel VARCHAR(255) NOT NULL,
Modus VARCHAR(12) NOT NULL,
Totaal INT NULL,
Gelukt INT NULL,
Overgeslagen INT NULL,
Fouten INT NULL,
Melding VARCHAR(500) NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True)
execute_query("""
CREATE TABLE IF NOT EXISTS gmail_label_items (
Bron VARCHAR(255) NOT NULL,
Bron_ID VARCHAR(64) NOT NULL,
Doel VARCHAR(255) NOT NULL,
Doel_ID VARCHAR(64) NULL,
Gekopieerd DATETIME NOT NULL,
PRIMARY KEY (Bron, Bron_ID, Doel)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True)
def labels(mailbox):
"""Eigen labels van een mailbox, met het aantal berichten: [{'id', 'naam', 'aantal'}], alfabetisch."""
try:
g = _gmail(mailbox)
lijst = g.users().labels().list(userId='me').execute().get('labels', [])
uit = []
for l in lijst:
if l.get('type') != 'user':
continue
try:
info = g.users().labels().get(userId='me', id=l['id']).execute()
aantal = info.get('messagesTotal')
except Exception:
aantal = None
uit.append({'id': l['id'], 'naam': l.get('name', ''), 'aantal': aantal})
except Exception as e:
raise LabelFout(vriendelijk(e, mailbox))
return sorted(uit, key=lambda l: l['naam'].lower())
def geschiedenis(limiet=20):
ensure_tables()
return execute_query("SELECT * FROM gmail_label_log ORDER BY Gestart DESC LIMIT %s", (int(limiet),)) or []
class LabelVerplaatser:
def __new__(cls):
def init(inst):
inst._lock = threading.Lock()
inst.status = {'bezig': False, 'voortgang': 0, 'totaal': 0, 'gelukt': 0, 'overgeslagen': 0, 'fouten': 0,
'fout': None, 'resultaat': None, 'opdracht': None, 'foutvoorbeelden': []}
return school_singleton(cls, init)
# ---------------- hulpfuncties ----------------
@staticmethod
def _alle_ids(g, label_id):
ids, token = [], None
while True:
resp = g.users().messages().list(userId='me', labelIds=[label_id], maxResults=500, pageToken=token,
includeSpamTrash=False).execute()
ids += [m['id'] for m in resp.get('messages', [])]
token = resp.get('nextPageToken')
if not token:
return ids
@staticmethod
def _doel_label(g, naam):
"""Id van het label `naam` in de doelmailbox (aangemaakt als het nog niet bestaat, ook sublabels)."""
bestaand = {l['name']: l['id'] for l in g.users().labels().list(userId='me').execute().get('labels', [])}
if naam in bestaand:
return bestaand[naam]
# Bovenliggende labels eerst aanmaken (A, A/B, A/B/C), zodat de nesting in Gmail klopt
delen = naam.split('/')
for i in range(1, len(delen) + 1):
pad = '/'.join(delen[:i])
if pad not in bestaand:
nieuw = g.users().labels().create(userId='me', body={
'name': pad, 'labelListVisibility': 'labelShow', 'messageListVisibility': 'show'}).execute()
bestaand[pad] = nieuw['id']
return bestaand[naam]
@staticmethod
def _herhaal(fn, pogingen=4):
"""Google-aanroep met een paar nieuwe pogingen bij 429/5xx (te snel / tijdelijk)."""
for poging in range(pogingen):
try:
return fn()
except Exception as e:
status = getattr(getattr(e, 'resp', None), 'status', None)
if status in (429, 500, 502, 503) and poging < pogingen - 1:
time.sleep(2 ** poging)
continue
raise
# ---------------- opdracht ----------------
def run(self, bron, label_id, label_naam, doel, modus, door):
if not self._lock.acquire(blocking=False):
return False, "Er loopt al een opdracht."
log_id = None
try:
ensure_tables()
self.status.update(bezig=True, voortgang=0, totaal=0, gelukt=0, overgeslagen=0, fouten=0, prullenbak=0, fout=None,
resultaat=None, foutvoorbeelden=[],
opdracht={'bron': bron, 'label': label_naam, 'doel': doel, 'modus': modus})
execute_query("INSERT INTO gmail_label_log (Gestart, Door, Bron, Label, Doel, Modus) "
"VALUES (NOW(), %s, %s, %s, %s, %s)", (door, bron, label_naam, doel, modus), commit=True)
log_id = (execute_query("SELECT MAX(ID) AS id FROM gmail_label_log WHERE Bron = %s AND Doel = %s",
(bron, doel)) or [{}])[0].get('id')
try:
gb, gd = _gmail(bron), _gmail(doel)
ids = self._alle_ids(gb, label_id)
doel_label = self._doel_label(gd, label_naam)
except Exception as e:
raise LabelFout(vriendelijk(e))
self.status['totaal'] = len(ids)
al = {r['Bron_ID'] for r in execute_query(
"SELECT Bron_ID FROM gmail_label_items WHERE Bron = %s AND Doel = %s", (bron, doel)) or []}
for mid in ids:
try:
if mid in al:
self.status['overgeslagen'] += 1
else:
bericht = self._herhaal(lambda: gb.users().messages().get(userId='me', id=mid, format='raw').execute())
extra = [l for l in bericht.get('labelIds', []) if l in MEEGAAN]
nieuw = self._herhaal(lambda: gd.users().messages().import_(
userId='me', body={'raw': bericht['raw'], 'labelIds': [doel_label] + extra},
internalDateSource='dateHeader', neverMarkSpam=True, processForCalendar=False).execute())
execute_query("INSERT IGNORE INTO gmail_label_items (Bron, Bron_ID, Doel, Doel_ID, Gekopieerd) "
"VALUES (%s, %s, %s, %s, NOW())", (bron, mid, doel, nieuw.get('id')), commit=True)
self.status['gelukt'] += 1
if modus == 'verplaatsen':
self._herhaal(lambda: gb.users().messages().trash(userId='me', id=mid).execute())
self.status['prullenbak'] = self.status.get('prullenbak', 0) + 1
except Exception as e:
self.status['fouten'] += 1
if len(self.status['foutvoorbeelden']) < 5:
self.status['foutvoorbeelden'].append(vriendelijk(e)[:200])
sys.stderr.write(f"Label verplaatsen {bron} → {doel}, bericht {mid}: {e}\n")
self.status['voortgang'] += 1
s = self.status
msg = f"Label '{label_naam}' van {bron} naar {doel}: {s['gelukt']} bericht(en) gekopieerd"
if s['overgeslagen']:
msg += f", {s['overgeslagen']} stonden er al"
if modus == 'verplaatsen':
msg += f", {s.get('prullenbak', 0)} in de bronmailbox naar de prullenbak"
if s['fouten']:
msg += f", {s['fouten']} mislukt"
s['resultaat'] = msg + '.'
return True, s['resultaat']
except Exception as e:
self.status['fout'] = vriendelijk(e)
return False, self.status['fout']
finally:
s = self.status
if log_id:
try:
execute_query("UPDATE gmail_label_log SET Klaar = NOW(), Totaal = %s, Gelukt = %s, Overgeslagen = %s, "
"Fouten = %s, Melding = %s WHERE ID = %s",
(s['totaal'], s['gelukt'], s['overgeslagen'], s['fouten'],
(s['fout'] or s['resultaat'] or '')[:500], log_id), commit=True)
except Exception:
pass
s['bezig'] = False
self._lock.release()
def start(self, *args):
if self.status['bezig']:
return False
self.status.update(bezig=True, voortgang=0, totaal=0, fout=None, resultaat=None)
threading.Thread(target=met_school(lambda: self.run(*args)), daemon=True).start()
return True
+276
View File
@@ -0,0 +1,276 @@
"""
Google Workspace for Education – licentie-overzicht.
Haalt via de Enterprise License Manager API op welke gebruikers een
licentie hebben. Google geeft het aantal AANGEKOCHTE licenties niet vrij
via de API; dat aantal wordt in PIVOT bijgehouden (tabel google_licenties)
en door een admin ingevuld.
Vereist (eenmalig, Google Admin-console):
Beveiliging > Toegangs- en gegevensbeheer > API-beheer >
Domeinbrede delegatie > service-account bewerken en deze scope toevoegen:
https://www.googleapis.com/auth/apps.licensing
"""
import sys
import time
from googleapiclient.discovery import build
from config import Config
from database import execute_query
from tenancy import google_credentials
LICENSING_SCOPE = 'https://www.googleapis.com/auth/apps.licensing'
# Product- en SKU-ID's volgens Google:
# https://developers.google.com/workspace/admin/licensing/v1/how-tos/products
EDU_PRODUCT_ID = '101031'
SKU_NAMES = {
'1010310008': 'Education Plus',
'1010310009': 'Education Plus (Staff)',
'1010310010': 'Education Plus (Extra Student)',
'1010310005': 'Education Standard',
'1010310006': 'Education Standard (Staff)',
'1010310007': 'Education Standard (Extra Student)',
}
# Deze SKU's worden altijd getoond, ook als er (nog) niemand een licentie heeft
DEFAULT_SKUS = ['1010310008']
# Licenties die de scholengroep niet gebruikt: niet tonen, tenzij iemand er toch (per vergissing) één heeft
VERBORGEN_SKUS = {'1010310009'} # Education Plus (Staff)
CACHE_SECONDS = 10 * 60
def _soort_fout(e):
"""'scope' = delegatie/scope ontbreekt, 'api' = API niet ingeschakeld, anders None."""
laag = str(e).lower()
if 'unauthorized_client' in laag or 'access_denied' in laag or 'invalid_scope' in laag:
return 'scope'
if 'accessnotconfigured' in laag or 'has not been used' in laag or 'is disabled' in laag \
or 'service_disabled' in laag:
return 'api'
return None
def _google_reden(e):
"""De foutboodschap van Google zelf (zonder de lange URL)."""
try:
import json
inhoud = json.loads(getattr(e, 'content', b'') or b'{}')
fout = inhoud.get('error') or {}
if isinstance(fout, dict) and fout.get('message'):
return f"{getattr(getattr(e, 'resp', None), 'status', '')} {fout['message']}".strip()
except Exception:
pass
return str(e)[:250]
class GoogleLicenseService:
def __init__(self, directory_service=None):
self._cache = None # (timestamp, data)
self._licensing = None
self._klant = None # customerId die werkt
# Bestaande Directory-client hergebruiken (scopes zijn daar al geautoriseerd)
self._directory = directory_service
# ---------- API-clients (lui aangemaakt, zodat de app ook start zonder scope) ----------
def _creds(self, scope):
return google_credentials([scope], subject=Config.GOOGLE_ADMIN_EMAIL)
def _licensing_api(self):
if self._licensing is None:
self._licensing = build('licensing', 'v1', credentials=self._creds(LICENSING_SCOPE),
static_discovery=False, cache_discovery=False)
return self._licensing
def _directory_api(self):
if self._directory is None:
from services.google_service import GoogleAdminService
self._directory = GoogleAdminService().service
return self._directory
# ---------- Database: aangekochte aantallen ----------
@staticmethod
def ensure_table():
execute_query("""
CREATE TABLE IF NOT EXISTS google_licenties (
SKU_ID VARCHAR(32) NOT NULL PRIMARY KEY,
Naam VARCHAR(128) NOT NULL,
Aangekocht INT NOT NULL DEFAULT 0,
Bijgewerkt DATETIME NULL,
Bijgewerkt_door VARCHAR(100) NULL
)""", commit=True)
def get_purchased(self):
rows = execute_query("SELECT SKU_ID, Naam, Aangekocht, Bijgewerkt, Bijgewerkt_door FROM google_licenties")
return {r['SKU_ID']: r for r in rows}
def set_purchased(self, sku_id, aantal, gebruiker):
naam = SKU_NAMES.get(sku_id, sku_id)
execute_query("""
INSERT INTO google_licenties (SKU_ID, Naam, Aangekocht, Bijgewerkt, Bijgewerkt_door)
VALUES (%s, %s, %s, NOW(), %s)
ON DUPLICATE KEY UPDATE Aangekocht = VALUES(Aangekocht), Naam = VALUES(Naam),
Bijgewerkt = NOW(), Bijgewerkt_door = VALUES(Bijgewerkt_door)
""", (sku_id, naam, int(aantal), gebruiker), commit=True)
# ---------- Google: toegewezen licenties ----------
def _klant_kandidaten(self):
"""Waarden om als customerId te proberen. De Licensing API wil het PRIMAIRE domein van de Google-klant
(of de klant-ID, bv. C01abc23d). Het domein dat in PIVOT bij de school staat, is soms een secundair domein
of alias: dan antwoordt Google met 403/400. Daarom ook de klant-ID van de beheerder proberen."""
kandidaten = [Config.SCHOOL_DOMAIN]
try:
u = self._directory_api().users().get(userKey=Config.GOOGLE_ADMIN_EMAIL,
fields='customerId,primaryEmail').execute()
kandidaten += [u.get('customerId'), (u.get('primaryEmail') or '').partition('@')[2]]
except Exception as e:
sys.stderr.write(f"Licenties: klant-ID van de beheerder niet op te halen: {e}\n")
kandidaten.append((Config.GOOGLE_ADMIN_EMAIL or '').partition('@')[2])
return [k for k in dict.fromkeys(k.strip() for k in kandidaten if k and k.strip())]
def _list_assignments(self):
api = self._licensing_api()
laatste = None
for klant in (self._klant_kandidaten() if not getattr(self, '_klant', None) else [self._klant]):
assignments, token = [], None
try:
while True:
resp = api.licenseAssignments().listForProduct(
productId=EDU_PRODUCT_ID, customerId=klant, maxResults=1000, pageToken=token).execute()
assignments.extend(resp.get('items', []))
token = resp.get('nextPageToken')
if not token:
self._klant = klant # onthouden welke waarde werkt
return assignments
except Exception as e:
status = getattr(getattr(e, 'resp', None), 'status', None)
if status not in (400, 403, 404) or _soort_fout(e) in ('scope', 'api'):
raise
sys.stderr.write(f"Licenties met customerId={klant} mislukt: {e}\n")
laatste = e
raise laatste or RuntimeError("Geen Google-klant gevonden om licenties voor op te vragen.")
def _uitleg(self, e):
"""Begrijpelijke melding met de echte reden van Google en wat je kan nakijken."""
from tenancy import huidige_school
sleutel = huidige_school().google_sleutel or {}
client_id = sleutel.get('client_id', '?')
project = sleutel.get('project_id', '?')
admin = Config.GOOGLE_ADMIN_EMAIL
soort = _soort_fout(e)
reden = _google_reden(e)
if soort == 'scope':
return (f"Google weigert de toegang (unauthorized_client): voor client-ID {client_id} staat de scope "
f"{LICENSING_SCOPE} niet in de domeinbrede delegatie. Kijk na in de Admin-console van het domein van "
f"{admin} (Beveiliging › API-beheer › Domeinbrede delegatie) dat de scope bij exact deze client-ID "
"staat — elke school met een eigen JSON-sleutel heeft een eigen client-ID. Een wijziging kan tot "
"een uur duren.")
if soort == 'api':
return (f"De Enterprise License Manager API staat niet aan in Google Cloud-project '{project}' "
"(console.cloud.google.com › API's en services › Bibliotheek › 'Enterprise License Manager API' "
f"› Inschakelen). Google: {reden}")
return (f"Google weigert de licenties ({reden}). De delegatie zelf werkt, dus de scope is in orde. "
f"Mogelijke oorzaken: {admin} is geen hoofdbeheerder (super admin) van het domein, of het domein "
f"'{Config.SCHOOL_DOMAIN}' is niet het primaire domein van de Google-klant (ook de klant-ID werd "
f"geprobeerd), of deze school heeft geen Education Standard/Plus-licenties. Service-account: "
f"client-ID {client_id}, project '{project}'.")
def _user_details(self):
"""Alle gebruikers met OU en status, zodat we per licentie context kunnen tonen."""
api = self._directory_api()
users, token = {}, None
while True:
resp = api.users().list(
customer='my_customer', maxResults=500, pageToken=token,
projection='basic',
fields='nextPageToken,users(primaryEmail,name/fullName,orgUnitPath,suspended,archived,lastLoginTime)'
).execute()
for u in resp.get('users', []):
users[u['primaryEmail'].lower()] = u
token = resp.get('nextPageToken')
if not token:
return users
def get_overview(self, force=False):
"""
Geeft (data, fout) terug. data = {
'skus': [ {sku_id, naam, in_gebruik, aangekocht, vrij, procent, geschorst, ...}, ... ],
'users': [ {email, naam, ou, sku_id, sku_naam, geschorst, gearchiveerd, laatste_login}, ... ],
'opgehaald': timestamp
}
"""
if not force and self._cache and time.time() - self._cache[0] < CACHE_SECONDS:
return self._cache[1], None
try:
assignments = self._list_assignments()
except Exception as e:
sys.stderr.write(f"Licenties ophalen mislukt: {e}\n")
self._licensing = None # volgende keer opnieuw opbouwen (bv. na een nieuwe JSON-sleutel)
self._klant = None
try:
return None, self._uitleg(e)
except Exception:
return None, str(e)[:400]
try:
directory = self._user_details()
except Exception as e:
sys.stderr.write(f"Gebruikersdetails ophalen mislukt (niet kritisch): {e}\n")
directory = {}
try:
purchased = self.get_purchased()
except Exception as e:
sys.stderr.write(f"Tabel google_licenties niet leesbaar: {e}\n")
purchased = {}
users, counts, suspended_counts = [], {}, {}
for a in assignments:
sku = a.get('skuId')
email = (a.get('userId') or '').lower()
d = directory.get(email, {})
geschorst = bool(d.get('suspended'))
gearchiveerd = bool(d.get('archived'))
counts[sku] = counts.get(sku, 0) + 1
if geschorst or gearchiveerd:
suspended_counts[sku] = suspended_counts.get(sku, 0) + 1
users.append({
'email': email,
'naam': (d.get('name') or {}).get('fullName', ''),
'ou': d.get('orgUnitPath', ''),
'sku_id': sku,
'sku_naam': SKU_NAMES.get(sku, a.get('skuName', sku)),
'geschorst': geschorst,
'gearchiveerd': gearchiveerd,
'onbekend': not d,
'laatste_login': (d.get('lastLoginTime') or '')[:10].replace('1970-01-01', ''),
})
users.sort(key=lambda u: (u['sku_naam'], u['ou'], u['email']))
sku_ids = list(dict.fromkeys(DEFAULT_SKUS + list(counts) + list(purchased)))
skus = []
for sku in sku_ids:
in_gebruik = counts.get(sku, 0)
if sku in VERBORGEN_SKUS and not in_gebruik:
continue
aangekocht = int(purchased.get(sku, {}).get('Aangekocht') or 0)
vrij = aangekocht - in_gebruik
procent = round(in_gebruik / aangekocht * 100) if aangekocht else None
skus.append({
'sku_id': sku,
'naam': SKU_NAMES.get(sku, purchased.get(sku, {}).get('Naam', sku)),
'in_gebruik': in_gebruik,
'aangekocht': aangekocht,
'vrij': vrij,
'procent': procent,
'geschorst': suspended_counts.get(sku, 0),
'bijgewerkt': purchased.get(sku, {}).get('Bijgewerkt'),
'bijgewerkt_door': purchased.get(sku, {}).get('Bijgewerkt_door'),
})
data = {'skus': skus, 'users': users, 'opgehaald': time.time()}
self._cache = (time.time(), data)
return data, None
def clear_cache(self):
self._cache = None
+391
View File
@@ -0,0 +1,391 @@
from googleapiclient.discovery import build
from config import Config
from tenancy import google_credentials
import sys
from datetime import datetime, timedelta, timezone
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
class GoogleAdminService:
def __init__(self):
#lijst met scopes
self.conf = Config()
self.server = Config.GOOGLE_SMTP_SERVER
self.port = Config.GOOGLE_SMTP_PORT
self.sender = Config.GOOGLE_SMTP_SENDER_adres
# Serviceaccount van de huidige school, gedelegeerd naar de admin van die school
self.delegated_creds = google_credentials(self.conf.GOOGLE_API_SCOPES, subject=self.conf.GOOGLE_ADMIN_EMAIL)
self.service = build('admin', 'directory_v1', credentials=self.delegated_creds,static_discovery=False)
#functie die passwoord aanpast
def change_password(self, email, new_password):
"""Wijzigt het wachtwoord van een gebruiker in Google Workspace."""
try:
self.service.users().update(
userKey=email,
body={'password': new_password, 'changePasswordAtNextLogin': True}
).execute()
return True, "Wachtwoord succesvol gewijzigd."
except Exception as e:
return False, str(e)
#Functie die info ophaalt
def get_user_info(self, email):
"""Haalt info op over een specifieke gebruiker."""
try:
user = self.service.users().get(userKey=email).execute()
return user
except:
return None
#Functie die nieuwe gebruiker aanmaakt
def create_user(self, first_name, last_name, password, org_unit_path):
try:
primary_email = f"{first_name.lower()}.{last_name.lower()}@{self.conf.SCHOOL_DOMAIN}"
user_body = {
'primaryEmail': primary_email,
'name': {'givenName': first_name.capitalize(), 'familyName': last_name.capitalize()},
'password': password,
'orgUnitPath': org_unit_path,
'changePasswordAtNextLogin': True
}
self.service.users().insert(body=user_body).execute()
return True, primary_email
except Exception as e:
return False, str(e)
#functie die opzoek of genruiger reeds aawezig is
def search_users(self, query_string):
"""
Zoekt gebruikers in Google Workspace op basis van een query.
Bijv: query_string="name:Jan*" of "email:jan@school.be"
"""
try:
results = self.service.users().list(
customer='my_customer',
query=query_string,
maxResults=10,
orderBy='email'
).execute()
return results.get('users', [])
except Exception as e:
print(f"Fout bij zoeken in Google: {e}")
return []
#functie die toestelstatus aanpast (disabled/enabled)
def toggle_device_status(self, device_id, lock=True):
print("My debug message", flush=True)
"""Blokkeert (disable) of deblokkeert (reenable) een ChromeOS device."""
try:
import sys
#print die gebruikt word als debug check
sys.stderr.write(f"DEBUG: Calling Google API for ID: {device_id}\n")
action_type = 'disable' if lock else 'reenable'
self.service.chromeosdevices().action(
customerId='my_customer',
resourceId=device_id,
body={'action': action_type}
).execute()
return True, f"Toestel status succesvol gewijzigd naar {action_type}"
except Exception as e:
import sys
sys.stderr.write(f"\n--- GOOGLE API ERROR: {str(e)} ---\n")
return False, f"Google API Fout: {str(e)}"
#Functie die na het ingeven van de serienummer het DeviceID ophaalt (dit is een andere string dan het serienummer en essentieel om het toestel te kunnen blokkeren)
def lijst_chromebooks(self, ous=None):
"""Alle ingeschreven Chromebooks (niet uitgeschreven), eventueel beperkt tot OU's (incl. sub-OU's).
Geeft een lijst dicts met serialNumber, model, status, orgUnitPath, annotatedAssetId."""
velden = 'nextPageToken,chromeosdevices(serialNumber,model,status,orgUnitPath,annotatedAssetId)'
toestellen, gezien = [], set()
for ou in (ous or [None]):
token = None
while True:
params = dict(customerId='my_customer', maxResults=200, projection='BASIC', fields=velden,
pageToken=token)
if ou:
params.update(orgUnitPath=ou, includeChildOrgunits=True)
resp = self.service.chromeosdevices().list(**params).execute()
for d in resp.get('chromeosdevices', []):
serie = (d.get('serialNumber') or '').strip()
if not serie or serie in gezien or d.get('status') == 'DEPROVISIONED':
continue
gezien.add(serie)
toestellen.append(d)
token = resp.get('nextPageToken')
if not token:
break
return toestellen
def chromebook_info(self, serienummer):
"""Gebruiksinfo van één Chromebook: recente gebruikers, laatste synchronisatie, actieve tijd per dag.
Geeft (dict, None) of (None, foutmelding)."""
serie = str(serienummer or '').strip()
if not serie:
return None, "Dit toestel heeft geen serienummer."
velden = ('chromeosdevices(serialNumber,model,status,orgUnitPath,lastSync,lastEnrollmentTime,osVersion,'
'recentUsers,activeTimeRanges,annotatedUser,annotatedLocation)')
devices = []
for query in (f"id:{serie}", serie): # id: = zoeken op serienummer
resp = self.service.chromeosdevices().list(customerId='my_customer', query=query, projection='FULL',
maxResults=5, fields=velden).execute()
devices = [d for d in resp.get('chromeosdevices', [])
if (d.get('serialNumber') or '').strip().upper() == serie.upper()]
if devices:
break
if not devices:
return None, f"Geen Chromebook met serienummer {serie} gevonden in Google Admin."
d = devices[0]
return {
'serienummer': d.get('serialNumber'),
'model': d.get('model'),
'status': d.get('status'),
'ou': d.get('orgUnitPath'),
'os': d.get('osVersion'),
'laatste_sync': d.get('lastSync'),
'ingeschreven': d.get('lastEnrollmentTime'),
'gebruikers': [{'email': u.get('email') or '', 'beheerd': u.get('type') == 'USER_TYPE_MANAGED'}
for u in d.get('recentUsers', [])],
'actief': sorted(({'datum': r.get('date'), 'minuten': round(int(r.get('activeTime') or 0) / 60000)}
for r in d.get('activeTimeRanges', [])), key=lambda r: r['datum'] or '',
reverse=True)[:14],
}, None
def zoek_chromebook(self, serienummer):
"""(deviceId, status) van de Chromebook met exact dit serienummer, of (None, foutmelding).
Status: ACTIVE, DISABLED, DEPROVISIONED, ..."""
serie = str(serienummer or '').strip().replace('\t', '').replace('\n', '').replace('\r', '')
if not serie:
return None, "Dit toestel heeft geen serienummer."
gevonden = []
for query in (f"id:{serie}", serie): # id: = zoeken op serienummer
resp = self.service.chromeosdevices().list(customerId='my_customer', query=query, maxResults=10,
projection='BASIC',
fields='chromeosdevices(deviceId,serialNumber,status)').execute()
gevonden = [d for d in resp.get('chromeosdevices', [])
if (d.get('serialNumber') or '').strip().upper() == serie.upper()]
if gevonden:
break
if not gevonden:
return None, f"Geen Chromebook met serienummer {serie} gevonden in Google Admin."
# Bij een opnieuw ingeschreven toestel: de niet-uitgeschreven versie
d = next((x for x in gevonden if x.get('status') != 'DEPROVISIONED'), gevonden[0])
return {'id': d.get('deviceId'), 'status': d.get('status')}, None
def get_device_id_by_serial(self, serienummer):
"""Zoekt het deviceId op basis van het fysieke serienummer."""
print(f"DEBUG: Zoeken naar serienummer: '{serienummer}'", flush=True)
try:
# Verwijder spaties, tabs (\t) en newlines (\n)
clean_serial = str(serienummer).strip().replace('\t', '').replace('\n', '').replace('\r', '')
# Voer de API call uit met het schone serienummer
results = self.service.chromeosdevices().list(
customerId='my_customer',
query=f"{clean_serial}"
).execute()
devices = results.get('chromeosdevices', [])
if devices:
dev_id = devices[0].get('deviceId')
sys.stderr.write(f"DEBUG: Toestel gevonden! ID: {dev_id}\n")
return dev_id, None
return None, f"Device met serienummer {clean_serial} niet gevonden."
except Exception as e:
sys.stderr.write(f"DEBUG: Fout in get_device_id_by_serial: {str(e)}\n")
return None, str(e)
def list_org_units(self):
"""Haalt alle Organizational Units op en sorteert ze alfabetisch."""
try:
results = self.service.orgunits().list(customerId='my_customer', type='all').execute()
org_units = results.get('organizationUnits', [])
# Sorteren op het volledige pad (bijv. /Leerlingen komt na /Beheer)
org_units.sort(key=lambda x: x.get('orgUnitPath', '').lower())
return org_units
except Exception as e:
import sys
sys.stderr.write(f"Fout bij ophalen OUs: {e}\n")
return []
def move_users_ou(self, source_ou, target_ou, only_enrolled=True, enrolled_emails=None):
"""
Verplaatst gebruikers van source naar target.
enrolled_emails: een lijst met e-mailadressen die volgens Informat/database ingeschreven zijn.
"""
users_moved = 0
try:
# We zoeken specifiek gebruikers in de bron-OU om API-quota te besparen
query = f"orgUnitPath='{source_ou}'"
results = self.service.users().list(customer='my_customer', query=query).execute()
users = results.get('users', [])
for user in users:
user_email = user.get('primaryEmail')
# Logica voor de radiobuttons uit je HTML
should_move = False
if not only_enrolled:
should_move = True # "Alle leerlingen"
elif enrolled_emails and user_email in enrolled_emails:
should_move = True # "Enkel ingeschreven leerlingen"
if should_move:
self.service.users().update(
userKey=user_email,
body={'orgUnitPath': target_ou}
).execute()
users_moved += 1
return True, f"{users_moved} gebruikers succesvol verplaatst."
except Exception as e:
return False, f"Fout bij verplaatsen: {str(e)}"
def move_chromebooks_ou(self, source_ou, target_ou):
"""Verplaatst alle Chromebooks van de bron-OU naar de doel-OU."""
devices_moved = 0
next_page_token = None
try:
while True:
# We halen alle toestellen op en filteren handmatig op de OU
results = self.service.chromeosdevices().list(
customerId='my_customer',
pageToken=next_page_token,
maxResults=100
).execute()
devices = results.get('chromeosdevices', [])
for device in devices:
if device.get('orgUnitPath') == source_ou:
device_id = device['deviceId']
self.service.chromeosdevices().update(
customerId='my_customer',
deviceId=device_id,
body={'orgUnitPath': target_ou}
).execute()
devices_moved += 1
next_page_token = results.get('nextPageToken')
if not next_page_token:
break
return True, f"{devices_moved} Chromebooks succesvol verplaatst."
except Exception as e:
return False, f"Fout bij verplaatsen Chromebooks: {str(e)}"
def toggle_ou_devices_status(self, org_unit_path, block=True):
"""
Blokkeert of deblokkeert alle ChromeOS toestellen in een specifieke OU.
Voorkomt 412 errors door de huidige status te controleren.
"""
devices_processed = 0
action_type = 'disable' if block else 'reenable'
# De status die het toestel AL heeft als de actie overbodig is
forbidden_status = 'DISABLED' if block else 'ACTIVE'
next_page_token = None
try:
while True:
results = self.service.chromeosdevices().list(
customerId='my_customer',
pageToken=next_page_token,
maxResults=100
).execute()
devices = results.get('chromeosdevices', [])
for device in devices:
if device.get('orgUnitPath') == org_unit_path:
current_status = device.get('status') # bijv. 'ACTIVE' of 'DISABLED'
device_id = device['deviceId']
serial = device.get('serialNumber', 'Onbekend')
# Alleen actie ondernemen als de status nog niet is
if current_status != forbidden_status:
try:
self.service.chromeosdevices().action(
customerId='my_customer',
resourceId=device_id,
body={'action': action_type}
).execute()
devices_processed += 1
except Exception as e:
print(f"Kon {serial} niet wijzigen: {e}")
else:
print(f"Overgeslagen: {serial} is al {current_status}")
next_page_token = results.get('nextPageToken')
if not next_page_token:
break
status_tekst = "geblokkeerd" if block else "gedeblokkeerd"
return True, f"{devices_processed} toestellen succesvol {status_tekst}. Toestellen die al de juiste status hadden zijn overgeslagen."
except Exception as e:
return False, f"Algemene fout: {str(e)}"
def send_custom_mail(self, receiver_email, subject, body):
msg = MIMEMultipart()
msg['From'] = self.sender
msg['To'] = receiver_email
msg['Subject'] = subject
msg.attach(MIMEText(body, 'html')) # We sturen het als HTML voor mooie opmaak
try:
with smtplib.SMTP(self.server, self.port) as server:
server.starttls()
# Geen login nodig voor de relay indien geconfigureerd op IP
wachtwoord = Config.GOOGLE_SMTP_EMAIL_PASSWORD
if wachtwoord and wachtwoord != 'None':
server.login(self.sender, wachtwoord)
server.sendmail(self.sender, receiver_email, msg.as_string())
return True, "Email succesvol verzonden"
except Exception as e:
return False, str(e)
def archive_inactive_users(self, source_ou, days_inactive):
"""Archiveert gebruikers die X dagen niet zijn ingelogd, zonder van OU te wisselen."""
users_archived = 0
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_inactive)
try:
# Zoek gebruikers in de bron-OU
query = f"orgUnitPath='{source_ou}'"
results = self.service.users().list(customer='my_customer', query=query).execute()
users = results.get('users', [])
for user in users:
last_login_str = user.get('lastLoginTime')
user_email = user.get('primaryEmail')
should_archive = False
if last_login_str:
last_login_dt = datetime.fromisoformat(last_login_str.replace('Z', '+00:00'))
if last_login_dt < cutoff_date:
should_archive = True
else:
# Nooit ingelogd = inactief
should_archive = True
if should_archive:
# We sturen enkel 'archived': True en GEEN 'orgUnitPath'
self.service.users().update(
userKey=user_email,
body={'archived': True}
).execute()
users_archived += 1
return True, f"{users_archived} gebruikers succesvol gearchiveerd (OU ongewijzigd)."
except Exception as e:
return False, f"Fout bij archiveren: {str(e)}"
+619
View File
@@ -0,0 +1,619 @@
"""
GO! Pro: personeelsleden van de actieve school uit Informat (Personeel WEB API, versie 2).
- GET /employees → naam, voornaam, isActive, comnrs (telefoonnummers), ...
- GET /employees/assignments → opdrachten met ambt (functie), begindatum en einddatum
Scope: api_informat_sas_personeel.personeel.{instellingsnummer} (zelfde client-ID/secret als de leerlingen-API,
maar de client moet ook deze scope hebben).
Per personeelslid komt er één rij. Functie en rol kies je in PIVOT uit dezelfde lijst (standaard Leerkracht,
een directeur Directeur) en worden per personeelslid bewaard in de schooldatabase (tabel gopro_keuzes).
"""
import re
import unicodedata
from datetime import date, datetime, timezone
import requests
from config import Config
from database import execute_query
from services.sync_service import informat_get
ROLLEN = [
'Algemeen Directeur', 'Coordinerend Directeur', 'Communicatie', 'Financiën', 'Infrastructuur', 'HR', 'ICT',
'Preventie en veiligheid', 'MVD', 'Administratief', 'RVB-lid', 'RVB', 'Voorzitter RVB', 'Beleidsondersteuner',
'Directeur Coordinator', 'Directeur', 'Internationalisering', 'Leerkracht', 'GO Studio Schoolbeheerder',
'Medewerker SGR', 'Adjunct-directeur', 'Administratief school', 'Beleidsondersteuner school',
]
STANDAARD_ROL = 'Leerkracht'
def standaard_rol(ambten):
"""Functie en rol zolang er in PIVOT niets gekozen is: een directeur blijft Directeur (adjunct-directeur blijft
Adjunct-directeur), alle anderen Leerkracht. `ambten` = de ambten uit Informat (lopende opdrachten + hoofdambt)."""
tekst = ' '.join(a.lower() for a in ambten if a)
if 'adjunct' in tekst and 'directeur' in tekst:
return 'Adjunct-directeur'
if 'directeur' in tekst:
return 'Directeur'
return STANDAARD_ROL
# Kolommen van het GO! Pro-importbestand (zelfde namen en volgorde)
EXPORT_KOLOMMEN = ['Naam', 'Achternaam', 'AanmeldEmailadres', 'UPN', 'CommunicatiefEmailadres',
'OnderwijsinstellingNr', 'Functie', 'rol', 'StartDatum', 'EindDatum', 'ProfessioneelTelefoonNr']
# ---------------------------------------------------------------------
# Hulpfuncties
# ---------------------------------------------------------------------
def naamdeel(tekst):
"""'Van den Broeck' → 'vandenbroeck', 'Zoë' → 'zoe': kleine letters, geen accenten, spaties of leestekens
(een koppelteken blijft staan)."""
t = unicodedata.normalize('NFKD', str(tekst or '')).encode('ascii', 'ignore').decode()
return re.sub(r'[^a-z0-9-]', '', t.lower()).strip('-')
def upn_van(voornaam, achternaam):
v, a = naamdeel(voornaam), naamdeel(achternaam)
return f"{v}.{a}" if v and a else (v or a)
def _datum(waarde):
"""'2026-09-01T00:00:00' → date; leeg of ongeldig → None."""
if not waarde:
return None
try:
return datetime.strptime(str(waarde)[:10], '%Y-%m-%d').date()
except ValueError:
return None
def _lijst(data, *sleutels):
if isinstance(data, list):
return data
for s in sleutels:
if isinstance(data, dict) and isinstance(data.get(s), list):
return data[s]
return []
def _telefoon(personeelslid):
"""Enkel een professioneel nummer (type 'Werk'): privénummers gaan niet naar GO! Pro."""
for c in personeelslid.get('comnrs') or []:
if str(c.get('type') or '').strip().lower() == 'werk' and c.get('nr'):
return str(c['nr']).strip()
return ''
# ---------------------------------------------------------------------
# Keuzes voor functie en rol (schooldatabase)
# ---------------------------------------------------------------------
VELDEN = ('functie', 'rol', 'email', 'upn')
ADRES_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
def ensure_tables():
execute_query("""
CREATE TABLE IF NOT EXISTS gopro_keuzes (
Person_ID VARCHAR(64) NOT NULL PRIMARY KEY,
Functie VARCHAR(100) NULL,
Rol VARCHAR(100) NULL,
Email VARCHAR(255) NULL,
UPN VARCHAR(255) NULL,
Bijgewerkt DATETIME NOT NULL,
Door VARCHAR(255) NULL
)""", commit=True)
# Bestaande tabel van vóór de bewerkbare e-mail/UPN
kolommen = {r['Field'].lower() for r in execute_query("SHOW COLUMNS FROM gopro_keuzes") or []}
for kolom in ('Email', 'UPN'):
if kolom.lower() not in kolommen:
execute_query(f"ALTER TABLE gopro_keuzes ADD COLUMN {kolom} VARCHAR(255) NULL AFTER Rol", commit=True)
def keuzes():
"""{person_id: {'functie': ..., 'rol': ...}} met wat in PIVOT gekozen is (None = niets gekozen).
Rollen uit de vroegere tabel gopro_rollen (indien die bestaat) tellen mee als er nog geen nieuwe keuze is."""
ensure_tables()
uit = {r['Person_ID']: {'functie': r['Functie'], 'rol': r['Rol'], 'email': r['Email'], 'upn': r['UPN']}
for r in execute_query("SELECT Person_ID, Functie, Rol, Email, UPN FROM gopro_keuzes") or []}
if execute_query("SHOW TABLES LIKE 'gopro_rollen'"):
for r in execute_query("SELECT Person_ID, Rol FROM gopro_rollen ORDER BY Bijgewerkt DESC") or []:
k = uit.setdefault(r['Person_ID'], {'functie': None, 'rol': None, 'email': None, 'upn': None})
k['rol'] = k['rol'] or r['Rol']
return uit
def keuze_opslaan(person_id, veld, waarde, door=None):
"""Bewaart één keuze. Voor e-mail en UPN: leeg = terug naar het standaardadres (voornaam.achternaam@domein)."""
if veld not in VELDEN:
raise ValueError(f"Onbekend veld '{veld}'")
if not person_id:
raise ValueError("Geen personeelslid opgegeven")
if veld in ('email', 'upn'):
waarde = (waarde or '').strip().lower() or None
if waarde and not ADRES_RE.match(waarde):
raise ValueError(f"'{waarde}' is geen geldig e-mailadres.")
elif waarde not in ROLLEN:
raise ValueError(f"Onbekende waarde '{waarde}'")
ensure_tables()
kolom = {'functie': 'Functie', 'rol': 'Rol', 'email': 'Email', 'upn': 'UPN'}[veld]
execute_query(
f"INSERT INTO gopro_keuzes (Person_ID, {kolom}, Bijgewerkt, Door) VALUES (%s, %s, NOW(), %s) "
f"ON DUPLICATE KEY UPDATE {kolom} = VALUES({kolom}), Bijgewerkt = NOW(), Door = VALUES(Door)",
(person_id, waarde, door), commit=True)
def pas_keuzes_toe(rij, gekozen):
"""Zet functie en rol van een rij: wat in PIVOT gekozen is, anders de standaard uit Informat."""
k = gekozen.get(rij['person_id']) or {}
rij['functie'] = k.get('functie') or rij['standaard']
rij['rol'] = k.get('rol') or rij['standaard']
rij['email'] = k.get('email') or rij['standaard_email']
rij['upn'] = k.get('upn') or rij['standaard_upn']
rij['email_aangepast'] = bool(k.get('email'))
rij['upn_aangepast'] = bool(k.get('upn'))
return rij
def markeer_dubbel(rijen):
"""Rijen waarvan het e-mailadres ook bij een ander personeelslid staat (bv. zelfde naam) krijgen 'dubbel'."""
per_mail = {}
for r in rijen:
per_mail.setdefault(r['email'], set()).add(r['person_id'])
for r in rijen:
r['dubbel'] = bool(r['email']) and len(per_mail[r['email']]) > 1
return rijen
def google_kleur(rij, google):
"""'account' als e-mail of UPN een account is in Google, 'alias' als één ervan een alias is, anders 'geen'.
Zelfde regel als de kleuren op de pagina."""
soorten = [google.get((rij.get(v) or '').strip().lower()) for v in ('email', 'upn')]
return 'account' if 'primair' in soorten else 'alias' if 'alias' in soorten else 'geen'
def google_adressen(vernieuw=False):
"""{adres: 'primair' | 'alias'} voor alle gebruikers in Google Workspace van de huidige school, 10 minuten
bewaard. Gebruikt de bestaande Directory-koppeling (scope admin.directory.user)."""
import time
from tenancy import huidige_school
school = huidige_school()
bewaard = school.cache.get('gopro_google')
if bewaard and not vernieuw and time.time() - bewaard[0] < 600:
return bewaard[1]
from services.google_service import GoogleAdminService
users = GoogleAdminService().service.users()
adressen, token = {}, None
while True:
res = users.list(customer='my_customer', maxResults=500, pageToken=token,
fields='nextPageToken,users(primaryEmail,aliases)').execute()
for u in res.get('users', []):
for a in u.get('aliases') or []:
adressen.setdefault(str(a).lower(), 'alias')
if u.get('primaryEmail'):
adressen[str(u['primaryEmail']).lower()] = 'primair'
token = res.get('nextPageToken')
if not token:
break
school.cache['gopro_google'] = (time.time(), adressen)
return adressen
# ---------------------------------------------------------------------
# Informat
# ---------------------------------------------------------------------
class GoProService:
def __init__(self):
self.api = str(Config.INFORMAT_PERSONEEL_API or '').rstrip('/')
self.scope_basis = re.sub(r'\.\d+$', '', str(Config.INFORMAT_PERSONEEL_SCOPE or '').strip())
self.nummers = [n for n, _ in (Config.INFORMAT_CAMPUSSEN or [])] or [Config.INFORMAT_INST_NUMBER]
def _token(self, nummer):
try:
r = requests.post(Config.INFORMAT_AUTH_URL, timeout=15, data={
'grant_type': 'client_credentials', 'client_id': Config.INFORMAT_ID,
'client_secret': Config.INFORMAT_SECRET, 'scope': f"{self.scope_basis}.{nummer}"})
except requests.RequestException as e:
raise RuntimeError(f"Informat niet bereikbaar: {e}")
try:
antwoord = r.json()
except ValueError:
antwoord = {}
if r.status_code == 200 and antwoord.get('access_token'):
return antwoord['access_token']
# Het echte antwoord van de Informat-aanmeldserver tonen (bv. invalid_scope of invalid_client)
reden = ' – '.join(str(antwoord[k]) for k in ('error', 'error_description') if antwoord.get(k)) \
or r.text[:200] or f"HTTP {r.status_code}"
uitleg = {
'invalid_scope': f"de client heeft de scope {self.scope_basis}.{nummer} (nog) niet.",
'invalid_client': "de client-ID of het client-secret van deze school klopt niet.",
'unauthorized_client': "deze client mag zich niet op deze manier aanmelden.",
}.get(antwoord.get('error'), "")
raise RuntimeError(
f"Aanmelden bij de Informat Personeel-API mislukt voor instellingsnummer {nummer} "
f"(scope {self.scope_basis}.{nummer}). Informat antwoordt: {reden}" + (f". Dat betekent: {uitleg}" if uitleg else ""))
def _get(self, pad, nummer, token, schooljaar):
headers = {'Authorization': f'BEARER {token}', 'InstituteNo': nummer, 'Api-Version': '2',
'Accept': 'application/json',
'Timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000Z')}
r = informat_get(f"{self.api}{pad}?schoolYear={schooljaar}", headers)
if r.status_code in (401, 403):
raise RuntimeError(f"Informat weigert de toegang tot {pad} (instellingsnummer {nummer}). Heeft de client "
f"de scope {self.scope_basis}.{nummer}?")
if r.status_code != 200:
raise RuntimeError(f"Informat Personeel-API fout {r.status_code} op {pad} "
f"(instellingsnummer {nummer}): {r.text[:300]}")
return r.json()
def personeel(self, domein, instellingsnummer_officieel='', schooljaar=None, vandaag=None):
"""Lijst van rijen (dicts) voor het scherm en de export, gesorteerd op achternaam en voornaam."""
schooljaar = schooljaar or Config.SCHOOLYEAR
vandaag = vandaag or date.today()
domein = (domein or '').lstrip('@')
gekozen = keuzes()
rijen, gezien = [], set()
for nummer in self.nummers:
token = self._token(nummer)
leden = _lijst(self._get('/employees', nummer, token, schooljaar), 'employees', 'items', 'data')
opdrachten = _lijst(self._get('/employees/assignments', nummer, token, schooljaar),
'assignments', 'items', 'data')
# Lopende opdrachten per personeelslid (alle ambten samen): vroegste begin, laatste einde
per = {}
for o in opdrachten:
begin, einde = _datum(o.get('begindatum')), _datum(o.get('einddatum'))
if begin and begin > vandaag or einde and einde < vandaag:
continue
f = per.setdefault(str(o.get('personId')), {'begin': begin, 'einde': einde, 'open': einde is None,
'ambten': set()})
f['ambten'].add((o.get('ambt') or '').strip())
if begin and (not f['begin'] or begin < f['begin']):
f['begin'] = begin
if einde is None:
f['open'] = True
elif not f['open'] and (not f['einde'] or einde > f['einde']):
f['einde'] = einde
for p in leden:
pid = str(p.get('personId') or p.get('pPersoon') or '')
if not pid or p.get('isActive') is False or p.get('isOverleden'):
continue
voornaam, achternaam = (p.get('voornaam') or '').strip(), (p.get('naam') or '').strip()
# Eén rij per persoon, ook als die in meer dan één instellingsnummer voorkomt
# (daar kan Informat een ander personId geven: dan herkennen we de persoon aan naam + geboortedatum)
geboren = str(p.get('geboortedatum') or '')[:10]
persoon = (naamdeel(voornaam), naamdeel(achternaam), geboren) if geboren else ('pid', pid)
f = per.get(pid) or {'begin': None, 'einde': None, 'open': True, 'ambten': set()}
ambten = f['ambten'] | {((p.get('hoofdAmbt') or {}).get('naam') or '').strip()}
if pid in gezien or persoon in gezien:
bestaand = next(r for r in rijen if r['person_id'] == pid or r['_persoon'] == persoon)
if f['begin'] and (not bestaand['start'] or f['begin'] < bestaand['start']):
bestaand['start'] = f['begin']
if f['open']:
bestaand['einde'] = None
elif bestaand['einde'] and f['einde'] and f['einde'] > bestaand['einde']:
bestaand['einde'] = f['einde']
bestaand['telefoon'] = bestaand['telefoon'] or _telefoon(p)
nieuw = standaard_rol(ambten) # bv. directeur in het tweede instellingsnummer
if nieuw != STANDAARD_ROL and bestaand['standaard'] == STANDAARD_ROL:
bestaand['standaard'] = nieuw
pas_keuzes_toe(bestaand, gekozen)
continue
gezien.update((pid, persoon))
upn = upn_van(voornaam, achternaam)
rijen.append({
'id': pid,
'person_id': pid,
'_persoon': persoon,
'voornaam': voornaam,
'achternaam': achternaam,
'standaard_email': f"{upn}@{domein}" if upn and domein else '',
'standaard_upn': f"{upn}@{domein}" if upn and domein else upn, # UPN = het volledige e-mailadres
'instellingsnummer': instellingsnummer_officieel or nummer,
'standaard': standaard_rol(ambten),
'start': f['begin'],
'einde': None if f['open'] else f['einde'],
'telefoon': _telefoon(p),
})
pas_keuzes_toe(rijen[-1], gekozen)
markeer_dubbel(rijen)
rijen.sort(key=lambda r: (naamdeel(r['achternaam']), naamdeel(r['voornaam'])))
return rijen
def tel(self, peildatum):
"""Aantal personeelsleden met een lopende opdracht op `peildatum` (één keer per persoon, ook als die in
meerdere instellingsnummers werkt). Geeft (totaal, detail) met detail = [{'naam', 'nummer', 'aantal'}]
per instellingsnummer als de school er meer dan één heeft, anders None."""
from tenancy import _automatisch_schooljaar
schooljaar = _automatisch_schooljaar(peildatum)
namen = dict(Config.INFORMAT_CAMPUSSEN or [])
alle, detail = set(), []
for nummer in self.nummers:
token = self._token(nummer)
leden = _lijst(self._get('/employees', nummer, token, schooljaar), 'employees', 'items', 'data')
opdrachten = _lijst(self._get('/employees/assignments', nummer, token, schooljaar),
'assignments', 'items', 'data')
# personId → persoon (naam + geboortedatum), om dezelfde persoon in een ander nummer te herkennen
persoon = {}
for p in leden:
pid = str(p.get('personId') or p.get('pPersoon') or '')
geboren = str(p.get('geboortedatum') or '')[:10]
persoon[pid] = ((naamdeel(p.get('voornaam')), naamdeel(p.get('naam')), geboren) if geboren
else (nummer, pid))
hier = set()
for o in opdrachten:
begin, einde = _datum(o.get('begindatum')), _datum(o.get('einddatum'))
if (begin and begin > peildatum) or (einde and einde < peildatum):
continue
pid = str(o.get('personId') or '')
if pid:
hier.add(persoon.get(pid, (nummer, pid)))
alle |= hier
detail.append({'naam': namen.get(nummer) or nummer, 'nummer': nummer, 'aantal': len(hier)})
return len(alle), (detail if len(detail) > 1 else None)
# ---------------------------------------------------------------------
# Verzameltabel (centrale database): personeel van alle scholen samen, voor één grote GO! Pro-import
# ---------------------------------------------------------------------
def schooljaar_periode(vandaag=None):
"""(1 september, 31 augustus) van het lopende schooljaar: in de verzameltabel heeft iedereen deze periode."""
vandaag = vandaag or date.today()
jaar = vandaag.year if vandaag.month >= 9 else vandaag.year - 1
return date(jaar, 9, 1), date(jaar + 1, 8, 31)
_periode_gezet = {'dag': None}
def verzamel_ensure():
from database import central_query
central_query("""
CREATE TABLE IF NOT EXISTS GoPro_verzameltabel (
ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
School_code VARCHAR(32) NOT NULL,
School_naam VARCHAR(255) NULL,
Person_ID VARCHAR(64) NOT NULL,
Voornaam VARCHAR(100) NULL,
Achternaam VARCHAR(100) NULL,
Email VARCHAR(255) NULL,
UPN VARCHAR(255) NULL,
Instellingsnummer VARCHAR(20) NULL,
Functie VARCHAR(100) NULL,
Rol VARCHAR(100) NULL,
Startdatum DATE NULL,
Einddatum DATE NULL,
Telefoon VARCHAR(50) NULL,
Toegevoegd DATETIME NOT NULL,
Door VARCHAR(255) NULL,
Soort VARCHAR(10) NOT NULL DEFAULT 'groen',
Verzonden DATETIME NULL,
Verzonden_door VARCHAR(255) NULL,
UNIQUE KEY uq_gopro_verz (School_code, Person_ID),
KEY idx_gopro_verz_email (Email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True)
# Soort: 'groen' = gaat mee in de GO! Pro-CSV, 'genegeerd' = rode lijst (nooit meer tonen, niet exporteren),
# 'verzonden' = al naar GO! Pro doorgestuurd (niet meer in de verzameltabel, nooit opnieuw toevoegen)
kolommen = {r['Field'].lower() for r in central_query("SHOW COLUMNS FROM GoPro_verzameltabel") or []}
if 'soort' not in kolommen:
central_query("ALTER TABLE GoPro_verzameltabel ADD COLUMN Soort VARCHAR(10) NOT NULL DEFAULT 'groen'", commit=True)
if 'verzonden' not in kolommen:
central_query("ALTER TABLE GoPro_verzameltabel ADD COLUMN Verzonden DATETIME NULL, "
"ADD COLUMN Verzonden_door VARCHAR(255) NULL", commit=True)
# Iedereen in de verzameltabel: periode = het lopende schooljaar (1 keer per dag nagekeken)
if _periode_gezet['dag'] != date.today():
start, einde = schooljaar_periode()
central_query("UPDATE GoPro_verzameltabel SET Startdatum = %s, Einddatum = %s WHERE Soort = 'groen' "
"AND (Startdatum IS NULL OR Einddatum IS NULL OR Startdatum <> %s OR Einddatum <> %s)",
(start, einde, start, einde), commit=True)
_periode_gezet['dag'] = date.today()
def verzameld_sleutels():
"""({(school_code, person_id): soort}, {e-mailadres: soort}) van alles in de verzameltabel (groen én genegeerd)."""
from database import central_query
verzamel_ensure()
rijen = central_query("SELECT School_code, Person_ID, Email, Soort FROM GoPro_verzameltabel") or []
return ({(r['School_code'], str(r['Person_ID'])): r['Soort'] for r in rijen},
{(r['Email'] or '').strip().lower(): r['Soort'] for r in rijen if r['Email']})
def is_verzameld(rij, school_code, sleutels):
"""In welke lijst staat dit personeelslid al ('groen' / 'genegeerd'), of None.
Herkend aan school + personeelslid, of aan het e-mailadres."""
paren, mails = sleutels
return paren.get((school_code, str(rij['person_id']))) or mails.get((rij.get('email') or '').strip().lower())
def verzamel_toevoegen(rijen, school, door=None, soort='groen'):
"""Voegt de rijen toe aan de verzameltabel (soort 'groen') of aan de rode lijst ('genegeerd').
Geeft (toegevoegd, overgeslagen)."""
from database import central_query
sleutels = verzameld_sleutels()
periode = schooljaar_periode() # iedereen: 1 september – 31 augustus van het lopende schooljaar
toegevoegd = overgeslagen = 0
for r in rijen:
if is_verzameld(r, school.code, sleutels):
overgeslagen += 1
continue
central_query(
"INSERT INTO GoPro_verzameltabel (School_code, School_naam, Person_ID, Voornaam, Achternaam, Email, UPN, "
"Instellingsnummer, Functie, Rol, Startdatum, Einddatum, Telefoon, Toegevoegd, Door, Soort) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), %s, %s)",
(school.code, school.naam, str(r['person_id']), r['voornaam'], r['achternaam'], r['email'], r['upn'],
r['instellingsnummer'], r['functie'], r['rol'], periode[0], periode[1], r['telefoon'], door, soort),
commit=True)
sleutels[0][(school.code, str(r['person_id']))] = soort
if r.get('email'):
sleutels[1][r['email'].strip().lower()] = soort
toegevoegd += 1
return toegevoegd, overgeslagen
def verzameld_alle(school_code=None, soort='groen'):
from database import central_query
verzamel_ensure()
if school_code:
return central_query("SELECT * FROM GoPro_verzameltabel WHERE School_code = %s AND Soort = %s "
"ORDER BY Achternaam, Voornaam", (school_code, soort)) or []
return central_query("SELECT * FROM GoPro_verzameltabel WHERE Soort = %s "
"ORDER BY School_naam, Achternaam, Voornaam", (soort,)) or []
def markeer_verzonden(door=None, school_code=None):
"""Alle personeelsleden in de verzameltabel (of enkel die van één school) als verzonden markeren.
Ze verdwijnen uit de verzameltabel en worden nooit opnieuw toegevoegd. Geeft het aantal."""
from database import central_query
verzamel_ensure()
n = len(verzameld_alle(school_code))
if school_code:
central_query("UPDATE GoPro_verzameltabel SET Soort = 'verzonden', Verzonden = NOW(), Verzonden_door = %s "
"WHERE Soort = 'groen' AND School_code = %s", (door, school_code), commit=True)
else:
central_query("UPDATE GoPro_verzameltabel SET Soort = 'verzonden', Verzonden = NOW(), Verzonden_door = %s "
"WHERE Soort = 'groen'", (door,), commit=True)
return n
def aantal_verzonden(school_code):
from database import central_query
verzamel_ensure()
r = central_query("SELECT COUNT(*) AS n FROM GoPro_verzameltabel WHERE School_code = %s AND Soort = 'verzonden'",
(school_code,)) or [{}]
return int(r[0].get('n') or 0)
# ---------------------------------------------------------------------
# Nachtelijke verzameling: elke nacht van alle scholen de groene personeelsleden naar de verzameltabel
# ---------------------------------------------------------------------
NACHT_SLEUTEL = 'gopro_nachtelijk' # '1' = aan
NACHT_DAG_SLEUTEL = 'gopro_nachtelijk_dag' # laatste datum waarop het liep (zodat het één keer per nacht loopt)
NACHT_RESULTAAT_SLEUTEL = 'gopro_nachtelijk_resultaat'
NACHT_UUR_VAN, NACHT_UUR_TOT = 2, 6 # tussen 02:00 en 06:00
NACHT_DOOR = 'Automatisch (nacht)'
_nacht = {'bezig': False}
def _instelling_ensure():
from services import ipblokkade
ipblokkade.ensure_table() # maakt PIVOT_instellingen aan als die nog niet bestaat
def nacht_instelling():
"""{'aan': bool, 'door', 'bijgewerkt', 'resultaat', 'resultaat_op'}"""
from database import central_query
_instelling_ensure()
rijen = {r['Sleutel']: r for r in central_query(
"SELECT Sleutel, Waarde, Bijgewerkt, Door FROM PIVOT_instellingen WHERE Sleutel IN (%s, %s)",
(NACHT_SLEUTEL, NACHT_RESULTAAT_SLEUTEL)) or []}
aan, res = rijen.get(NACHT_SLEUTEL) or {}, rijen.get(NACHT_RESULTAAT_SLEUTEL) or {}
return {'aan': aan.get('Waarde') == '1', 'door': aan.get('Door'), 'bijgewerkt': aan.get('Bijgewerkt'),
'resultaat': res.get('Waarde'), 'resultaat_op': res.get('Bijgewerkt'), 'bezig': _nacht['bezig']}
def zet_nacht(aan, door=None):
from database import central_query
_instelling_ensure()
central_query("INSERT INTO PIVOT_instellingen (Sleutel, Waarde, Bijgewerkt, Door) VALUES (%s, %s, NOW(), %s) "
"ON DUPLICATE KEY UPDATE Waarde = VALUES(Waarde), Bijgewerkt = NOW(), Door = VALUES(Door)",
(NACHT_SLEUTEL, '1' if aan else '0', door), commit=True)
def verzamel_groene_van_school(school, door=NACHT_DOOR):
"""Haalt het personeel van `school` op (Informat), kijkt na wie een account heeft in Google en zet de groene
die nog niet in de verzameltabel of op de rode lijst staan in de verzameltabel. Geeft het aantal toegevoegd."""
from tenancy import in_school
with in_school(school):
rijen = GoProService().personeel(school.domein, Config.INSTELLINGSNUMMER)
school.cache.pop('gopro', None) # de GO! Pro-pagina haalt daarna vers op
google = google_adressen(vernieuw=True)
sleutels = verzameld_sleutels()
groen = [r for r in rijen if not is_verzameld(r, school.code, sleutels) and google_kleur(r, google) == 'account']
toegevoegd, _ = verzamel_toevoegen(groen, school, door)
return toegevoegd
def verzamel_alle_scholen(door=NACHT_DOOR):
"""Voor elke actieve school met Informat en een e-maildomein. Geeft een korte samenvatting."""
from database import central_query
from tenancy import alle_scholen
from rechten import log
_nacht['bezig'] = True
try:
totaal, scholen, fouten, details = 0, 0, [], []
for school in alle_scholen():
if not school.heeft('INFORMAT_INST_NUMBER', 'INFORMAT_ID', 'INFORMAT_SECRET') or not school.domein:
continue
try:
n = verzamel_groene_van_school(school, door)
totaal += n
scholen += 1
details.append(f"{school.naam}: {n}")
except Exception as e:
fouten.append(f"{school.naam}: {str(e)[:120]}")
import sys
sys.stderr.write(f"[{school.code}] GO! Pro nachtelijk verzamelen: {e}\n")
samenvatting = f"{totaal} toegevoegd uit {scholen} school/scholen"
if fouten:
samenvatting += f"; {len(fouten)} mislukt ({' | '.join(fouten)})"
try:
log(f"GO! Pro automatisch verzamelen: {samenvatting}. " + '; '.join(details), url='(nachtelijke taak)')
except Exception:
pass
_instelling_ensure()
central_query("INSERT INTO PIVOT_instellingen (Sleutel, Waarde, Bijgewerkt, Door) VALUES (%s, %s, NOW(), %s) "
"ON DUPLICATE KEY UPDATE Waarde = VALUES(Waarde), Bijgewerkt = NOW(), Door = VALUES(Door)",
(NACHT_RESULTAAT_SLEUTEL, samenvatting[:255], door), commit=True)
return samenvatting
finally:
_nacht['bezig'] = False
def _nacht_claim(vandaag):
"""True als dit proces de run van vandaag mag doen (ook als PIVOT in meerdere processen draait)."""
from database import central_query
central_query("INSERT IGNORE INTO PIVOT_instellingen (Sleutel, Waarde, Bijgewerkt) VALUES (%s, '', NOW())",
(NACHT_DAG_SLEUTEL,), commit=True)
return central_query("UPDATE PIVOT_instellingen SET Waarde = %s, Bijgewerkt = NOW() "
"WHERE Sleutel = %s AND (Waarde IS NULL OR Waarde <> %s)",
(vandaag, NACHT_DAG_SLEUTEL, vandaag), commit=True) == 1
_nacht_thread = None
def start_scheduler():
"""Achtergrondtaak: elk kwartier kijken of het nacht is en de schakelaar aan staat."""
import threading, time, sys
global _nacht_thread
if _nacht_thread:
return
def loop():
time.sleep(60)
while True:
try:
nu = datetime.now()
if NACHT_UUR_VAN <= nu.hour < NACHT_UUR_TOT and nacht_instelling()['aan'] \
and _nacht_claim(nu.strftime('%Y-%m-%d')):
verzamel_alle_scholen()
except Exception as e:
sys.stderr.write(f"GO! Pro nachtelijke taak: {e}\n")
time.sleep(15 * 60)
_nacht_thread = threading.Thread(target=loop, daemon=True)
_nacht_thread.start()
def verzamel_alle_scholen_op_achtergrond(door):
import threading
if _nacht['bezig']:
return False
_nacht['bezig'] = True
threading.Thread(target=lambda: verzamel_alle_scholen(door), daemon=True).start()
return True
+347
View File
@@ -0,0 +1,347 @@
"""
Handleidingen die PIVOT meelevert. Bij het opstarten komt elke handleiding waarvan de slug nog niet bestaat in de
tabel Handleidingen. Daarna pas je ze aan in PIVOT zelf (📘 Handleidingen › Bewerken); wijzigingen hier overschrijven niets.
Een verwijderde handleiding blijft verborgen (Zichtbaar = 'weg') en komt dus niet terug.
"""
TWEESTAPS_UITZONDERING = """
<p>Met deze handleiding stel je in Google Admin alles in zodat PIVOT iemand <strong>tijdelijk zonder 2FA</strong> (tot 23:59 dezelfde dag) kan laten inloggen
(uitzondering) en 2FA kan <strong>resetten</strong> bij een nieuwe telefoon. Je doet dit één keer per domein; daarna enkel opnieuw
als er een organisatie-eenheid (OU) bijkomt.</p>
<div class="info"><strong>Hoe werkt het?</strong> PIVOT zet een gebruiker in de groep <em>2fa-uitzondering</em>. Voor die groep staat
"Afdwingen" op <strong>Uit</strong>. Zolang de gebruiker in de groep zit, mag hij zonder 2FA inloggen. Om 23:59 dezelfde dag haalt PIVOT
hem er automatisch weer uit.</div>
<h2 id="stap-1">Stap 1 – De groep aanmaken</h2>
<ol>
<li>Ga naar <a href="https://admin.google.com" target="_blank">admin.google.com</a> › <strong>Directory › Groepen</strong> › <strong>Groep maken</strong>.</li>
<li>Naam: <code>2fa-uitzondering</code>, e-mailadres: <code>2fa-uitzondering@jouwdomein.be</code>.</li>
<li>Bij de instellingen van de groep:
<ul>
<li>Wie kan lid worden: <strong>Alleen uitgenodigde gebruikers</strong>.</li>
<li>Wie kan berichten plaatsen: <strong>Groepseigenaren</strong> (de groep dient niet om te mailen).</li>
<li>Wie kan leden bekijken: <strong>Groepsbeheerders</strong>.</li>
</ul>
</li>
<li>Voeg zelf <strong>geen</strong> vaste leden toe. PIVOT voegt mensen toe en haalt ze er weer uit.</li>
</ol>
<h2 id="stap-2">Stap 2 – 2FA uitzetten voor de groep, in élke OU waar 2FA verplicht is</h2>
<div class="let-op"><strong>Let op:</strong> een 2FA-instelling op een <strong>onderliggende OU gaat altijd vóór</strong> een groepsinstelling
hoger in de boom. Stel de groep daarom in bij <strong>elke OU waar 2FA afgedwongen wordt</strong>, ook bij sub-OU's.</div>
<ol>
<li>Ga naar <strong>Beveiliging › Authenticatie › Verificatie in 2 stappen</strong> (2-Step Verification).</li>
<li>Kies links onder <strong>Organisatie-eenheden</strong> de OU, bv. <em>GO-Antwerpen</em>. Klap met het pijltje open om sub-OU's te zien.</li>
<li>Klik links onderaan bij <strong>Groepen</strong> op de groep <em>2fa-uitzondering</em> (of voeg ze toe via "Groepen zoeken").
Bovenaan moet nu staan: <em>"Instellingen tonen voor gebruikers in <strong>[jouw OU]</strong> (organisatie-eenheid) die zich ook in
<strong>2fa-uitzondering</strong> (groep) bevinden"</em>.</li>
<li>Laat <strong>Gebruikers toestaan tweestapsverificatie aan te zetten</strong> aangevinkt.</li>
<li>Zet <strong>Afdwingen</strong> op <strong>Uit</strong>.</li>
<li>Klik op <strong>Overschrijven</strong> (of Opslaan). Onder "Verificatie" moet nu <strong>Overschreven</strong> staan, niet "Overgenomen".</li>
<li>Herhaal stap 2 tot 6 voor elke OU en sub-OU waar 2FA op <strong>Aan</strong> staat.</li>
</ol>
<div class="tip"><strong>Tip:</strong> per OU kan je maar één groep instellen. Gebruik daarom overal dezelfde groep <em>2fa-uitzondering</em>.</div>
<h2 id="stap-3">Stap 3 – Rechten voor PIVOT (domeinbrede delegatie)</h2>
<ol>
<li>Ga naar <strong>Beveiliging › Toegangs- en gegevensbeheer › API-beheer › Domeinbrede delegatie beheren</strong>.</li>
<li>Klik bij het service-account van PIVOT op <strong>Bewerken</strong>.</li>
<li>Controleer dat minstens deze scopes erbij staan (in het veld gescheiden door komma's):
<ul>
<li><code>https://www.googleapis.com/auth/admin.directory.user</code></li>
<li><code>https://www.googleapis.com/auth/admin.directory.user.security</code> (2FA resetten, back-upcodes)</li>
<li><code>https://www.googleapis.com/auth/admin.directory.group.member</code> (in/uit de groep zetten)</li>
<li><code>https://www.googleapis.com/auth/admin.directory.orgunit</code></li>
</ul>
</li>
<li>Klik op <strong>Autoriseren</strong>. Het kan enkele minuten duren voor Google dit toepast.</li>
</ol>
<h2 id="stap-4">Stap 4 – De groep invullen in PIVOT</h2>
<ol>
<li>Open in PIVOT <strong>🔌 Koppelingen van deze school › 🛡️ 2FA-hulp</strong>.</li>
<li>Vul bij <strong>Uitzonderingsgroep</strong> het e-mailadres van de groep in: <code>2fa-uitzondering@jouwdomein.be</code>.</li>
<li>Opslaan en <strong>Testen</strong>.</li>
</ol>
<h2 id="stap-5">Stap 5 – Testen met een testaccount</h2>
<ol>
<li>Geef in PIVOT (🛡️ 2FA-hulp) een testgebruiker een tijdelijke uitzondering (loopt tot 23:59).</li>
<li>Kijk in Google Admin bij <strong>Gebruikers › [testgebruiker] › Groepen</strong>: <em>2fa-uitzondering</em> moet in de lijst staan.</li>
<li>Kijk bij <strong>Gebruikers › [testgebruiker] › Beveiliging › Tweestapsverificatie</strong>.
Zolang daar <strong>"Afgedwongen in uw hele organisatie"</strong> staat, heeft Google de uitzondering nog niet toegepast.</li>
</ol>
<div class="let-op"><strong>Geduld:</strong> Google past een nieuwe groepsinstelling meestal binnen enkele minuten toe, maar het kan
<strong>tot 24 uur</strong> duren, zeker bij een nieuwe groep of een nieuw account. PIVOT blijft een 2FA-reset elke minuut opnieuw proberen
zolang de uitzondering loopt.</div>
<h2 id="nieuwe-telefoon">Iemand heeft een nieuwe telefoon</h2>
<p>Gebruik eerst een <strong>back-upcode</strong>: die werkt meteen, ook als 2FA afgedwongen wordt.</p>
<ol>
<li>PIVOT: 🛡️ 2FA-hulp › zoek de gebruiker › <strong>Back-upcodes tonen / aanmaken</strong>
(of in Google Admin: Gebruikers › gebruiker › Beveiliging › Tweestapsverificatie › <em>Back-upverificatiecodes ophalen</em>).</li>
<li>Geef de gebruiker één code. Hij logt in met zijn wachtwoord en die code.</li>
<li>Daarna koppelt hij zijn nieuwe telefoon via <a href="https://myaccount.google.com/security" target="_blank">myaccount.google.com › Beveiliging</a>
› Verificatie in 2 stappen.</li>
</ol>
<p>Lukt dat niet, gebruik dan in PIVOT <strong>📵 2FA resetten</strong>. PIVOT zet de gebruiker dan in de uitzonderingsgroep en zet 2FA uit
zodra Google de uitzondering toepast.</p>
<h2 id="problemen">Problemen oplossen</h2>
<table>
<thead><tr><th>Wat zie je?</th><th>Oorzaak</th><th>Oplossing</th></tr></thead>
<tbody>
<tr><td>Gebruiker zit in de groep, maar 2FA blijft "Afgedwongen"</td>
<td>De gebruiker staat in een (sub-)OU waar de groep niet ingesteld is, of Google past de instelling nog toe.</td>
<td>Controleer de OU van de gebruiker (Gebruikers › gebruiker, links onder "Organisatie-eenheid") en voer stap 2 uit voor
precies die OU. Wacht anders tot 24 uur.</td></tr>
<tr><td>"2-Step Verification cannot be turned off: … enforced"</td>
<td>Google dwingt 2FA nog af voor deze gebruiker.</td>
<td>Zie hierboven. PIVOT probeert automatisch opnieuw.</td></tr>
<tr><td>"Google weigert de toegang" / unauthorized_client</td>
<td>Een scope ontbreekt.</td><td>Stap 3.</td></tr>
<tr><td>"Gebruiker of groep niet gevonden"</td>
<td>Het e-mailadres van de groep in PIVOT klopt niet.</td><td>Stap 4.</td></tr>
</tbody>
</table>
"""
MAIL_DELEGATIE = """
<p>Met deze handleiding stel je Google Admin zo in dat PIVOT (<strong>✉️ Mailbox-delegatie</strong>) iemand toegang kan geven tot de
mailbox van iemand anders, bv. het secretariaat tot de mailbox van de directie. De gedelegeerde kan dan lezen, versturen en verwijderen
namens de eigenaar, maar kent het wachtwoord niet en kan de instellingen niet wijzigen.</p>
<h2 id="stap-1">Stap 1 – Maildelegatie toelaten</h2>
<ol>
<li>Ga naar <a href="https://admin.google.com" target="_blank">admin.google.com</a> › <strong>Apps › Google Workspace › Gmail › Gebruikersinstellingen</strong>.</li>
<li>Kies links de <strong>organisatie-eenheid</strong> van de mailboxen die gedeeld moeten worden (bv. <em>GO-Antwerpen</em>).
Kies de hoogste OU als het voor iedereen mag; sub-OU's nemen de instelling over, tenzij ze ze zelf overschrijven.</li>
<li>Open het onderdeel <strong>Maildelegatie</strong> (Mail delegation).</li>
<li>Vink aan: <strong>Gebruikers toestaan toegang tot hun mailbox te delegeren aan andere gebruikers in het domein</strong>.</li>
<li>Kies hoe verzonden mails getoond worden:
<ul>
<li><strong>De accounteigenaar en de gedelegeerde die de e-mail heeft verzonden tonen</strong> (aangeraden: de ontvanger ziet wie echt schreef), of</li>
<li><strong>Alleen de accounteigenaar tonen</strong>.</li>
</ul>
</li>
<li>Klik op <strong>Opslaan</strong>.</li>
</ol>
<div class="let-op"><strong>Let op:</strong> staat maildelegatie in een sub-OU op "Overschreven" en uit, dan werkt delegatie daar niet,
ook al staat ze hoger in de boom aan. Controleer de OU van de mailbox-eigenaar.</div>
<h2 id="stap-2">Stap 2 – Gmail API aanzetten (eenmalig)</h2>
<ol>
<li>Ga naar <a href="https://console.cloud.google.com/apis/library/gmail.googleapis.com" target="_blank">console.cloud.google.com › API's › Gmail API</a>.</li>
<li>Kies bovenaan het project van het PIVOT-service-account.</li>
<li>Klik op <strong>Inschakelen</strong> (staat er "Beheren", dan is het al aan).</li>
</ol>
<h2 id="stap-3">Stap 3 – Rechten voor PIVOT (domeinbrede delegatie)</h2>
<ol>
<li>Ga in Google Admin naar <strong>Beveiliging › Toegangs- en gegevensbeheer › API-beheer › Domeinbrede delegatie beheren</strong>.</li>
<li>Klik bij het service-account van PIVOT op <strong>Bewerken</strong>.</li>
<li>Voeg deze twee scopes toe aan de bestaande lijst (gescheiden door komma's; verwijder de andere niet):
<ul>
<li><code>https://www.googleapis.com/auth/gmail.settings.sharing</code> (delegatie toevoegen en verwijderen)</li>
<li><code>https://www.googleapis.com/auth/gmail.settings.basic</code> (bestaande delegaties tonen)</li>
</ul>
</li>
<li>Klik op <strong>Autoriseren</strong>.</li>
</ol>
<div class="tip"><strong>Tip:</strong> ontbreekt <em>gmail.settings.basic</em>, dan kan PIVOT wel delegaties toevoegen maar de lijst niet tonen
("insufficient authentication scopes").</div>
<h2 id="stap-4">Stap 4 – Rechten in PIVOT</h2>
<ol>
<li>Geef wie delegaties mag beheren het recht <strong>Mailbox-delegatie</strong> in de juiste school
(⚙️ Beheer › 🔐 Gebruikers &amp; rechten › 🔐 Rechten).</li>
<li>Test in <strong>✉️ Mailbox-delegatie</strong> met twee testaccounts: geef account B toegang tot de mailbox van account A.</li>
</ol>
<h2 id="werking">Goed om te weten</h2>
<ul>
<li>Een delegatie via PIVOT werkt meestal na <strong>ongeveer een minuut</strong>. De gedelegeerde hoeft niets te bevestigen.</li>
<li>Een nieuwe instelling in Google Admin (stap 1) kan <strong>tot 24 uur</strong> duren voor ze overal geldt.</li>
<li>De gedelegeerde opent de mailbox in Gmail via zijn <strong>profielfoto rechtsboven</strong> › de gedeelde mailbox kiezen.</li>
<li>Eigenaar en gedelegeerde moeten in <strong>dezelfde Google Workspace-organisatie</strong> zitten. Gebruik het
<strong>primaire e-mailadres</strong>, geen alias.</li>
<li>Een mailbox kan maximaal <strong>25 gedelegeerden</strong> hebben; iemand kan maximaal <strong>10 mailboxen</strong> openen
(Google kan deze limieten per organisatie anders instellen).</li>
</ul>
<h2 id="problemen">Problemen oplossen</h2>
<table>
<thead><tr><th>Wat zie je?</th><th>Oorzaak</th><th>Oplossing</th></tr></thead>
<tbody>
<tr><td>"insufficient authentication scopes" bij het tonen</td><td><em>gmail.settings.basic</em> ontbreekt.</td><td>Stap 3.</td></tr>
<tr><td>"unauthorized_client" / Google weigert de toegang</td><td><em>gmail.settings.sharing</em> ontbreekt of is nog niet toegepast.</td>
<td>Stap 3, en enkele minuten wachten.</td></tr>
<tr><td>Gmail API has not been used / is disabled</td><td>Gmail API staat uit in het Cloud-project.</td><td>Stap 2.</td></tr>
<tr><td>Toevoegen mislukt voor één persoon, voor anderen lukt het</td>
<td>Maildelegatie staat uit in de OU van die mailbox-eigenaar, of er zijn al 25 gedelegeerden.</td><td>Stap 1 voor die OU.</td></tr>
<tr><td>Gedelegeerde ziet de mailbox niet</td><td>Nog niet toegepast, of hij kijkt niet via zijn profielfoto.</td>
<td>Even wachten, opnieuw inloggen in Gmail.</td></tr>
</tbody>
</table>
"""
GOOGLE_KOPPELING = """
<p>PIVOT praat met Google via een <strong>serviceaccount</strong>: een technisch account in Google Cloud dat namens een beheerder
(bv. <code>ict@school.be</code>) handelt. Daarvoor zijn er drie plaatsen: <strong>Google Cloud Console</strong> (project, API's,
serviceaccount, sleutel), <strong>Google Admin</strong> (domeinbrede delegatie) en <strong>PIVOT</strong> (sleutel uploaden).
Optioneel stel je ook <strong>Inloggen met Google</strong> in.</p>
<div class="info"><strong>Wie?</strong> Je hebt een account nodig dat in Google Cloud projecten mag aanmaken en in Google Admin
<strong>superbeheerder</strong> is. Je doet dit één keer; meerdere scholen in hetzelfde Google-domein mogen dezelfde sleutel gebruiken.</div>
<h2 id="stap-1">Stap 1 – Project aanmaken</h2>
<ol>
<li>Ga naar <a href="https://console.cloud.google.com" target="_blank">console.cloud.google.com</a> en log in met je beheerdersaccount.</li>
<li>Klik bovenaan op de projectkiezer › <strong>Nieuw project</strong>.</li>
<li>Naam: bv. <code>PIVOT</code>. Organisatie: jullie domein. Klik op <strong>Maken</strong>.</li>
<li>Controleer bovenaan dat het nieuwe project gekozen is voor je verder gaat.</li>
</ol>
<h2 id="stap-2">Stap 2 – API's inschakelen</h2>
<p>Ga naar <strong>API's en services › Bibliotheek</strong>, zoek elke API en klik op <strong>Inschakelen</strong>:</p>
<table>
<thead><tr><th>API</th><th>Nodig voor</th></tr></thead>
<tbody>
<tr><td><strong>Admin SDK API</strong></td><td>Gebruikers, wachtwoorden, OU's, Chromebooks, groepen, 2FA</td></tr>
<tr><td><strong>Gmail API</strong></td><td>Mailbox-delegatie</td></tr>
<tr><td><strong>Enterprise License Manager API</strong></td><td>Google-licenties</td></tr>
<tr><td><strong>Groups Settings API</strong></td><td>Verzendlijsten: wie mag mailen naar de lijst</td></tr>
</tbody>
</table>
<h2 id="stap-3">Stap 3 – Serviceaccount aanmaken</h2>
<ol>
<li>Ga naar <strong>IAM en beheer › Serviceaccounts</strong> › <strong>Serviceaccount maken</strong>.</li>
<li>Naam: bv. <code>pivot</code>. Klik op <strong>Maken en doorgaan</strong>.</li>
<li>De stappen "Rollen" en "Gebruikerstoegang" laat je <strong>leeg</strong> (PIVOT werkt via domeinbrede delegatie, niet via
Cloud-rollen). Klik op <strong>Klaar</strong>.</li>
<li>Open het serviceaccount en noteer de <strong>Unieke ID</strong> (een lang getal, ook "OAuth 2-client-ID" genoemd).
Die heb je nodig in stap 5.</li>
</ol>
<h2 id="stap-4">Stap 4 – JSON-sleutel maken</h2>
<ol>
<li>Open het serviceaccount › tabblad <strong>Sleutels</strong> › <strong>Sleutel toevoegen › Nieuwe sleutel maken</strong>.</li>
<li>Kies <strong>JSON</strong> › <strong>Maken</strong>. Het bestand wordt gedownload.</li>
<li>Bewaar het veilig: wie dit bestand heeft, kan alles wat PIVOT kan. Verwijder het van je pc na stap 6.</li>
</ol>
<div class="let-op"><strong>"Het maken van serviceaccountsleutels is uitgeschakeld"?</strong> Nieuwe Google Cloud-organisaties blokkeren
sleutels standaard met de organisatiebeleidsregel <code>iam.disableServiceAccountKeyCreation</code>
(of <code>iam.managed.disableServiceAccountKeyCreation</code>). Zet ze uit voor <strong>enkel dit project</strong>:
<ol>
<li>Geef jezelf op organisatieniveau de rol <strong>Beheerder van organisatiebeleid</strong> (Organization Policy Administrator)
via IAM en beheer › IAM (kies bovenaan de organisatie, niet het project).</li>
<li>Kies bovenaan opnieuw het project PIVOT › <strong>IAM en beheer › Organisatiebeleid</strong>.</li>
<li>Zoek <code>disableServiceAccountKeyCreation</code> › <strong>Beleid beheren</strong> › <strong>Beleid van bovenliggend item
overschrijven</strong> › regel toevoegen › <strong>Afdwingen: Uit</strong> › <strong>Beleid instellen</strong>.</li>
<li>Wacht enkele minuten (soms tot een uur) en probeer stap 4 opnieuw.</li>
</ol>
</div>
<h2 id="stap-5">Stap 5 – Domeinbrede delegatie in Google Admin</h2>
<ol>
<li>Ga naar <a href="https://admin.google.com" target="_blank">admin.google.com</a> › <strong>Beveiliging › Toegangs- en
gegevensbeheer › API-beheer</strong> › <strong>Domeinbrede delegatie beheren</strong>.</li>
<li>Klik op <strong>Nieuwe toevoegen</strong>.</li>
<li>Client-ID: de <strong>Unieke ID</strong> uit stap 3 (PIVOT toont ze ook na het uploaden van de sleutel).</li>
<li>OAuth-bereiken: plak deze regel in zijn geheel:
<pre><code>https://www.googleapis.com/auth/admin.directory.user,https://www.googleapis.com/auth/admin.directory.user.security,https://www.googleapis.com/auth/admin.directory.group.member,https://www.googleapis.com/auth/admin.directory.orgunit,https://www.googleapis.com/auth/admin.directory.device.chromeos,https://www.googleapis.com/auth/admin.directory.device.chromeos.readonly,https://www.googleapis.com/auth/admin.directory.device.mobile,https://www.googleapis.com/auth/admin.directory.device.mobile.readonly,https://www.googleapis.com/auth/apps.licensing,https://www.googleapis.com/auth/gmail.settings.sharing,https://www.googleapis.com/auth/gmail.settings.basic,https://www.googleapis.com/auth/admin.directory.group,https://www.googleapis.com/auth/apps.groups.settings,https://www.googleapis.com/auth/gmail.modify,https://www.googleapis.com/auth/contacts,https://www.googleapis.com/auth/drive</code></pre>
</li>
<li>Klik op <strong>Autoriseren</strong>. Het kan enkele minuten duren voor Google dit toepast.</li>
</ol>
<table>
<thead><tr><th>Scope (zonder https://www.googleapis.com/auth/)</th><th>Waarvoor in PIVOT</th></tr></thead>
<tbody>
<tr><td><code>admin.directory.user</code></td><td>Accounts aanmaken, wachtwoord resetten, leerlingen</td></tr>
<tr><td><code>admin.directory.user.security</code></td><td>2FA: back-upcodes, 2FA resetten</td></tr>
<tr><td><code>admin.directory.group.member</code></td><td>2FA-uitzonderingsgroep</td></tr>
<tr><td><code>admin.directory.orgunit</code></td><td>Organisatie-eenheden</td></tr>
<tr><td><code>admin.directory.device.chromeos</code> (+ <code>.readonly</code>)</td><td>Chromebooks blokkeren/deblokkeren, opzoeken</td></tr>
<tr><td><code>admin.directory.device.mobile</code> (+ <code>.readonly</code>)</td><td>Mobiele toestellen</td></tr>
<tr><td><code>apps.licensing</code></td><td>Google-licenties</td></tr>
<tr><td><code>gmail.settings.sharing</code></td><td>Mailbox-delegatie toevoegen/verwijderen</td></tr>
<tr><td><code>gmail.settings.basic</code></td><td>Mailbox-delegaties tonen, Gmail-handtekeningen instellen</td></tr>
<tr><td><code>gmail.modify</code></td><td>E-mails met een label kopiëren/verplaatsen naar een andere mailbox</td></tr>
<tr><td><code>drive</code></td><td>Gedeelde drives: overzicht, leden en rechten (Google Drive API moet aanstaan)</td></tr>
<tr><td><code>contacts</code></td><td>Contactlijsten in de mailboxen van gebruikers (People API moet aanstaan)</td></tr>
</tbody>
</table>
<div class="tip"><strong>Tip:</strong> Bestaat er al een regel voor deze client-ID, klik dan op <strong>Bewerken</strong> en
vervang de lijst; anders heb je er twee.</div>
<h2 id="stap-6">Stap 6 – Sleutel en beheerder instellen in PIVOT</h2>
<ol>
<li>Open in PIVOT <strong>🏫 Alle scholen beheren</strong> › de school › <strong>Bewerken</strong>
(of <strong>🔌 Koppelingen van deze school</strong>).</li>
<li>Bij <strong>Algemeen › Beheerder (Google-admin)</strong>: het account waarnamens PIVOT werkt, bv. <code>ict@school.be</code>.
Dit moet een <strong>superbeheerder</strong> zijn (of een beheerder met de nodige rechten) in Google Admin.</li>
<li>Bij <strong>🔑 Google Workspace › Serviceaccount (JSON-sleutel)</strong>: upload het JSON-bestand uit stap 4.
PIVOT bewaart het versleuteld en toont daarna het e-mailadres en de client-ID van het serviceaccount.</li>
<li>Opslaan, en daarna op <strong>🩺 Testen</strong> klikken. Alles in orde? Verwijder het JSON-bestand van je pc.</li>
</ol>
<h2 id="stap-7">Stap 7 (optioneel) – Inloggen met Google</h2>
<p>Hiermee loggen leerkrachten en PIVOT-gebruikers in met hun Google-account in plaats van een wachtwoord.</p>
<ol>
<li>In de Cloud Console (zelfde project): <strong>API's en services › OAuth-toestemmingsscherm</strong>
(in de nieuwe versie: <strong>Google Auth Platform › Branding</strong>). Kies als doelgroep <strong>Intern</strong>,
app-naam <code>PIVOT</code>, ondersteunings-e-mail en je ontwikkelaarsadres. Opslaan.</li>
<li><strong>Inloggegevens</strong> (of Google Auth Platform › <strong>Clients</strong>) › <strong>Inloggegevens maken › OAuth-client-ID</strong>.</li>
<li>Type: <strong>Webapplicatie</strong>, naam <code>PIVOT login</code>.</li>
<li>Bij <strong>Geautoriseerde omleidings-URI's</strong>: het adres van PIVOT + <code>/callback</code>,
bv. <code>https://pivot.school.be/callback</code>. Gebruik je meerdere adressen (per school een eigen hostnaam), voeg ze allemaal toe.</li>
<li>Klik op <strong>Maken</strong> en kopieer de <strong>client-ID</strong> en het <strong>clientgeheim</strong>.</li>
<li>In PIVOT: vul ze in bij de school onder <strong>🔑 Google Workspace › Google-login client-ID / client-secret</strong>,
of één keer voor alle scholen in <code>config.ini</code> onder <code>[google_sso]</code>.</li>
</ol>
<h2 id="problemen">Problemen oplossen</h2>
<table>
<thead><tr><th>Wat zie je?</th><th>Oorzaak</th><th>Oplossing</th></tr></thead>
<tbody>
<tr><td><code>unauthorized_client</code> / "Client is unauthorized to retrieve access tokens"</td>
<td>Client-ID of scope ontbreekt in de domeinbrede delegatie, of nog niet toegepast.</td>
<td>Stap 5 nakijken (juiste Unieke ID, alle scopes), enkele minuten wachten.</td></tr>
<tr><td>"Invalid Input" / "Not Authorized to access this resource"</td>
<td>Het beheerdersaccount klopt niet of is geen beheerder.</td><td>Stap 6, Beheerder (Google-admin).</td></tr>
<tr><td>"&lt;API&gt; has not been used in project … or it is disabled"</td>
<td>De API staat niet aan.</td><td>Stap 2, in het juiste project.</td></tr>
<tr><td>"insufficient authentication scopes"</td><td>Eén scope ontbreekt (vaak een Gmail-scope).</td><td>Stap 5.</td></tr>
<tr><td>"redirect_uri_mismatch" bij inloggen met Google</td>
<td>Het adres van PIVOT staat niet bij de omleidings-URI's.</td>
<td>Stap 7.4: exact hetzelfde adres (https, hostnaam, <code>/callback</code>).</td></tr>
<tr><td>Sleutel maken lukt niet</td><td>Organisatiebeleid blokkeert sleutels.</td><td>Kader bij stap 4.</td></tr>
</tbody>
</table>
"""
STANDAARD = [
{
'slug': 'google-koppeling-cloud-console',
'titel': 'Google-koppeling instellen (Google Cloud Console)',
'categorie': 'Google Workspace',
'samenvatting': 'Project, API\'s, serviceaccount en JSON-sleutel, domeinbrede delegatie met alle scopes, '
'sleutel in PIVOT en inloggen met Google.',
'inhoud': GOOGLE_KOPPELING,
},
{
'slug': '2fa-uitzondering-google-admin',
'titel': '2FA-uitzondering instellen in Google Admin',
'categorie': 'Google Workspace',
'samenvatting': 'Groep 2fa-uitzondering aanmaken, 2FA uitzetten per OU, rechten voor PIVOT en testen.',
'inhoud': TWEESTAPS_UITZONDERING,
},
{
'slug': 'mailbox-delegatie-google-admin',
'titel': 'Mailbox-delegatie instellen in Google Admin',
'categorie': 'Google Workspace',
'samenvatting': 'Maildelegatie toelaten per OU, Gmail API, scopes voor PIVOT en testen.',
'inhoud': MAIL_DELEGATIE,
},
]
+443
View File
@@ -0,0 +1,443 @@
"""
Gmail › Handtekeningen: één HTML-sjabloon per school, ingevuld per gebruiker en ingesteld in Gmail.
- Gebruikers: alle accounts in de gekozen OU's (Directory API, optioneel met de onderliggende OU's).
- Functie en telefoon komen uit Google (gebruikersprofiel: organizations[].title en phones[]) en worden daar ook
bewaard als je ze in PIVOT aanpast. Zo staan ze ook in het adresboek van de organisatie.
- Toepassen: Gmail API users.settings.sendAs.patch(signature) namens de gebruiker, voor het hoofdadres en de aliassen.
- Logo: geüpload in PIVOT (publiek op /handtekening-logo/<school>/<sleutel>) of een eigen publieke link.
Gmail toont geen ingesloten (base64) afbeeldingen in handtekeningen, enkel afbeeldingen via een link.
- Nachtelijk opnieuw toepassen (schakelaar): gebruikers kunnen hun handtekening in Gmail zelf wijzigen,
zo staat de juiste er de volgende ochtend terug.
Vereist (domeinbrede delegatie): https://www.googleapis.com/auth/gmail.settings.basic
en voor functie/telefoon https://www.googleapis.com/auth/admin.directory.user (staat al in de standaardlijst).
"""
import hashlib
import html
import json
import re
import secrets
import sys
import threading
import time
from datetime import datetime
from config import Config
from database import execute_query
from tenancy import school_singleton, met_school, google_credentials, huidige_school
SCOPE = 'https://www.googleapis.com/auth/gmail.settings.basic'
MAX_TEKENS = 10000 # limiet van Gmail voor een handtekening
MAX_LOGO = 512 * 1024
LOGO_TYPES = {'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif'}
NACHT_UUR_VAN, NACHT_UUR_TOT = 3, 6
VELDEN = [ # (sleutel, uitleg)
('voornaam', 'Voornaam'), ('achternaam', 'Achternaam'), ('naam', 'Voornaam en achternaam'),
('functie', 'Functie (uit Google)'), ('telefoon', 'Telefoon (uit Google)'), ('email', 'E-mailadres'),
('schoolnaam', 'Naam van de school'), ('schooladres', 'Adres van de school'), ('logo', 'Logo (afbeelding)'),
]
STANDAARD_SJABLOON = """<table cellpadding="0" cellspacing="0" style="font-family:Arial,Helvetica,sans-serif;font-size:13px;color:#1f2937">
<tr>
<td style="padding-right:14px;vertical-align:top">{logo}</td>
<td style="border-left:2px solid #1d6fa5;padding-left:14px;vertical-align:top">
<div style="font-size:15px;font-weight:bold;color:#1d6fa5">{voornaam} {achternaam}</div>
{#functie}<div style="color:#4b5563">{functie}</div>{/functie}
<div style="margin-top:6px;font-weight:bold">{schoolnaam}</div>
<div>{schooladres}</div>
{#telefoon}<div>Tel. {telefoon}</div>{/telefoon}
<div><a href="mailto:{email}" style="color:#1d6fa5;text-decoration:none">{email}</a></div>
</td>
</tr>
</table>"""
class HandtekeningFout(Exception):
pass
# ---------------------------------------------------------------------
# Opslag (database van de school)
# ---------------------------------------------------------------------
def ensure_tables():
execute_query("""
CREATE TABLE IF NOT EXISTS gmail_handtekening (
ID TINYINT NOT NULL PRIMARY KEY,
Sjabloon MEDIUMTEXT NULL,
OUs TEXT NULL,
Met_sub_ous TINYINT NOT NULL DEFAULT 1,
Logo MEDIUMBLOB NULL,
Logo_type VARCHAR(40) NULL,
Logo_sleutel VARCHAR(40) NULL,
Logo_url VARCHAR(500) NULL,
Logo_breedte INT NOT NULL DEFAULT 120,
Publiek_adres VARCHAR(255) NULL,
Nachtelijk TINYINT NOT NULL DEFAULT 0,
Laatste_nacht DATE NULL,
Nacht_resultaat VARCHAR(500) NULL,
Bijgewerkt DATETIME NULL,
Door VARCHAR(255) NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True)
execute_query("""
CREATE TABLE IF NOT EXISTS gmail_handtekening_log (
Email VARCHAR(255) NOT NULL PRIMARY KEY,
Toegepast DATETIME NOT NULL,
Ok TINYINT NOT NULL,
Melding VARCHAR(500) NULL,
Hash CHAR(40) NULL,
Door VARCHAR(255) NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True)
execute_query("INSERT IGNORE INTO gmail_handtekening (ID, Sjabloon) VALUES (1, %s)", (STANDAARD_SJABLOON,), commit=True)
def instellingen():
ensure_tables()
r = (execute_query("SELECT ID, Sjabloon, OUs, Met_sub_ous, Logo_type, Logo_sleutel, Logo_url, Logo_breedte, "
"Publiek_adres, Nachtelijk, Laatste_nacht, Nacht_resultaat, Bijgewerkt, Door, "
"Logo IS NOT NULL AS heeft_logo FROM gmail_handtekening WHERE ID = 1") or [{}])[0]
try:
r['ous'] = [o for o in json.loads(r.get('OUs') or '[]') if isinstance(o, str)]
except ValueError:
r['ous'] = []
r['logo_link'] = logo_link(r)
return r
def bewaar(sjabloon, ous, met_sub, logo_url, logo_breedte, publiek_adres, door):
sjabloon = (sjabloon or '').strip()
if len(sjabloon) > 50000:
raise HandtekeningFout("Het sjabloon is te lang.")
execute_query("UPDATE gmail_handtekening SET Sjabloon = %s, OUs = %s, Met_sub_ous = %s, Logo_url = %s, "
"Logo_breedte = %s, Publiek_adres = %s, Bijgewerkt = NOW(), Door = %s WHERE ID = 1",
(sjabloon, json.dumps(sorted(set(ous))), 1 if met_sub else 0, (logo_url or '').strip() or None,
max(20, min(600, int(logo_breedte or 120))), (publiek_adres or '').strip().rstrip('/') or None, door),
commit=True)
def bewaar_logo(data, mimetype):
if mimetype not in LOGO_TYPES:
raise HandtekeningFout("Enkel PNG, JPG of GIF als logo.")
if len(data) > MAX_LOGO:
raise HandtekeningFout(f"Het logo is te groot (max. {MAX_LOGO // 1024} kB).")
execute_query("UPDATE gmail_handtekening SET Logo = %s, Logo_type = %s, Logo_sleutel = %s WHERE ID = 1",
(data, mimetype, secrets.token_hex(12)), commit=True)
def verwijder_logo():
execute_query("UPDATE gmail_handtekening SET Logo = NULL, Logo_type = NULL, Logo_sleutel = NULL WHERE ID = 1",
commit=True)
def logo_bestand(sleutel):
ensure_tables()
r = execute_query("SELECT Logo, Logo_type FROM gmail_handtekening WHERE ID = 1 AND Logo_sleutel = %s", (sleutel,))
return (r[0]['Logo'], r[0]['Logo_type']) if r and r[0]['Logo'] else (None, None)
def logo_link(inst):
"""Publieke link naar het logo: eigen link, of het geüploade logo via PIVOT."""
if inst.get('Logo_url'):
return inst['Logo_url']
if inst.get('heeft_logo') and inst.get('Logo_sleutel') and inst.get('Publiek_adres'):
ext = LOGO_TYPES.get(inst.get('Logo_type'), 'png')
return f"{inst['Publiek_adres']}/handtekening-logo/{huidige_school().code}/{inst['Logo_sleutel']}.{ext}"
return ''
def zet_nacht(aan, door):
execute_query("UPDATE gmail_handtekening SET Nachtelijk = %s, Door = %s WHERE ID = 1", (1 if aan else 0, door),
commit=True)
def logboek():
ensure_tables()
return {r['Email'].lower(): r for r in execute_query("SELECT * FROM gmail_handtekening_log") or []}
# ---------------------------------------------------------------------
# Sjabloon invullen
# ---------------------------------------------------------------------
def gegevens(gebruiker):
s = huidige_school()
return {'voornaam': gebruiker.get('voornaam', ''), 'achternaam': gebruiker.get('achternaam', ''),
'naam': f"{gebruiker.get('voornaam', '')} {gebruiker.get('achternaam', '')}".strip(),
'functie': gebruiker.get('functie', ''), 'telefoon': gebruiker.get('telefoon', ''),
'email': gebruiker.get('email', ''), 'schoolnaam': s.naam or '', 'schooladres': s.get('ADRES') or ''}
def vul_in(sjabloon, waarden, inst):
"""{veld} → waarde (HTML-veilig); {#veld}...{/veld} valt weg als het veld leeg is; {logo} → <img>."""
def blok(m):
return m.group(2) if (waarden.get(m.group(1)) or '').strip() or (m.group(1) == 'logo' and inst['logo_link']) else ''
uit = re.sub(r'\{#(\w+)\}(.*?)\{/\1\}', blok, sjabloon or '', flags=re.S)
link = inst.get('logo_link') or ''
logo = (f'<img src="{html.escape(link)}" width="{int(inst.get("Logo_breedte") or 120)}" alt="{html.escape(waarden.get("schoolnaam", ""))}" '
f'style="display:block;border:0;max-width:100%;height:auto">') if link else ''
def veld(m):
k = m.group(1)
if k == 'logo':
return logo
if k in waarden:
w = html.escape(waarden[k] or '')
return w.replace('\n', '<br>') if k == 'schooladres' else w
return m.group(0)
return re.sub(r'\{(\w+)\}', veld, uit)
# ---------------------------------------------------------------------
# Google: gebruikers, functie en telefoon
# ---------------------------------------------------------------------
def _directory():
from googleapiclient.discovery import build
creds = google_credentials(Config.GOOGLE_API_SCOPES, subject=Config.GOOGLE_ADMIN_EMAIL)
return build('admin', 'directory_v1', credentials=creds, static_discovery=False, cache_discovery=False)
def _gmail(mailbox):
from googleapiclient.discovery import build
creds = google_credentials([SCOPE], subject=mailbox)
try:
return build('gmail', 'v1', credentials=creds, static_discovery=True, cache_discovery=False)
except Exception:
return build('gmail', 'v1', credentials=creds, static_discovery=False, cache_discovery=False)
def vriendelijk(e):
t = str(e)
laag = t.lower()
if 'unauthorized_client' in laag or 'access_denied' in laag:
return (f"Google weigert de toegang: voeg in de Admin-console › Beveiliging › API-beheer › Domeinbrede delegatie "
f"het bereik {SCOPE} toe bij het serviceaccount van PIVOT.")
if 'has not been used' in laag or 'is disabled' in laag or 'accessnotconfigured' in laag:
return "De Gmail API staat niet aan in het Google Cloud-project van het serviceaccount."
if 'mail service not enabled' in laag or 'failedprecondition' in laag:
return "Deze gebruiker heeft geen Gmail (geen licentie of Gmail uitgeschakeld)."
if 'not authorized to access this resource' in laag or 'insufficient' in laag:
return "Google weigert het: de beheerder mag deze gebruiker niet aanpassen (scope admin.directory.user?)."
return t[:250]
def ou_lijst():
try:
resp = _directory().orgunits().list(customerId='my_customer', type='all').execute()
except Exception as e:
raise HandtekeningFout(f"OU's ophalen mislukt: {vriendelijk(e)}")
return sorted([o['orgUnitPath'] for o in resp.get('organizationUnits', [])] + ['/'], key=str.lower)
def _functie(u):
orgs = u.get('organizations') or []
org = next((o for o in orgs if o.get('primary')), orgs[0] if orgs else {})
return (org.get('title') or '').strip()
def _telefoon(u):
tel = u.get('phones') or []
t = next((p for p in tel if p.get('type') == 'work'), None) or next((p for p in tel if p.get('primary')), None) \
or (tel[0] if tel else {})
return (t.get('value') or '').strip()
def gebruikers(ous, met_sub=True):
"""Alle (niet-geschorste) gebruikers in de OU's: [{'email','voornaam','achternaam','ou','functie','telefoon'}]."""
if not ous:
return []
api, uit, gezien = _directory(), [], set()
for ou in ous:
token = None
while True:
try:
params = dict(customer='my_customer', maxResults=500, pageToken=token, projection='full',
fields='nextPageToken,users(primaryEmail,name,orgUnitPath,organizations,phones,suspended,archived)')
if ou != '/':
params['query'] = "orgUnitPath='" + ou.replace("\\", "\\\\").replace("'", "\\'") + "'"
resp = api.users().list(**params).execute()
except Exception as e:
raise HandtekeningFout(f"Gebruikers van {ou} ophalen mislukt: {vriendelijk(e)}")
for u in resp.get('users', []):
pad = u.get('orgUnitPath') or '/'
if not met_sub and pad != ou:
continue
if u.get('suspended') or u.get('archived'):
continue
email = u['primaryEmail'].lower()
if email in gezien:
continue
gezien.add(email)
n = u.get('name') or {}
uit.append({'email': email, 'voornaam': n.get('givenName', ''), 'achternaam': n.get('familyName', ''),
'ou': pad, 'functie': _functie(u), 'telefoon': _telefoon(u)})
token = resp.get('nextPageToken')
if not token:
break
return sorted(uit, key=lambda x: (x['achternaam'].lower(), x['voornaam'].lower()))
def bewaar_in_google(email, functie, telefoon):
"""Functie (primaire organisatie) en werktelefoon van een gebruiker in Google aanpassen; de rest blijft."""
api = _directory()
try:
u = api.users().get(userKey=email, projection='full', fields='organizations,phones').execute()
orgs = u.get('organizations') or []
org = next((o for o in orgs if o.get('primary')), orgs[0] if orgs else None)
if org is None:
if functie:
orgs = [{'title': functie, 'primary': True, 'customType': ''}]
else:
org['title'] = functie
tel = [p for p in (u.get('phones') or []) if p.get('type') != 'work']
if telefoon:
tel.insert(0, {'value': telefoon, 'type': 'work', 'primary': not any(p.get('primary') for p in tel)})
api.users().patch(userKey=email, body={'organizations': orgs, 'phones': tel or []}).execute()
except Exception as e:
raise HandtekeningFout(f"{email}: {vriendelijk(e)}")
def pas_toe(gebruiker, inst, door=None, alleen_gewijzigd=False):
"""Zet de handtekening voor het hoofdadres en de aliassen. Geeft 'ok' / 'ongewijzigd' of gooit HandtekeningFout."""
tekst = vul_in(inst.get('Sjabloon') or '', gegevens(gebruiker), inst)
if len(tekst) > MAX_TEKENS:
raise HandtekeningFout(f"De handtekening is te lang voor Gmail ({len(tekst)} tekens, max. {MAX_TEKENS}).")
h = hashlib.sha1(tekst.encode('utf-8')).hexdigest()
email = gebruiker['email']
if alleen_gewijzigd:
oud = execute_query("SELECT Hash, Ok FROM gmail_handtekening_log WHERE Email = %s", (email,))
if oud and oud[0]['Ok'] and oud[0]['Hash'] == h and _huidige_in_gmail_gelijk(email, tekst):
return 'ongewijzigd'
try:
g = _gmail(email)
adressen = g.users().settings().sendAs().list(userId='me').execute().get('sendAs', [])
for a in adressen:
if a.get('isPrimary') or a.get('verificationStatus') == 'accepted' or a.get('treatAsAlias'):
g.users().settings().sendAs().patch(userId='me', sendAsEmail=a['sendAsEmail'],
body={'signature': tekst}).execute()
melding, ok = None, 1
except Exception as e:
melding, ok = vriendelijk(e), 0
execute_query("INSERT INTO gmail_handtekening_log (Email, Toegepast, Ok, Melding, Hash, Door) "
"VALUES (%s, NOW(), %s, %s, %s, %s) ON DUPLICATE KEY UPDATE Toegepast = NOW(), Ok = VALUES(Ok), "
"Melding = VALUES(Melding), Hash = VALUES(Hash), Door = VALUES(Door)",
(email, ok, melding, h if ok else None, door), commit=True)
if not ok:
raise HandtekeningFout(melding)
return 'ok'
def _huidige_in_gmail_gelijk(email, tekst):
"""Staat de handtekening nog ongewijzigd in Gmail? (De gebruiker kan ze zelf aangepast hebben.)"""
try:
for a in _gmail(email).users().settings().sendAs().list(userId='me').execute().get('sendAs', []):
if a.get('isPrimary'):
gewoon = lambda x: re.sub(r'\s+', '', html.unescape(x or '')).lower()
return gewoon(a.get('signature')) == gewoon(tekst)
except Exception:
pass
return False
# ---------------------------------------------------------------------
# Toepassen op de achtergrond (met voortgang)
# ---------------------------------------------------------------------
class Toepasser:
def __new__(cls):
def init(inst):
inst._lock = threading.Lock()
inst.status = {'bezig': False, 'voortgang': 0, 'totaal': 0, 'gelukt': 0, 'ongewijzigd': 0, 'fouten': 0,
'fout': None, 'resultaat': None, 'foutvoorbeelden': []}
return school_singleton(cls, init)
def run(self, emails=None, door=None, alleen_gewijzigd=False):
if not self._lock.acquire(blocking=False):
return False, "Er loopt al een opdracht."
s = self.status
try:
s.update(bezig=True, voortgang=0, totaal=0, gelukt=0, ongewijzigd=0, fouten=0, fout=None, resultaat=None,
foutvoorbeelden=[])
inst = instellingen()
lijst = gebruikers(inst['ous'], bool(inst.get('Met_sub_ous')))
if emails is not None:
kies = {e.lower() for e in emails}
lijst = [u for u in lijst if u['email'] in kies]
s['totaal'] = len(lijst)
for u in lijst:
try:
if pas_toe(u, inst, door, alleen_gewijzigd) == 'ongewijzigd':
s['ongewijzigd'] += 1
else:
s['gelukt'] += 1
except Exception as e:
s['fouten'] += 1
if len(s['foutvoorbeelden']) < 6:
s['foutvoorbeelden'].append(f"{u['email']}: {str(e)[:180]}")
s['voortgang'] += 1
msg = f"Handtekening ingesteld voor {s['gelukt']} gebruiker(s)"
if s['ongewijzigd']:
msg += f", {s['ongewijzigd']} waren al in orde"
if s['fouten']:
msg += f", {s['fouten']} mislukt"
s['resultaat'] = msg + '.'
return True, s['resultaat']
except Exception as e:
s['fout'] = str(e)[:400]
return False, s['fout']
finally:
s['bezig'] = False
self._lock.release()
def start(self, emails=None, door=None):
if self.status['bezig']:
return False
self.status.update(bezig=True, voortgang=0, totaal=0, fout=None, resultaat=None)
threading.Thread(target=met_school(lambda: self.run(emails, door)), daemon=True).start()
return True
# ---------------------------------------------------------------------
# Nachtelijk opnieuw toepassen (per school met de schakelaar aan)
# ---------------------------------------------------------------------
def _nacht_claim(vandaag):
return execute_query("UPDATE gmail_handtekening SET Laatste_nacht = %s WHERE ID = 1 AND Nachtelijk = 1 "
"AND (Laatste_nacht IS NULL OR Laatste_nacht < %s)", (vandaag, vandaag), commit=True) == 1
def nacht_voor_alle_scholen():
from tenancy import alle_scholen, in_school
vandaag = datetime.now().strftime('%Y-%m-%d')
for school in alle_scholen():
if not school.google_sleutel:
continue
with in_school(school):
try:
ensure_tables()
if not _nacht_claim(vandaag):
continue
ok, msg = Toepasser().run(door='PIVOT (nachtelijk)', alleen_gewijzigd=True)
execute_query("UPDATE gmail_handtekening SET Nacht_resultaat = %s WHERE ID = 1",
(f"{datetime.now():%d-%m-%Y %H:%M}: {msg}"[:500],), commit=True)
except Exception as e:
sys.stderr.write(f"Handtekeningen nachtelijk ({school.code}): {e}\n")
_thread = None
def start_scheduler():
global _thread
if _thread:
return
def loop():
time.sleep(90)
while True:
try:
if NACHT_UUR_VAN <= datetime.now().hour < NACHT_UUR_TOT:
nacht_voor_alle_scholen()
except Exception as e:
sys.stderr.write(f"Handtekeningen nachtelijke taak: {e}\n")
time.sleep(15 * 60)
_thread = threading.Thread(target=loop, daemon=True)
_thread.start()
+115
View File
@@ -0,0 +1,115 @@
"""
Maakt HTML uit de handleiding-editor veilig: enkel een vaste lijst tags en attributen blijft over.
Scripts, event-handlers (onclick, ...), stijlen en javascript:-links verdwijnen.
"""
from html import escape
from html.parser import HTMLParser
TOEGELATEN = {
'p': (), 'br': (), 'hr': (), 'div': (), 'span': (),
'h2': ('id',), 'h3': ('id',), 'h4': ('id',),
'strong': (), 'b': (), 'em': (), 'i': (), 'u': (), 's': (), 'mark': (), 'kbd': (), 'sub': (), 'sup': (),
'ul': (), 'ol': ('start',), 'li': (),
'blockquote': (), 'code': (), 'pre': (),
'a': ('href', 'title', 'target'),
'img': ('src', 'alt', 'title', 'width'),
'table': (), 'thead': (), 'tbody': (), 'tr': (), 'th': ('colspan', 'rowspan'), 'td': ('colspan', 'rowspan'),
'figure': (), 'figcaption': (),
}
LEEG = {'br', 'hr', 'img'}
KLASSEN = {'tip', 'let-op', 'info'} # kaders in de editor
WEGGOOIEN_MET_INHOUD = {'script', 'style', 'iframe', 'object', 'embed', 'noscript', 'template', 'svg', 'math'}
# Word/Docs plakt deze tags; de inhoud blijft, de tag niet
VERTALING = {'b': 'strong', 'i': 'em', 'h1': 'h2', 'h5': 'h4', 'h6': 'h4'}
def _veilige_url(url, afbeelding=False):
u = (url or '').strip()
laag = u.lower().replace('\x00', '')
if afbeelding and laag.startswith('data:image/') and ';base64,' in laag and not laag.startswith('data:image/svg'):
return u
if laag.startswith(('http://', 'https://', '/', '#')) or (not afbeelding and laag.startswith('mailto:')):
return u
return None
class _Schoonmaker(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.uit = []
self.open = []
self.weg = 0
def handle_starttag(self, tag, attrs):
if tag in WEGGOOIEN_MET_INHOUD:
if tag not in LEEG:
self.weg += 1
return
if self.weg:
return
tag = VERTALING.get(tag, tag)
if tag not in TOEGELATEN:
return
delen = [tag]
for naam, waarde in attrs:
naam = (naam or '').lower()
if naam == 'class':
klassen = [k for k in (waarde or '').split() if k in KLASSEN]
if klassen:
delen.append(f'class="{escape(" ".join(klassen))}"')
continue
if naam not in TOEGELATEN[tag]:
continue
if naam in ('href', 'src'):
waarde = _veilige_url(waarde, afbeelding=(naam == 'src'))
if waarde is None:
continue
if naam == 'target':
waarde = '_blank'
if naam in ('width', 'colspan', 'rowspan', 'start') and not str(waarde or '').isdigit():
continue
delen.append(f'{naam}="{escape(waarde or "", quote=True)}"')
if tag == 'a' and any(d.startswith('target=') for d in delen):
delen.append('rel="noopener noreferrer"')
if tag == 'img' and not any(d.startswith('src=') for d in delen):
return
self.uit.append('<' + ' '.join(delen) + '>')
if tag not in LEEG:
self.open.append(tag)
def handle_startendtag(self, tag, attrs):
self.handle_starttag(tag, attrs)
tag = VERTALING.get(tag, tag)
if tag not in LEEG and self.open and self.open[-1] == tag:
self.handle_endtag(tag)
def handle_endtag(self, tag):
if tag in WEGGOOIEN_MET_INHOUD:
self.weg = max(0, self.weg - 1)
return
if self.weg:
return
tag = VERTALING.get(tag, tag)
if tag not in TOEGELATEN or tag in LEEG or tag not in self.open:
return
while self.open:
t = self.open.pop()
self.uit.append(f'</{t}>')
if t == tag:
break
def handle_data(self, data):
if not self.weg:
self.uit.append(escape(data, quote=False))
def resultaat(self):
while self.open:
self.uit.append(f'</{self.open.pop()}>')
return ''.join(self.uit)
def schoon(html):
p = _Schoonmaker()
p.feed(html or '')
p.close()
return p.resultaat().strip()
+233
View File
@@ -0,0 +1,233 @@
"""
Internaat: de internen uit Informat (Internaat WEB API, versie 1).
- Token: zelfde identityserver en client-ID/secret als de andere Informat-API's, scope
api_informat_sas_internaat.<instellingsnummer van het internaat>
(meerdere instellingsnummers mogen in één token, gescheiden door een spatie).
- GET <API-URL>/pupils?schoolYear=2026-27&refdate=JJJJ-MM-DD → de internen met hun gegevens, relaties, ...
- GET <API-URL>/registrations?schoolYear=2026-27 → de inschrijvingen (internaat, vestiging, kamers, ...)
Headers: InstituteNo (instellingsnummer) en Api-Version: 1. 404 = geen gegevens.
Het resultaat wordt 10 minuten per school, schooljaar en peildatum bewaard.
"""
import time
from datetime import date
import requests
from config import Config
CACHE_SECONDEN = 600
TIMEOUT = (15, 180)
STANDAARD_API = 'https://internaatapi.informatsoftware.be'
STANDAARD_SCOPE = 'api_informat_sas_internaat'
class InternaatFout(Exception):
pass
def _datum(w):
if not w:
return None
try:
return date.fromisoformat(str(w)[:10])
except ValueError:
return None
def _lopend(item, peildatum):
"""Loopt dit (inschrijving of kamer) op de peildatum?"""
begin, einde = _datum(item.get('begindatum')), _datum(item.get('einddatum'))
return (not begin or begin <= peildatum) and (not einde or einde >= peildatum)
class InternaatService:
def __init__(self):
from tenancy import huidige_school, informat_campussen
self.school = huidige_school()
self.base = (self.school.get('INFORMAT_INTERNAAT_API') or STANDAARD_API).rstrip('/')
scope = (self.school.get('INFORMAT_INTERNAAT_SCOPE') or STANDAARD_SCOPE).strip()
self.scope_basis = scope.rsplit('.', 1)[0] if scope.rsplit('.', 1)[-1].isdigit() else scope
# Instellingsnummer(s) van het internaat; leeg = die van de Informat-koppeling van de school
eigen = informat_campussen(self.school.waarden.get('INFORMAT_INTERNAAT_NUMMER'))
self.nummers = eigen or informat_campussen(self.school.waarden.get('INFORMAT_INST_NUMBER'))
# ---------------- authenticatie ----------------
def _token(self, nummer):
sleutel = ('internaat_token', nummer)
bewaard = self.school.cache.get(sleutel)
if bewaard and time.time() < bewaard[0]:
return bewaard[1]
scope = f"{self.scope_basis}.{nummer}"
try:
r = requests.post(Config.INFORMAT_AUTH_URL, data={
'grant_type': 'client_credentials', 'client_id': Config.INFORMAT_ID,
'client_secret': Config.INFORMAT_SECRET, 'scope': scope}, timeout=15)
except Exception as e:
raise InternaatFout(f"Informat (identityserver) is niet bereikbaar: {e}")
try:
js = r.json()
except Exception:
js = {}
if r.status_code != 200 or not js.get('access_token'):
fout = js.get('error') or f"HTTP {r.status_code}"
if fout == 'invalid_scope':
raise InternaatFout(f"Informat weigert de scope {scope} (invalid_scope). Heeft het internaat met "
f"instellingsnummer {nummer} in Informat de app toegang gegeven tot de Internaat-API? "
"Klopt het instellingsnummer van het internaat (Koppelingen › Informat)?")
if fout == 'invalid_client':
raise InternaatFout("Informat kent deze client-ID/secret niet (invalid_client). Kijk Koppelingen › Informat na.")
raise InternaatFout(f"Aanmelden bij de Internaat-API mislukt: {fout}")
self.school.cache[sleutel] = (time.time() + int(js.get('expires_in') or 3600) - 60, js['access_token'])
return js['access_token']
def _get(self, pad, nummer, params):
headers = {'Authorization': f"BEARER {self._token(nummer)}", 'InstituteNo': nummer, 'Api-Version': '1',
'Accept': 'application/json'}
url = f"{self.base}{pad}"
try:
r = requests.get(url, params=params, headers=headers, timeout=TIMEOUT)
if r.status_code == 405: # de voorbeelden in de handleiding gebruiken POST
r = requests.post(url, params=params, headers=headers, timeout=TIMEOUT)
except requests.exceptions.Timeout:
raise InternaatFout("De Internaat-API van Informat antwoordde niet op tijd. Probeer later opnieuw.")
except Exception as e:
raise InternaatFout(f"De Internaat-API van Informat is niet bereikbaar: {e}")
if r.status_code == 404:
return [] # volgens Informat: geen gegevens
if r.status_code in (401, 403):
raise InternaatFout(f"Informat weigert de toegang tot de internen van {nummer} ({r.status_code}).")
if r.status_code != 200:
raise InternaatFout(f"Internaat-API ({nummer}): HTTP {r.status_code} {r.text[:200]}")
try:
data = r.json()
except Exception:
raise InternaatFout(f"Onverwacht antwoord van de Internaat-API: {r.text[:200]}")
return data if isinstance(data, list) else (data.get('items') or data.get('data') or [])
# ---------------- internen ----------------
def internen(self, schooljaar, peildatum=None, vernieuw=False):
"""(rijen, opgehaald) met één rij per intern die op de peildatum ingeschreven is."""
if not self.nummers:
raise InternaatFout("Er is nog geen instellingsnummer voor het internaat ingesteld (Koppelingen › Informat).")
peildatum = peildatum or date.today()
sleutel = ('internen', schooljaar, peildatum.isoformat())
bewaard = self.school.cache.get(sleutel)
if bewaard and not vernieuw and time.time() - bewaard[0] < CACHE_SECONDEN:
return bewaard[1], bewaard[0]
rijen, gezien = [], set()
for nummer, campusnaam in self.nummers:
inschrijvingen = {}
for ins in self._get('/registrations', nummer, {'schoolYear': schooljaar}):
if _lopend(ins, peildatum) or str(ins.get('pPersoon')) not in inschrijvingen:
inschrijvingen[str(ins.get('pPersoon'))] = ins
for p in self._get('/pupils', nummer, {'schoolYear': schooljaar, 'refdate': peildatum.isoformat()}):
sleutel_p = str(p.get('persoonId') or p.get('pPersoon'))
if sleutel_p in gezien: # zelfde intern via een tweede instellingsnummer
continue
gezien.add(sleutel_p)
ins = inschrijvingen.get(str(p.get('pPersoon'))) or {}
kamer = next((k for k in ins.get('kamers') or [] if _lopend(k, peildatum)), None)
ouders = []
for rel in sorted(p.get('relaties') or [], key=lambda x: (x.get('lpv') or 9)):
if rel.get('isOverleden'):
continue
gsm = next((c.get('nr') for c in rel.get('comnrs') or [] if (c.get('soort') or '').lower() == 'gsm'), None) \
or next((c.get('nr') for c in rel.get('comnrs') or []), None)
mail = next((e.get('email') for e in rel.get('emails') or [] if e.get('schoolcom')), None) \
or next((e.get('email') for e in rel.get('emails') or []), None)
ouders.append({'type': rel.get('type') or '', 'naam': f"{rel.get('voornaam') or ''} {rel.get('naam') or ''}".strip(),
'gsm': gsm or '', 'email': mail or ''})
adres = next((a for a in p.get('adressen') or [] if a.get('isVerblijf')), None) or \
next(iter(p.get('adressen') or []), None) or {}
rijen.append({
'id': p.get('pPersoon'),
'naam': (p.get('naam') or '').strip(), 'voornaam': (p.get('voornaam') or '').strip(),
'geboortedatum': _datum(p.get('geboortedatum')), 'geslacht': p.get('geslacht') or '',
'stamnummer': ins.get('stamnr') or '',
'internaat': ins.get('internaat') or campusnaam or nummer, 'nummer': nummer,
'vestiging': ins.get('vestiging') or '',
'begin': _datum(ins.get('begindatum')), 'einde': _datum(ins.get('einddatum')),
'financiering': ins.get('fin') or '',
'kamer': ' '.join(x for x in (
f"blok {kamer.get('blok')}" if kamer and kamer.get('blok') else '',
f"verd. {kamer.get('verdiep')}" if kamer and kamer.get('verdiep') not in (None, '') else '',
f"nr. {kamer.get('nr')}" if kamer and kamer.get('nr') not in (None, '') else '') if x),
'kamer_sort': f"{(kamer or {}).get('blok') or ''}-{str((kamer or {}).get('verdiep') or '').zfill(3)}-{str((kamer or {}).get('nr') or '').zfill(5)}" if kamer else '',
'ouders': ouders,
'gemeente': ' '.join(x for x in (adres.get('postcode'), adres.get('gemeente')) if x),
'huisdokter': ' – '.join(x for x in (p.get('huisdokter'), p.get('telefoonHuisdokter')) if x),
})
rijen.sort(key=lambda r: (r['naam'].lower(), r['voornaam'].lower()))
nu = time.time()
self.school.cache[sleutel] = (nu, rijen)
return rijen, nu
# ---------------------------------------------------------------------
# Bestaat de intern in Smartschool? (gebruikersnaam = "voornaam achternaam")
# ---------------------------------------------------------------------
SS_CACHE_SECONDEN = 3600
def _naamdeel(tekst):
"""'Van den Broeck' → 'vandenbroeck', 'Zoë' → 'zoe'."""
import unicodedata
t = unicodedata.normalize('NFKD', str(tekst or '')).encode('ascii', 'ignore').decode().lower()
return ''.join(c for c in t if c.isalnum() or c == '-')
def ss_kandidaten(voornaam, naam):
"""Mogelijke Smartschool-gebruikersnamen: eerst "voornaam achternaam" (met spatie), dan varianten."""
v, n = _naamdeel(voornaam), _naamdeel(naam)
vol = ' '.join(f"{str(voornaam).strip()} {str(naam).strip()}".split())
# Op het internaat: "voornaam achternaam" met een spatie (eerst zoals in Informat, dan in kleine letters)
uit = [vol, vol.lower(), f"{v} {n}",
f"{v}.{n}", f"{v}.{n.replace('-', '')}", f"{v.replace('-', '')}.{n.replace('-', '')}", f"{v}{n}"]
return [x for x in dict.fromkeys(uit) if x.strip('. ')]
def smartschool_status(personen):
"""personen: [{'pid', 'voornaam', 'naam'}] → {pid: gebruikersnaam of None}. 1 uur bewaard per school."""
import threading
from concurrent.futures import ThreadPoolExecutor
from tenancy import huidige_school, in_school
school = huidige_school()
if not school.heeft('SS_API_PLATFORM', 'SS_API_PW'):
raise InternaatFout("Smartschool is nog niet ingesteld voor deze school (🔌 Koppelingen › Smartschool).")
cache = school.cache.setdefault('ss_internen', {})
nu = time.time()
uit, te_doen = {}, []
for p in personen:
sleutel = (str(p.get('pid')), p.get('voornaam') or '', p.get('naam') or '')
hit = cache.get(sleutel)
if hit and nu - hit[0] < SS_CACHE_SECONDEN:
uit[sleutel[0]] = hit[1]
else:
te_doen.append(sleutel)
if not te_doen:
return uit
lokaal = threading.local()
def zoek(sleutel):
pid, voornaam, naam = sleutel
with in_school(school):
if not hasattr(lokaal, 'ss'):
from services.smartschool_api import SmartschoolService
lokaal.ss = SmartschoolService(Config.SS_API_PLATFORM, Config.SS_API_PW)
gevonden = None
for kandidaat in ss_kandidaten(voornaam, naam):
try:
raw = lokaal.ss.client.service.getUserDetailsByUsername(Config.SS_API_PW, kandidaat)
except Exception:
continue
if isinstance(raw, str) and raw.strip().startswith('{'):
gevonden = kandidaat
break
return sleutel, gevonden
with ThreadPoolExecutor(max_workers=4) as pool:
for sleutel, gevonden in pool.map(zoek, te_doen):
cache[sleutel] = (time.time(), gevonden)
uit[sleutel[0]] = gevonden
return uit
+196
View File
@@ -0,0 +1,196 @@
"""
IP-blokkades: aanvragen van bepaalde IP-adressen of reeksen weigeren, voor alle scholen.
Regels staan in de centrale tabel IP_blokkades:
- Soort 'blokkeren' → elke aanvraag van zo'n adres krijgt 403 (ook de loginpagina).
- Soort 'toestaan' → uitzondering: dit adres mag altijd, ook als het in een geblokkeerde reeks valt.
Een reeks kan je schrijven als:
85. → alles wat met 85. begint (85.0.0.0/8)
85.12. → 85.12.0.0/16
85.12.3. → 85.12.3.0/24
85.12.3.4 → één adres
85.0.0.0/8 → CIDR-notatie (ook IPv6, bv. 2a02:1810::/32)
De lijst wordt 30 seconden in het geheugen gehouden; een wijziging in PIVOT geldt meteen.
Lukt het lezen van de database niet, dan wordt niemand geweigerd (de site blijft bereikbaar).
"""
import ipaddress
import sys
import threading
import time
CACHE_SECONDEN = 30
_cache = {'t': 0, 'regels': []}
_lock = threading.Lock()
class OngeldigeReeks(ValueError):
pass
def normaliseer(reeks):
"""Tekst van de gebruiker → ip_network. '85.' → 85.0.0.0/8, '85.12.3.4' → 85.12.3.4/32."""
t = str(reeks or '').strip().replace(' ', '')
if not t:
raise OngeldigeReeks("Vul een IP-adres of reeks in.")
if '/' not in t and ':' not in t:
delen = t.rstrip('.').split('.') if t.endswith('.') or t.count('.') < 3 else None
if delen is not None:
if not 1 <= len(delen) <= 3 or not all(d.isdigit() and 0 <= int(d) <= 255 for d in delen):
raise OngeldigeReeks(f"'{reeks}' is geen geldige reeks. Voorbeelden: 85. 85.12. 85.12.3.4 85.0.0.0/8")
t = '.'.join(delen + ['0'] * (4 - len(delen))) + f"/{8 * len(delen)}"
try:
return ipaddress.ip_network(t, strict=False)
except ValueError:
raise OngeldigeReeks(f"'{reeks}' is geen geldig IP-adres of geldige reeks. "
"Voorbeelden: 85. 85.12. 85.12.3.4 85.0.0.0/8")
def ensure_table():
from database import central_query
central_query("""
CREATE TABLE IF NOT EXISTS PIVOT_instellingen (
Sleutel VARCHAR(50) NOT NULL PRIMARY KEY,
Waarde VARCHAR(255) NULL,
Bijgewerkt DATETIME NULL,
Door VARCHAR(100) NULL
) ENGINE=InnoDB""", commit=True)
central_query("""
CREATE TABLE IF NOT EXISTS IP_blokkades (
ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
Reeks VARCHAR(64) NOT NULL,
Soort VARCHAR(10) NOT NULL DEFAULT 'blokkeren',
Opmerking VARCHAR(255) NULL,
Scholen VARCHAR(1000) NULL,
Aangemaakt DATETIME NOT NULL,
Door VARCHAR(100) NULL,
Gebruiker_ID INT NULL,
Aantal_geweigerd INT NOT NULL DEFAULT 0,
Laatst_geweigerd DATETIME NULL,
UNIQUE KEY uq_ip_reeks (Reeks, Soort)
) ENGINE=InnoDB""", commit=True)
# Bestaande tabel van vóór de schoolkeuze bij "Altijd toestaan" (of met de eerdere kolom School_code: één school)
kolommen = {r['Field'].lower() for r in central_query("SHOW COLUMNS FROM IP_blokkades") or []}
if 'scholen' not in kolommen:
central_query("ALTER TABLE IP_blokkades ADD COLUMN Scholen VARCHAR(1000) NULL AFTER Opmerking", commit=True)
if 'school_code' in kolommen:
central_query("UPDATE IP_blokkades SET Scholen = School_code WHERE School_code IS NOT NULL", commit=True)
if 'school_code' in kolommen:
central_query("ALTER TABLE IP_blokkades DROP COLUMN School_code", commit=True)
if 'gebruiker_id' not in kolommen: # wie de regel toevoegde (gewone gebruikers mogen enkel hun eigen regels verwijderen)
central_query("ALTER TABLE IP_blokkades ADD COLUMN Gebruiker_ID INT NULL AFTER Door", commit=True)
def scholen_codes(waarde):
"""'a,b' (kolom Scholen) → ['a', 'b']."""
return [c for c in (x.strip() for x in str(waarde or '').split(',')) if c]
def scholen_waarde(codes):
"""['a', 'b'] → 'a,b' (of None), zonder dubbels, in de gegeven volgorde."""
uit = []
for c in codes or []:
c = str(c).strip()
if c and c not in uit:
uit.append(c)
return ','.join(uit)[:1000] or None
MODUS_SLEUTEL = 'ip_enkel_toegestaan' # '1' = enkel adressen uit "Altijd toestaan" mogen binnen
TELLER_SLEUTEL = 'ip_whitelist_geweigerd' # aantal weigeringen door die modus
def _laad():
from database import central_query
uit = []
for r in central_query("SELECT ID, Reeks, Soort FROM IP_blokkades") or []:
try:
uit.append((r['ID'], r['Soort'], ipaddress.ip_network(r['Reeks'], strict=False)))
except ValueError:
continue
rij = central_query("SELECT Waarde FROM PIVOT_instellingen WHERE Sleutel = %s", (MODUS_SLEUTEL,))
_cache.update(t=time.time(), regels=uit, enkel_toegestaan=bool(rij and rij[0]['Waarde'] == '1'))
def regels(vers=False):
"""[(id, soort, ip_network)] uit de database (gecachet)."""
if vers or time.time() - _cache['t'] >= CACHE_SECONDEN:
with _lock:
if vers or time.time() - _cache['t'] >= CACHE_SECONDEN:
_laad()
return _cache['regels']
def enkel_toegestaan(vers=False):
"""True als de modus "enkel Altijd toestaan" aan staat."""
regels(vers)
return _cache.get('enkel_toegestaan', False)
def zet_enkel_toegestaan(aan, door=None):
from database import central_query
central_query("INSERT INTO PIVOT_instellingen (Sleutel, Waarde, Bijgewerkt, Door) VALUES (%s, %s, NOW(), %s) "
"ON DUPLICATE KEY UPDATE Waarde = VALUES(Waarde), Bijgewerkt = NOW(), Door = VALUES(Door)",
(MODUS_SLEUTEL, '1' if aan else '0', door), commit=True)
vergeet_cache()
def instelling(sleutel):
from database import central_query
rij = central_query("SELECT Waarde, Bijgewerkt, Door FROM PIVOT_instellingen WHERE Sleutel = %s", (sleutel,))
return rij[0] if rij else None
def is_lokaal(ip):
"""127.0.0.1 of ::1: dan ziet PIVOT waarschijnlijk een reverse proxy i.p.v. de echte bezoeker."""
try:
a = ipaddress.ip_address(str(ip or '').strip())
except ValueError:
return False
return a.is_loopback
def vergeet_cache():
_cache['t'] = 0
def beoordeel(ip, lijst=None, enkel_toe=None):
"""Geeft (geweigerd, regel_id). Een 'toestaan'-regel wint altijd van een 'blokkeren'-regel.
In de modus "enkel Altijd toestaan" wordt alles geweigerd wat niet op die lijst staat (regel_id = 'modus')."""
try:
adres = ipaddress.ip_address(str(ip or '').strip())
except ValueError:
return False, None
if getattr(adres, 'ipv4_mapped', None): # ::ffff:85.1.2.3 → 85.1.2.3
adres = adres.ipv4_mapped
lijst = regels() if lijst is None else lijst
if any(s == 'toestaan' and adres in net for _, s, net in lijst if net.version == adres.version):
return False, None
if enkel_toegestaan() if enkel_toe is None else enkel_toe:
return True, 'modus'
for rid, s, net in lijst:
if s == 'blokkeren' and net.version == adres.version and adres in net:
return True, rid
return False, None
def controleer(ip):
"""Voor elke aanvraag: True = weigeren. Telt de weigering bij de regel. Faalt nooit (fout → doorlaten)."""
try:
geweigerd, rid = beoordeel(ip)
except Exception as e:
sys.stderr.write(f"PIVOT: IP-blokkades lezen mislukt: {e}\n")
return False
if geweigerd:
try:
from database import central_query
if rid == 'modus':
central_query("INSERT INTO PIVOT_instellingen (Sleutel, Waarde, Bijgewerkt) VALUES (%s, '1', NOW()) "
"ON DUPLICATE KEY UPDATE Waarde = CAST(Waarde AS UNSIGNED) + 1, Bijgewerkt = NOW()",
(TELLER_SLEUTEL,), commit=True)
else:
central_query("UPDATE IP_blokkades SET Aantal_geweigerd = Aantal_geweigerd + 1, "
"Laatst_geweigerd = NOW() WHERE ID = %s", (rid,), commit=True)
except Exception:
pass
return geweigerd
+371
View File
@@ -0,0 +1,371 @@
"""
Personeel: afwezigheden van leerkrachten, nascholingen en uitstappen.
Rollen (config.ini › [personeel]):
- leerkracht : iedereen in personeel_ous die met Google inlogt (PIVOT-groep 5).
Ziet en beheert enkel zijn eigen meldingen/aanvragen.
- directie : e-mailadressen in 'directie' → keurt nascholingen en uitstappen goed.
- secretariaat : e-mailadressen in 'secretariaat' → dagoverzicht, vervangingen,
melden namens een leerkracht.
PIVOT-gebruikers met het recht 'personeel_secretariaat' / 'personeel_directie' in deze
school krijgen die rol ook. Administrators hebben de secretariaatsrechten, en ook de
directierechten zolang er nog geen directie is ingesteld.
Soorten:
- afwezigheid : geen goedkeuring nodig, meteen zichtbaar voor het secretariaat
- nascholing : goedkeuring door de directie
- uitstap : goedkeuring door de directie (klassen + begeleiders zijn weg)
"""
import json
import sys
import threading
import time
from datetime import date, timedelta
from config import Config
from database import execute_query, get_db_connection
from tenancy import huidige_school, met_school
LEERKRACHT_GROEP = 5
SOORTEN = {
'afwezigheid': {'label': 'Afwezigheid', 'icoon': '🤒', 'goedkeuring': False},
'nascholing': {'label': 'Nascholing', 'icoon': '🎓', 'goedkeuring': True},
'uitstap': {'label': 'Uitstap', 'icoon': '🚌', 'goedkeuring': True},
}
STATUS = {
'gemeld': ('Gemeld', 'info'),
'ingediend': ('Wacht op directie', 'warning'),
'vraag': ('Vraag van directie', 'warning'),
'goedgekeurd': ('Goedgekeurd', 'success'),
'geweigerd': ('Geweigerd', 'danger'),
'geannuleerd': ('Geannuleerd', ''),
}
DAGDELEN = {'dag': 'Hele dag', 'vm': 'Voormiddag', 'nm': 'Namiddag', 'uren': 'Bepaalde lesuren'}
# Statussen die in het dagoverzicht meetellen als "is weg"
ACTIEF = ('gemeld', 'goedgekeurd')
def _lijst(waarde):
return [x.strip().lower() for x in (waarde or '').replace(';', ',').split(',') if x.strip()]
# ---------------- rollen ----------------
def directie():
return _lijst(Config.PERSONEEL_DIRECTIE)
def secretariaat():
return _lijst(Config.PERSONEEL_SECRETARIAAT)
def rollen(sess):
"""Rollen in de personeelsmodule: via de e-maillijsten van de school of via de PIVOT-rechten
'personeel_directie' / 'personeel_secretariaat' (Gebruikers & rechten)."""
from rechten import heeft_recht, is_admin
email = (sess.get('user') or '').lower()
admin = is_admin()
r = {'leerkracht'}
if email in directie() or (heeft_recht('personeel_directie') and (not admin or not directie())):
r.add('directie')
if email in secretariaat() or heeft_recht('personeel_secretariaat'):
r.add('secretariaat')
return r
_ou_cache = {}
def is_personeelslid(email):
"""True als het Google-account in een van de personeels-OU's zit (cache 10 min)."""
email = (email or '').lower()
school = huidige_school()
if email.rsplit('@', 1)[-1] not in school.domeinen:
return False
sleutel = (school.code, email)
hit = _ou_cache.get(sleutel)
if hit and time.time() - hit[0] < 600:
return hit[1]
ok = False
try:
from services.google_service import GoogleAdminService
u = GoogleAdminService().get_user_info(email) or {}
ou = u.get('orgUnitPath', '')
ok = (not u.get('suspended')) and any(ou == p or ou.startswith(p.rstrip('/') + '/')
for p in [x.strip() for x in Config.PERSONEEL_OUS.split(',') if x.strip()])
except Exception as e:
sys.stderr.write(f"Personeel: OU-controle mislukt voor {email}: {e}\n")
_ou_cache[sleutel] = (time.time(), ok)
return ok
# ---------------- klassen uit Smartschool ----------------
def klassen():
"""Officiële klassen uit Smartschool (cache 6 uur, per school). Lege lijst als het niet lukt."""
_klassen_cache = huidige_school().object('klassen_cache', lambda: {'t': 0, 'lijst': []})
if _klassen_cache['lijst'] and time.time() - _klassen_cache['t'] < 6 * 3600:
return _klassen_cache['lijst']
try:
from services.smartschool_api import SmartschoolService
ss = SmartschoolService(platform=Config.SS_API_PLATFORM, webservice_pwd=Config.SS_API_PW)
raw = ss.client.service.getClassListJson(Config.SS_API_PW)
data = json.loads(raw) if isinstance(raw, str) else raw
if isinstance(data, dict):
data = list(data.values())
namen = []
for k in data or []:
if not isinstance(k, dict):
continue
officieel = k.get('isKlas', k.get('isOfficial', True))
if str(officieel).lower() in ('false', '0', 'none'):
continue
naam = (k.get('name') or k.get('code') or '').strip()
if naam:
namen.append(naam)
lijst = sorted(set(namen), key=lambda n: (len(n) > 3, n))
if lijst:
_klassen_cache.update(t=time.time(), lijst=lijst)
return lijst
except Exception as e:
sys.stderr.write(f"Personeel: klassenlijst uit Smartschool mislukt: {e}\n")
return _klassen_cache['lijst']
# ---------------- database ----------------
def ensure_tables():
execute_query("""
CREATE TABLE IF NOT EXISTS personeel_aanvragen (
ID INT AUTO_INCREMENT PRIMARY KEY,
Soort VARCHAR(20) NOT NULL,
Status VARCHAR(20) NOT NULL,
Email VARCHAR(255) NOT NULL,
Naam VARCHAR(255) NULL,
Ingediend_door VARCHAR(255) NULL,
Titel VARCHAR(255) NOT NULL,
Omschrijving TEXT NULL,
Locatie VARCHAR(255) NULL,
Van_datum DATE NOT NULL,
Tot_datum DATE NOT NULL,
Dagdeel VARCHAR(10) NOT NULL DEFAULT 'dag',
Lesuren VARCHAR(50) NULL,
Klassen TEXT NULL,
Begeleiders TEXT NULL,
Kost DECIMAL(10,2) NULL,
Vervanging_ok TINYINT(1) NOT NULL DEFAULT 0,
Vervanging_info VARCHAR(255) NULL,
Aangemaakt DATETIME NOT NULL,
Bijgewerkt DATETIME NOT NULL,
INDEX idx_status (Status),
INDEX idx_datum (Van_datum, Tot_datum),
INDEX idx_email (Email)
)""", commit=True)
execute_query("""
CREATE TABLE IF NOT EXISTS personeel_reacties (
ID INT AUTO_INCREMENT PRIMARY KEY,
Aanvraag_ID INT NOT NULL,
Tijdstip DATETIME NOT NULL,
Email VARCHAR(255) NULL,
Naam VARCHAR(255) NULL,
Actie VARCHAR(30) NOT NULL,
Tekst TEXT NULL,
INDEX idx_aanvraag (Aanvraag_ID)
)""", commit=True)
def _reactie(aanvraag_id, email, naam, actie, tekst=None):
execute_query("INSERT INTO personeel_reacties (Aanvraag_ID, Tijdstip, Email, Naam, Actie, Tekst) "
"VALUES (%s, NOW(), %s, %s, %s, %s)", (aanvraag_id, email, naam, actie, tekst), commit=True)
def aanmaken(data, door_email, door_naam):
soort = data['soort']
status = 'ingediend' if SOORTEN[soort]['goedkeuring'] else 'gemeld'
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute("""
INSERT INTO personeel_aanvragen (Soort, Status, Email, Naam, Ingediend_door, Titel, Omschrijving, Locatie,
Van_datum, Tot_datum, Dagdeel, Lesuren, Klassen, Begeleiders, Kost, Aangemaakt, Bijgewerkt)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW(),NOW())""",
(soort, status, data['email'], data['naam'], door_email if door_email != data['email'] else None,
data['titel'], data.get('omschrijving'), data.get('locatie'), data['van'], data['tot'],
data['dagdeel'], data.get('lesuren'), ', '.join(data.get('klassen') or []),
data.get('begeleiders'), data.get('kost')))
conn.commit()
aid = cur.lastrowid
finally:
cur.close()
conn.close()
tekst = None if door_email == data['email'] else f"Gemeld door {door_naam or door_email}"
_reactie(aid, door_email, door_naam, 'gemeld' if status == 'gemeld' else 'ingediend', tekst)
return aid
def get(aid):
rows = execute_query("SELECT * FROM personeel_aanvragen WHERE ID = %s", (aid,))
return rows[0] if rows else None
def reacties(aid):
return execute_query(
"SELECT Tijdstip, Email, Naam, Actie, Tekst FROM personeel_reacties WHERE Aanvraag_ID = %s ORDER BY Tijdstip, ID",
(aid,))
def wijzig_status(aid, nieuwe_status, door_email, door_naam, actie, tekst=None):
execute_query("UPDATE personeel_aanvragen SET Status = %s, Bijgewerkt = NOW() WHERE ID = %s",
(nieuwe_status, aid), commit=True)
_reactie(aid, door_email, door_naam, actie, tekst)
def commentaar(aid, door_email, door_naam, tekst):
execute_query("UPDATE personeel_aanvragen SET Bijgewerkt = NOW() WHERE ID = %s", (aid,), commit=True)
_reactie(aid, door_email, door_naam, 'commentaar', tekst)
def vervanging(aid, ok, info, door_email, door_naam):
execute_query("UPDATE personeel_aanvragen SET Vervanging_ok = %s, Vervanging_info = %s, Bijgewerkt = NOW() "
"WHERE ID = %s", (1 if ok else 0, (info or '')[:255] or None, aid), commit=True)
_reactie(aid, door_email, door_naam, 'vervanging',
('Vervanging geregeld' if ok else 'Vervanging nog niet geregeld') + (f": {info}" if info else ''))
def mijn(email):
return execute_query("SELECT * FROM personeel_aanvragen WHERE Email = %s ORDER BY Van_datum DESC, ID DESC LIMIT 200",
(email,))
def te_beoordelen():
return execute_query("SELECT * FROM personeel_aanvragen WHERE Status IN ('ingediend','vraag') "
"ORDER BY Status = 'vraag', Van_datum, ID")
def recent_beslist(limit=20):
return execute_query("SELECT * FROM personeel_aanvragen WHERE Status IN ('goedgekeurd','geweigerd') "
"AND Soort <> 'afwezigheid' ORDER BY Bijgewerkt DESC LIMIT %s", (int(limit),))
def periode(van, tot, statussen=ACTIEF):
ph = ','.join(['%s'] * len(statussen))
return execute_query(
f"SELECT * FROM personeel_aanvragen WHERE Status IN ({ph}) AND Van_datum <= %s AND Tot_datum >= %s "
"ORDER BY Van_datum, Naam", tuple(statussen) + (tot, van))
def aantal_te_beoordelen():
try:
return execute_query("SELECT COUNT(*) AS n FROM personeel_aanvragen WHERE Status = 'ingediend'")[0]['n']
except Exception:
return 0
# ---------------- dagoverzicht ----------------
def dagoverzicht(van, tot):
"""Per dag: wie is weg en welke klassen zijn weg (enkel gemeld/goedgekeurd), plus wat nog wacht."""
items = periode(van, tot)
wachtend = periode(van, tot, ('ingediend', 'vraag'))
dagen = []
d = van
while d <= tot:
if d.weekday() < 5:
vandaag = [i for i in items if i['Van_datum'] <= d <= i['Tot_datum']]
personen, klassen_weg = [], []
for i in vandaag:
personen.append({'naam': i['Naam'] or i['Email'], 'email': i['Email'], 'item': i, 'rol': None})
if i['Soort'] == 'uitstap':
for b in [x.strip() for x in (i['Begeleiders'] or '').split(',') if x.strip()]:
personen.append({'naam': b, 'email': None, 'item': i, 'rol': 'begeleider'})
for k in [x.strip() for x in (i['Klassen'] or '').split(',') if x.strip()]:
klassen_weg.append({'klas': k, 'item': i})
dagen.append({
'datum': d, 'personen': personen,
'klassen': sorted(klassen_weg, key=lambda k: k['klas']),
'wachtend': [w for w in wachtend if w['Van_datum'] <= d <= w['Tot_datum']],
'open_vervanging': sum(1 for i in vandaag if i['Klassen'] and i['Soort'] != 'uitstap' and not i['Vervanging_ok']),
})
d += timedelta(days=1)
return dagen
# ---------------- e-mail ----------------
def _mail_async(ontvangers, onderwerp, html):
ontvangers = sorted({o for o in ontvangers if o and '@' in o})
if not Config.PERSONEEL_MAILS or not ontvangers:
return
def verstuur():
try:
from services.google_service import GoogleAdminService
svc = GoogleAdminService()
for o in ontvangers:
ok, msg = svc.send_custom_mail(o, onderwerp, html)
if not ok:
sys.stderr.write(f"Personeel: mail naar {o} mislukt: {msg}\n")
except Exception as e:
sys.stderr.write(f"Personeel: mail mislukt: {e}\n")
threading.Thread(target=met_school(verstuur), daemon=True).start()
def _mailtekst(item, intro, link, extra=None):
soort = SOORTEN[item['Soort']]
periode_txt = item['Van_datum'].strftime('%d/%m/%Y')
if item['Tot_datum'] != item['Van_datum']:
periode_txt += ' – ' + item['Tot_datum'].strftime('%d/%m/%Y')
dagdeel = DAGDELEN.get(item['Dagdeel'], '')
if item['Dagdeel'] == 'uren' and item['Lesuren']:
dagdeel += f" ({item['Lesuren']})"
rijen = [('Wie', item['Naam'] or item['Email']), ('Wat', f"{soort['icoon']} {soort['label']}: {item['Titel']}"),
('Wanneer', f"{periode_txt}, {dagdeel}")]
if item['Klassen']:
rijen.append(('Klassen', item['Klassen']))
if item['Begeleiders']:
rijen.append(('Begeleiders', item['Begeleiders']))
if item['Kost']:
rijen.append(('Kost', f"€ {item['Kost']}"))
tabel = ''.join(f"<tr><td style='padding:4px 12px 4px 0;color:#64748b'>{k}</td><td style='padding:4px 0'><b>{v}</b></td></tr>"
for k, v in rijen)
extra_html = f"<p style='background:#f1f5f9;padding:10px 14px;border-radius:6px'>{extra}</p>" if extra else ''
return (f"<div style='font-family:Segoe UI,Arial,sans-serif;color:#1e293b'>"
f"<p>{intro}</p><table>{tabel}</table>{extra_html}"
f"<p><a href='{link}' style='background:#1e6fa5;color:#fff;padding:10px 16px;border-radius:6px;text-decoration:none'>"
f"Openen in PIVOT</a></p></div>")
def secretariaat_adressen():
return _lijst(Config.PERSONEEL_SECRETARIAAT_MAIL) or secretariaat()
def mail_nieuw(item, link):
soort = SOORTEN[item['Soort']]
if soort['goedkeuring']:
_mail_async(directie(), f"Nieuwe aanvraag {soort['label'].lower()}: {item['Naam']} – {item['Titel']}",
_mailtekst(item, f"Er is een nieuwe aanvraag voor een {soort['label'].lower()} die je goedkeuring vraagt.", link))
else:
_mail_async(secretariaat_adressen(), f"Afwezig: {item['Naam']} ({item['Van_datum']:%d/%m})",
_mailtekst(item, "Er is een afwezigheid gemeld.", link, item['Omschrijving']))
def mail_beslissing(item, beslissing, tekst, link):
soort = SOORTEN[item['Soort']]
woorden = {'goedgekeurd': 'is goedgekeurd ✅', 'geweigerd': 'is geweigerd ❌', 'vraag': 'heeft een vraag van de directie ❓'}
_mail_async([item['Email']], f"Je aanvraag {soort['label'].lower()} {woorden[beslissing]}",
_mailtekst(item, f"Je aanvraag <b>{item['Titel']}</b> {woorden[beslissing]}.", link,
f"Commentaar: {tekst}" if tekst else None))
if beslissing == 'goedgekeurd':
_mail_async(secretariaat_adressen(), f"Goedgekeurd: {soort['label'].lower()} {item['Naam']} ({item['Van_datum']:%d/%m})",
_mailtekst(item, f"Een {soort['label'].lower()} is goedgekeurd en staat nu in het dagoverzicht.", link))
def mail_antwoord(item, tekst, link):
_mail_async(directie(), f"Antwoord van {item['Naam']}: {item['Titel']}",
_mailtekst(item, "De leerkracht heeft geantwoord op je vraag; de aanvraag wacht opnieuw op je beslissing.",
link, tekst))
def mail_geannuleerd(item, link):
ontvangers = secretariaat_adressen() if item['Status'] in ('gemeld', 'goedgekeurd') else []
if SOORTEN[item['Soort']]['goedkeuring']:
ontvangers = ontvangers + directie()
_mail_async(ontvangers, f"Geannuleerd: {SOORTEN[item['Soort']]['label'].lower()} {item['Naam']} ({item['Van_datum']:%d/%m})",
_mailtekst(item, "Deze melding/aanvraag is geannuleerd.", link))
+193
View File
@@ -0,0 +1,193 @@
"""
Smartschool › SGR groepen: de subgroepen van één hoofdgroep (standaard 'groepen GO-Antwerpen') met hun huidige leden.
Werking (Smartschool-webservices V3):
1. getAllGroupsAndClasses → de volledige boom van groepen en klassen (XML, meestal base64-gecodeerd)
2. de hoofdgroep zoeken op naam of code, en alle groepen eronder verzamelen (ook dieper geneste)
3. getAllAccountsExtended(code, '0') → de rechtstreekse leden van elke (sub)groep
Het resultaat wordt 10 minuten per school bewaard (knop 'Opnieuw ophalen' om te vernieuwen).
"""
import base64
import json
import threading
import time
import xml.etree.ElementTree as ET
from concurrent.futures import ThreadPoolExecutor
from config import Config
STANDAARD_GROEP = 'groepen GO-Antwerpen'
CACHE_SECONDEN = 10 * 60
ROLLEN = {'1': 'Leerling', '2': 'Leerkracht', '3': 'Directie', '4': 'Andere', '5': 'Andere', '6': 'Andere'}
class SgrFout(Exception):
pass
def _foutcode(raw):
return isinstance(raw, (int, float)) or (isinstance(raw, str) and raw.strip().lstrip('-').isdigit())
def _tekst(raw):
"""Antwoord van Smartschool → tekst (base64 wordt gedecodeerd als dat XML/JSON oplevert)."""
if isinstance(raw, bytes):
raw = raw.decode('utf-8', 'replace')
t = str(raw or '').strip()
if t and t[0] not in '<{[':
try:
d = base64.b64decode(t, validate=False).decode('utf-8', 'replace').strip()
if d[:1] in ('<', '{', '['):
return d
except Exception:
pass
return t
def _kind(el, *namen):
for n in namen:
for c in el:
if c.tag.lower() == n:
return c
return None
def _veld(el, *namen):
c = _kind(el, *namen)
return (c.text or '').strip() if c is not None and c.text else ''
def _groepen_xml(root):
"""XML-boom → [{'naam','code','omschrijving','type','kinderen':[...]}]."""
def groep(el):
details = _kind(el, 'details')
d = details if details is not None else el
kids_el = _kind(el, 'children')
kinderen = [groep(c) for c in (kids_el if kids_el is not None else el) if c.tag.lower() == 'group']
return {'naam': _veld(d, 'name', 'naam'), 'code': _veld(d, 'code'), 'omschrijving': _veld(d, 'desc', 'description'),
'type': _veld(d, 'type'), 'kinderen': kinderen}
if root.tag.lower() == 'group':
return [groep(root)]
return [groep(c) for c in root if c.tag.lower() == 'group']
def _groepen_json(data):
def groep(g):
kids = g.get('children') or g.get('groups') or []
if isinstance(kids, dict):
kids = kids.get('group') or list(kids.values())
if isinstance(kids, dict):
kids = [kids]
d = g.get('details') or g
return {'naam': str(d.get('name') or ''), 'code': str(d.get('code') or ''), 'omschrijving': str(d.get('desc') or ''),
'type': str(d.get('type') or ''), 'kinderen': [groep(k) for k in kids if isinstance(k, dict)]}
if isinstance(data, dict):
data = data.get('groups') or data.get('group') or [data]
if isinstance(data, dict):
data = [data]
return [groep(g) for g in data if isinstance(g, dict)]
def boom(ss):
raw = ss.client.service.getAllGroupsAndClasses(Config.SS_API_PW)
if _foutcode(raw):
raise SgrFout(f"Smartschool weigert de groepenlijst (foutcode {raw}). Heeft het webservice-wachtwoord het recht "
"'getAllGroupsAndClasses'?")
t = _tekst(raw)
try:
return _groepen_xml(ET.fromstring(t)) if t.startswith('<') else _groepen_json(json.loads(t))
except Exception as e:
raise SgrFout(f"Kon de groepenlijst van Smartschool niet lezen: {e}")
def zoek(groepen, naam):
"""Eerste groep (diepte-eerst) waarvan naam of code overeenkomt (hoofdletterongevoelig)."""
n = (naam or '').strip().lower()
for g in groepen:
if g['naam'].lower() == n or g['code'].lower() == n:
return g
hit = zoek(g['kinderen'], naam)
if hit:
return hit
return None
def plat(groep, pad=()):
"""Alle groepen onder `groep` (niet de groep zelf), met hun pad en diepte, in boomvolgorde."""
uit = []
for k in sorted(groep['kinderen'], key=lambda g: g['naam'].lower()):
uit.append({**k, 'pad': list(pad), 'diepte': len(pad)})
uit += plat(k, pad + (k['naam'],))
return uit
def _leden(ss, code, recursief='0'):
raw = ss.client.service.getAllAccountsExtended(Config.SS_API_PW, code, recursief)
if _foutcode(raw):
raise SgrFout(f"foutcode {raw}")
t = _tekst(raw)
if not t:
return []
data = json.loads(t)
if isinstance(data, dict):
data = next((data[k] for k in ('accounts', 'users', 'items') if isinstance(data.get(k), list)), list(data.values()))
uit = []
for a in data or []:
if not isinstance(a, dict):
continue
rol = str(a.get('basisrol') or a.get('basisRol') or '').strip()
uit.append({'voornaam': str(a.get('voornaam') or '').strip(), 'naam': str(a.get('naam') or '').strip(),
'gebruikersnaam': str(a.get('gebruikersnaam') or '').strip(),
'rol': ROLLEN.get(rol, rol), 'email': str(a.get('emailadres') or a.get('email') or '').strip(),
'status': str(a.get('status') or '').strip()})
return sorted(uit, key=lambda x: (x['naam'].lower(), x['voornaam'].lower()))
def overzicht(hoofdgroep=None, vernieuw=False):
"""(hoofdgroep-dict, [subgroepen met 'leden' of 'fout'], opgehaald-tijdstip)."""
from tenancy import huidige_school, in_school
from services.smartschool_api import SmartschoolService
school = huidige_school()
if not school.heeft('SS_API_PLATFORM', 'SS_API_PW'):
raise SgrFout("Smartschool is nog niet ingesteld voor deze school (🔌 Koppelingen › Smartschool).")
hoofdgroep = (hoofdgroep or school.get('SS_SGR_GROEP') or STANDAARD_GROEP).strip()
sleutel = ('sgr_groepen', hoofdgroep.lower())
bewaard = school.cache.get(sleutel)
if bewaard and not vernieuw and time.time() - bewaard[0] < CACHE_SECONDEN:
return bewaard[1], bewaard[2], bewaard[0]
try:
ss = SmartschoolService(Config.SS_API_PLATFORM, Config.SS_API_PW)
except Exception as e:
raise SgrFout(f"Smartschool is niet bereikbaar: {e}")
hoofd = zoek(boom(ss), hoofdgroep)
if not hoofd:
raise SgrFout(f"De groep '{hoofdgroep}' bestaat niet in Smartschool ({Config.SS_API_PLATFORM}).")
groepen = plat(hoofd)
lokaal = threading.local()
def haal(g):
with in_school(school):
if not hasattr(lokaal, 'ss'):
lokaal.ss = SmartschoolService(Config.SS_API_PLATFORM, Config.SS_API_PW)
try:
if not g['code']:
return {**g, 'leden': [], 'fout': 'geen code'}
leden = _leden(lokaal.ss, g['code'])
if not leden and not g['kinderen']:
# Groep zonder subgroepen: rechtstreeks = recursief; sommige platformen geven enkel zo de leden
leden = _leden(lokaal.ss, g['code'], '1')
return {**g, 'leden': leden, 'fout': None}
except Exception as e:
return {**g, 'leden': [], 'fout': str(e)[:200]}
with ThreadPoolExecutor(max_workers=4) as pool:
resultaat = list(pool.map(haal, groepen))
for g in resultaat:
g.pop('kinderen', None)
g['aantal_sub'] = sum(1 for x in resultaat if x['pad'][:len(g['pad']) + 1] == g['pad'] + [g['naam']])
info = {'naam': hoofd['naam'], 'code': hoofd['code'], 'omschrijving': hoofd['omschrijving']}
nu = time.time()
school.cache[sleutel] = (nu, info, resultaat)
return info, resultaat, nu
+140
View File
@@ -0,0 +1,140 @@
import requests
from config import Config
BASE_DATA_URL = "https://byod-api.signpost.be/v1"
def get_signpost_token():
"""Token met de Signpost/M4S-gegevens van de huidige school (None bij een fout)."""
return signpost_token_met_reden()[0]
def signpost_token_met_reden():
"""Geeft (token, None) of (None, leesbare foutmelding)."""
if not (Config.M4S_CLIENT_ID and Config.M4S_CLIENT_SECRET):
return None, ("Signpost is nog niet ingesteld voor deze school: vul de client-ID en het client-secret in "
"via 🔌 Koppelingen van deze school › Signpost / M4S.")
auth_payload = {
"grant_type": "client_credentials",
"client_id": Config.M4S_CLIENT_ID,
"client_secret": Config.M4S_CLIENT_SECRET,
"scope": "openid",
"resource": "management_portal:api"
}
try:
response = requests.post(Config.M4S_AUTH_URL, data=auth_payload, timeout=15)
except requests.exceptions.RequestException as e:
return None, f"Signpost ({Config.M4S_AUTH_URL}) is niet bereikbaar: {e}"
if response.status_code in (400, 401, 403):
detail = ''
try:
j = response.json()
detail = j.get('error_description') or j.get('error') or ''
except ValueError:
pass
return None, (f"Signpost weigert de aanmelding (HTTP {response.status_code}{': ' + detail if detail else ''}). "
"Controleer de client-ID en het client-secret bij 🔌 Koppelingen van deze school.")
if not response.ok:
return None, f"Signpost gaf foutcode {response.status_code} bij het aanmelden."
token = response.json().get("access_token")
return (token, None) if token else (None, "Signpost gaf geen token terug.")
#-------------------------------------------------------------------------------------------------
#herstelling signpost doorgeven
#-------------------------------------------------------------------------------------------------
def create_signpost_case(serial_number, description, damage_flags=None):
"""
Maakt een nieuw herstellingsdossier aan bij Signpost.
:param serial_number: Het serienummer van het toestel.
:param description: Omschrijving van het probleem.
:param damage_flags: Dict met booleans (bv. {'waterDamage': True})
"""
token = get_signpost_token()
if not token:
return False, "Authenticatie mislukt."
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "application/json"
}
# Bouw het payload op basis van de Config klasse
payload = {
"languageCode": getattr(Config, "M4S_LANGUAGE_CODE", "nl-BE"),
"description": description,
"status": getattr(Config, "M4S_REPAIR_STATUS", "Nieuw"),
"repairLocationType": int(getattr(Config, "M4S_REPAIR_LOCATION_TYPE", 1)),
"serialNumber": serial_number,
"institutionGuid": Config.M4S_INSTITUTIONGUID,
"address": {
"street": Config.M4S_REPAIR_STREET,
"number": Config.M4S_REPAIR_STREETNUMBER,
"zipCode": Config.M4S_REPAIR_ZIPCODE,
"city": Config.M4S_REPAIIR_CITY,
"countryCode": Config.M4S_REPAIR_COUNTRYCODE,
"repairAtInstitution": True
},
"contacts": [{
"firstName": Config.M4S_CONTACT_FIRST_NAME,
"lastName": Config.M4S_CONTACT_LAST_NAME,
"email": Config.M4S_CONTACT_EMAIL,
"phone": Config.M4S_CONTACT_PHONE
}],
"billingContact": {
"firstName": Config.M4S_BILLING_FIRST_NAME,
"lastName": Config.M4S_BILLING_LAST_NAME,
"email": Config.M4S_BILLING_EMAIL,
"phone": Config.M4S_BILLING_PHONE,
"street": Config.M4S_REPAIR_STREET,
"number": Config.M4S_REPAIR_STREETNUMBER,
"zipCode": Config.M4S_REPAIR_ZIPCODE,
"city": Config.M4S_REPAIIR_CITY,
"countryCode": Config.M4S_REPAIR_COUNTRYCODE
}
}
# Voeg schade-indicatoren toe indien meegegeven
if damage_flags:
payload.update(damage_flags)
try:
res = requests.post(f"{BASE_DATA_URL}/field-service/cases", headers=headers, json=payload, timeout=15)
if res.status_code in [200, 201]:
return True, res.json().get("ourReference", "Succesvol aangemaakt")
else:
return False, f"API Fout {res.status_code}: {res.text}"
except Exception as e:
return False, f"Verbindingsfout: {str(e)}"
#-------------------------------------------------------------------------------------------------
#
#-------------------------------------------------------------------------------------------------
def get_cases_by_serial(serial_number):
token = get_signpost_token()
if not token:
return []
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json"
}
params = {"$search": serial_number}
try:
res = requests.get(f"{BASE_DATA_URL}/field-service/cases", headers=headers, params=params, timeout=15)
if res.status_code == 200:
return res.json().get("items", [])
return []
except Exception as e:
print(f"Signpost API Fout: {e}")
return []
+326
View File
@@ -0,0 +1,326 @@
"""
Skore-synchronisatie in Smartschool (klassen en leerlingen van Smartschool → Skore).
Smartschool-webservices:
startSkoreSync(accesscode) → start de synchronisatie, geeft een taak-ID (serviceId) terug
checkStatus(accesscode, serviceId) → status van die taak
Per school:
- skore_planning : één rij met de planning (uit / dagelijks / weekdagen / wekelijks / maandelijks + uur)
- skore_log : elke gestarte synchronisatie (manueel of gepland) met taak-ID en laatste status
Eén achtergrondtaak overloopt elke minuut alle scholen en start de synchronisatie wanneer de planning dat vraagt.
"""
import json
import sys
import threading
import time
from datetime import datetime, timedelta
from config import Config
from database import execute_query, execute_insert
from tenancy import alle_scholen, in_school
FREQUENTIES = [
('uit', 'Niet automatisch (enkel manueel)'),
('dagelijks', 'Elke dag'),
('weekdagen', 'Elke weekdag (ma–vr)'),
('wekelijks', 'Eén keer per week'),
('maandelijks', 'Eén keer per maand'),
]
WEEKDAGEN = ['maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag']
# Een geplande synchronisatie die gemist werd (PIVOT stond uit) wordt nog ingehaald tot zoveel uur later.
INHAAL_UREN = 6
# Statuscodes van checkStatus die we kennen (vastgesteld: 2 = "SYNC IS COMPLETED")
STATUSCODES = {2: 'voltooid'}
# Automatische opvolging na een start: elke 30 s de status opvragen, maximaal 45 minuten
OPVOLG_INTERVAL = 30
OPVOLG_MAX = 45 * 60
EINDSTATUS = ('voltooid', 'mislukt')
TABELLEN = [
"""CREATE TABLE IF NOT EXISTS skore_planning (
ID TINYINT NOT NULL PRIMARY KEY,
Frequentie VARCHAR(20) NOT NULL DEFAULT 'uit',
Uur TINYINT NOT NULL DEFAULT 6,
Minuut TINYINT NOT NULL DEFAULT 0,
Weekdag TINYINT NOT NULL DEFAULT 0,
Dag_van_maand TINYINT NOT NULL DEFAULT 1,
Laatst_gepland DATETIME NULL,
Bijgewerkt DATETIME NULL,
Bijgewerkt_door VARCHAR(255) NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""",
"""CREATE TABLE IF NOT EXISTS skore_log (
ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
Gestart DATETIME NOT NULL,
Door VARCHAR(255) NULL,
Manier VARCHAR(20) NOT NULL,
Service_ID VARCHAR(100) NULL,
Status VARCHAR(30) NOT NULL,
Melding TEXT NULL,
Bijgewerkt DATETIME NULL,
INDEX idx_skore_gestart (Gestart)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""",
]
class SkoreFout(RuntimeError):
pass
def ensure_tables():
for sql in TABELLEN:
execute_query(sql, commit=True)
execute_query("INSERT IGNORE INTO skore_planning (ID) VALUES (1)", commit=True)
# =====================================================================
# Smartschool
# =====================================================================
def _client():
if not (Config.SS_API_PLATFORM and Config.SS_API_PW):
raise SkoreFout("Smartschool is nog niet ingesteld voor deze school (platform en webservice-wachtwoord "
"via 🔌 Koppelingen van deze school).")
from services.smartschool_api import SmartschoolService
return SmartschoolService(Config.SS_API_PLATFORM, Config.SS_API_PW).client
def _json(raw):
"""Smartschool geeft JSON als tekst terug; geeft een dict (of None)."""
if isinstance(raw, dict):
return raw
try:
data = json.loads(raw) if isinstance(raw, str) and raw.strip().startswith('{') else None
except ValueError:
data = None
return data if isinstance(data, dict) else None
def service_id_uit(raw):
"""Taak-ID uit het antwoord van startSkoreSync, bv. {"serviceId":"db18…","message":"SERVICE STARTED"}.
Werkt ook voor oude logregels waarin het volledige antwoord als ID bewaard werd."""
data = _json(raw)
if data is not None:
return str(data.get('serviceId') or data.get('serviceID') or data.get('id') or '').strip() or None
if raw in (None, ''):
return None
return str(raw).strip() or None
def _is_foutcode(raw):
return isinstance(raw, (int, float)) or (isinstance(raw, str) and raw.strip().lstrip('-').isdigit())
def _foutcode_tekst(client, code):
"""Smartschool geeft bij fouten een getal; probeer de omschrijving op te halen."""
try:
lijst = json.loads(client.service.returnJsonErrorCodes())
tekst = lijst.get(str(code).strip()) if isinstance(lijst, dict) else None
if tekst:
return f"Smartschool-fout {code}: {tekst}"
except Exception:
pass
return (f"Smartschool-fout {code}. Controleer of het webservice-wachtwoord het recht heeft om de "
"Skore-synchronisatie te starten (Smartschool › Beheer › Webservices).")
def _status_uit(raw):
"""Zet het antwoord van checkStatus om naar (status, melding).
Smartschool antwoordt met {"status": <getal>, "message": <tekst of null>}. Wat de getallen betekenen is niet
publiek gedocumenteerd; daarom leiden we 'voltooid' of 'mislukt' enkel af uit de tekst, en tonen we anders
de statuscode zelf (bv. 'code 0')."""
tekst = raw if isinstance(raw, str) else json.dumps(raw, default=str) if raw is not None else ''
data = _json(raw)
code = data.get('status') if data else None
bericht = str((data or {}).get('message') or (data or {}).get('state') or '') if data else tekst
laag = bericht.lower()
if any(w in laag for w in ('error', 'fout', 'failed', 'mislukt')):
status = 'mislukt'
elif any(w in laag for w in ('done', 'finished', 'completed', 'klaar', 'voltooid', 'success')):
status = 'voltooid'
elif any(w in laag for w in ('running', 'busy', 'bezig', 'started', 'queue', 'wacht')):
status = 'bezig'
elif code is not None and str(code).strip().isdigit() and int(code) in STATUSCODES:
status = STATUSCODES[int(code)]
elif code is not None:
status = f"code {code}"
else:
status = 'onbekend'
melding = f"Smartschool: status {code}" + (f", {bericht}" if bericht else " (geen bericht)") if data else tekst
return status[:30], melding[:2000]
def start(door, manier='manueel'):
"""Start de Skore-synchronisatie. Geeft de rij uit skore_log terug."""
ensure_tables()
nu = datetime.now()
try:
client = _client()
raw = client.service.startSkoreSync(Config.SS_API_PW)
if _is_foutcode(raw) and str(raw).strip() not in ('', '0'):
raise SkoreFout(_foutcode_tekst(client, raw))
service_id = service_id_uit(raw)
bericht = (_json(raw) or {}).get('message')
if not service_id:
raise SkoreFout(f"Smartschool startte de synchronisatie niet: {bericht or raw}")
status = 'gestart'
melding = f"Synchronisatie gestart bij Smartschool ({bericht})." if bericht else "Synchronisatie gestart bij Smartschool."
except SkoreFout as e:
service_id, status, melding = None, 'mislukt', str(e)
except Exception as e:
service_id, status, melding = None, 'mislukt', f"Smartschool niet bereikbaar: {e}"
log_id = execute_insert(
"INSERT INTO skore_log (Gestart, Door, Manier, Service_ID, Status, Melding, Bijgewerkt) "
"VALUES (%s, %s, %s, %s, %s, %s, %s)", (nu, door, manier, service_id, status, melding, nu))
rij = execute_query("SELECT * FROM skore_log WHERE ID = %s", (log_id,))[0]
if rij['Service_ID']:
volg_op(log_id)
return rij
def volg_op(log_id):
"""Vraagt op de achtergrond de status op tot de synchronisatie klaar of mislukt is."""
from tenancy import met_school
def loop():
begin = time.time()
while time.time() - begin < OPVOLG_MAX:
time.sleep(OPVOLG_INTERVAL)
try:
rij = controleer(log_id)
except Exception as e:
sys.stderr.write(f"[{Config.SCHOOLNAME_SHORT}] Skore-opvolging: {e}\n")
continue
if not rij or rij['Status'] in EINDSTATUS:
return
threading.Thread(target=met_school(loop), daemon=True).start()
def bezig():
"""True als er een synchronisatie loopt die nog geen eindstatus heeft (laatste 45 minuten)."""
rows = execute_query("SELECT Status, Gestart FROM skore_log WHERE Service_ID IS NOT NULL "
"ORDER BY Gestart DESC, ID DESC LIMIT 1")
return bool(rows) and rows[0]['Status'] not in EINDSTATUS and \
datetime.now() - rows[0]['Gestart'] < timedelta(seconds=OPVOLG_MAX)
def controleer(log_id):
"""Vraagt de status van een gestarte synchronisatie op bij Smartschool en bewaart die."""
rows = execute_query("SELECT * FROM skore_log WHERE ID = %s", (log_id,))
if not rows or not rows[0]['Service_ID']:
return rows[0] if rows else None
try:
client = _client()
sid = service_id_uit(rows[0]['Service_ID'])
if sid != rows[0]['Service_ID']:
execute_query("UPDATE skore_log SET Service_ID = %s WHERE ID = %s", (sid, log_id), commit=True)
raw = client.service.checkStatus(Config.SS_API_PW, sid)
if _is_foutcode(raw) and str(raw).strip() not in ('', '0'):
status, melding = 'onbekend', _foutcode_tekst(client, raw)
else:
status, melding = _status_uit(raw)
except Exception as e:
status, melding = rows[0]['Status'], f"Status opvragen mislukt: {e}"
execute_query("UPDATE skore_log SET Status = %s, Melding = %s, Bijgewerkt = %s WHERE ID = %s",
(status, melding, datetime.now(), log_id), commit=True)
return execute_query("SELECT * FROM skore_log WHERE ID = %s", (log_id,))[0]
def logboek(n=25):
ensure_tables()
return execute_query("SELECT * FROM skore_log ORDER BY Gestart DESC, ID DESC LIMIT %s", (n,))
# =====================================================================
# Planning
# =====================================================================
def planning():
ensure_tables()
return execute_query("SELECT * FROM skore_planning WHERE ID = 1")[0]
def bewaar_planning(frequentie, uur, minuut, weekdag, dag_van_maand, door):
if frequentie not in dict(FREQUENTIES):
raise ValueError("Onbekende frequentie.")
uur, minuut, weekdag, dag = int(uur), int(minuut), int(weekdag), int(dag_van_maand)
if not (0 <= uur <= 23 and 0 <= minuut <= 59 and 0 <= weekdag <= 6 and 1 <= dag <= 28):
raise ValueError("Ongeldig tijdstip of ongeldige dag.")
ensure_tables()
# Laatst_gepland op 'nu' zetten: een planning die je net aanpast, start niet meteen voor een moment in het verleden
execute_query(
"UPDATE skore_planning SET Frequentie = %s, Uur = %s, Minuut = %s, Weekdag = %s, Dag_van_maand = %s, "
"Laatst_gepland = %s, Bijgewerkt = %s, Bijgewerkt_door = %s WHERE ID = 1",
(frequentie, uur, minuut, weekdag, dag, datetime.now(), datetime.now(), door), commit=True)
def laatste_moment(p, nu):
"""Het laatste geplande moment op of vóór `nu` volgens planning `p` (of None als 'uit')."""
f = p['Frequentie']
if f == 'uit':
return None
for terug in range(0, 62):
dag = (nu - timedelta(days=terug)).date()
moment = datetime(dag.year, dag.month, dag.day, int(p['Uur']), int(p['Minuut']))
if moment > nu:
continue
if f == 'dagelijks' \
or (f == 'weekdagen' and dag.weekday() < 5) \
or (f == 'wekelijks' and dag.weekday() == int(p['Weekdag'])) \
or (f == 'maandelijks' and dag.day == int(p['Dag_van_maand'])):
return moment
return None
def volgende_moment(p, nu=None):
"""Het eerstvolgende geplande moment na `nu` (voor op de pagina)."""
nu = nu or datetime.now()
if p['Frequentie'] == 'uit':
return None
for vooruit in range(0, 62):
dag = (nu + timedelta(days=vooruit)).date()
moment = datetime(dag.year, dag.month, dag.day, int(p['Uur']), int(p['Minuut']))
if moment > nu and laatste_moment(p, moment) == moment:
return moment
return None
def geplande_controle(nu=None):
"""Start de synchronisatie als er een gepland moment voorbij is dat nog niet uitgevoerd werd."""
nu = nu or datetime.now()
p = planning()
moment = laatste_moment(p, nu)
if not moment or nu - moment > timedelta(hours=INHAAL_UREN):
return None
if p['Laatst_gepland'] and p['Laatst_gepland'] >= moment:
return None
execute_query("UPDATE skore_planning SET Laatst_gepland = %s WHERE ID = 1", (nu,), commit=True)
rij = start('automatisch', manier='gepland')
print(f"[{Config.SCHOOLNAME_SHORT}] Skore-synchronisatie (gepland): {rij['Status']} – {rij['Melding']}", flush=True)
return rij
_scheduler = None
def start_scheduler():
"""Eén achtergrondtaak die elke minuut alle scholen met een Smartschool-koppeling overloopt."""
global _scheduler
if _scheduler:
return
def loop():
time.sleep(45) # app eerst laten opstarten
while True:
for school in alle_scholen():
if not school.heeft('SS_API_PLATFORM', 'SS_API_PW'):
continue
try:
with in_school(school):
geplande_controle()
except Exception as e:
sys.stderr.write(f"[{school.code}] Skore-planning: {e}\n")
time.sleep(60)
_scheduler = threading.Thread(target=loop, daemon=True)
_scheduler.start()
+122
View File
@@ -0,0 +1,122 @@
import zeep
import json
class SmartschoolService:
def __init__(self, platform, webservice_pwd):
#-----------------------------------
clean_platform = platform.replace("https://", "").replace("http://", "").split('/')[0]
self.platform = clean_platform
#------------------------------
#self.platform = platform
self.webservice_pwd = webservice_pwd
self.wsdl_url = f"https://{self.platform}/Webservices/V3?wsdl"
self.client = zeep.Client(wsdl=self.wsdl_url)
def get_student_details(self, search_term):
"""Zoekt een student op basis van internnummer of gebruikersnaam."""
try:
# Poging 1: Via internnummer
raw_result = self.client.service.getUserDetails(self.webservice_pwd, search_term)
if raw_result and isinstance(raw_result, str) and raw_result.startswith('{'):
return json.loads(raw_result)
# Poging 2: Via gebruikersnaam
raw_result_alt = self.client.service.getUserDetailsByUsername(self.webservice_pwd, search_term)
if raw_result_alt and isinstance(raw_result_alt, str) and raw_result_alt.startswith('{'):
return json.loads(raw_result_alt)
return None
except Exception as e:
print(f"Service Error (GetDetails): {e}")
return None
def get_co_accounts_from_details(self, username):
"""Haalt co-accounts op en onthoudt hun index (1-6) voor wachtwoordresets."""
try:
raw_result = self.client.service.getUserDetailsByUsername(self.webservice_pwd, username)
if raw_result and isinstance(raw_result, str) and raw_result.startswith('{'):
data = json.loads(raw_result)
co_accounts = []
for i in range(1, 7):
voornaam = data.get(f"voornaam_coaccount{i}")
if voornaam:
co_accounts.append({
"voornaam": voornaam,
"naam": data.get(f"naam_coaccount{i}", ""),
"type": data.get(f"type_coaccount{i}", "Co-account"),
"index": i, # Belangrijk voor savePassword
"parent_internnummer": data.get("internnummer") # Nodig voor de reset actie
})
return co_accounts
return []
except Exception as e:
print(f"Service Error (Co-Accounts): {e}")
return []
def reset_password(self, identifier, new_password, account_type=0):
"""
account_type: 0 voor leerling, 1-6 voor co-accounts.
"""
try:
# Gebruik de parameters uit je werkende voorbeeld
result_code = self.client.service.savePassword(
self.webservice_pwd,
identifier,
new_password,
account_type,
0
)
return str(result_code)
except Exception as e:
print(f"Service Error (ResetPassword): {e}")
return "ERROR"
def get_co_accounts_with_login(self, username):
"""Haalt co-accounts op met de exacte velden uit de Smartschool JSON."""
try:
# Gebruik de bestaande methode om de JSON op te halen
raw_result = self.client.service.getUserDetailsByUsername(self.webservice_pwd, username)
if raw_result and isinstance(raw_result, str) and raw_result.startswith('{'):
data = json.loads(raw_result)
co_accounts = []
# Smartschool ondersteunt tot 6 co-accounts
for i in range(1, 7):
voornaam = data.get(f"voornaam_coaccount{i}")
# Alleen toevoegen als er daadwerkelijk een voornaam is ingevuld (account bestaat)
if voornaam and voornaam.strip() != "":
# Gebruik het specifieke veld uit jouw JSON voorbeeld
last_login = data.get(f"last_successful_login_coaccount{i}")
co_accounts.append({
"voornaam": voornaam,
"naam": data.get(f"naam_coaccount{i}", ""),
"type": data.get(f"type_coaccount{i}", "Onbekend"),
"last_login": last_login if last_login else "Nooit ingelogd",
"email": data.get(f"email_coaccount{i}", ""),
"index": i
})
return co_accounts
return []
except Exception as e:
print(f"Fout bij ophalen co-account status: {e}")
return []
def get_coaccount_report(self, search_query):
"""
Bundelt alle logica: zoekt student, formatteert naam en haalt co-accounts op.
Geeft een tuple terug: (student_naam, resultaten_lijst)
"""
student_data = self.get_student_details(search_query)
if not student_data:
return None, []
student_name = f"{student_data.get('voornaam')} {student_data.get('naam')}"
username = student_data.get('gebruikersnaam')
results = self.get_co_accounts_with_login(username)
return student_name, results
+549
View File
@@ -0,0 +1,549 @@
import json
import requests
from datetime import datetime, timezone # Essentieel voor de timestamp
from dateutil.relativedelta import relativedelta
from config import Config
from database import execute_query
# Informat geeft de volledige leerlingenlijst soms pas na een minuut of langer terug.
# (verbinden, lezen) in seconden
INFORMAT_TIMEOUT = (15, 300)
ONEROSTER_TIMEOUT = (15, 120)
def informat_get(url, headers, pogingen=2):
"""GET naar Informat met ruime wachttijd; bij een time-out nog één keer opnieuw."""
for poging in range(1, pogingen + 1):
try:
return requests.get(url, headers=headers, timeout=INFORMAT_TIMEOUT)
except requests.exceptions.Timeout:
if poging == pogingen:
raise RuntimeError(
f"Informat antwoordde niet binnen {INFORMAT_TIMEOUT[1] // 60} minuten "
f"({pogingen} pogingen). Probeer het later opnieuw; Informat is soms traag of in onderhoud.")
print(f"[Informat] time-out, nieuwe poging ({poging + 1}/{pogingen})...")
def zorg_vertrokken_kolom():
"""Kolom Leerlingen.Vertrokken_op toevoegen in een bestaande schooldatabase (één keer per school)."""
try:
from tenancy import huidige_school
school = huidige_school()
except Exception:
school = None
if school is not None and school.cache.get('lln_vertrokken_kolom'):
return
kolommen = {r['Field'].lower() for r in execute_query("SHOW COLUMNS FROM Leerlingen") or []}
if 'vertrokken_op' not in kolommen:
execute_query("ALTER TABLE Leerlingen ADD COLUMN Vertrokken_op DATE NULL AFTER Schooljaar_ID", commit=True)
if school is not None:
school.cache['lln_vertrokken_kolom'] = True
def veld_waarden(obj, pad):
"""Alle waarden op een pad met punten, ook door lijsten heen: veld_waarden(st, 'inschrijvingen.vestiging')."""
huidig = [obj]
for deel in pad.split('.'):
volgende = []
for o in huidig:
items = o if isinstance(o, list) else [o]
for it in items:
if isinstance(it, dict) and deel in it:
volgende.append(it[deel])
huidig = volgende
uit = []
for h in huidig:
uit.extend(h if isinstance(h, list) else [h])
return uit
class SyncService:
def __init__(self):
self.conf = Config()
# Informat-gegevens van de huidige school (beheerd via /scholen).
# Een school kan meerdere Informat-instellingsnummers hebben (één per campus).
self.campussen = Config.INFORMAT_CAMPUSSEN or []
self.institute_no = Config.INFORMAT_INST_NUMBER
self.auth_url = Config.INFORMAT_AUTH_URL
self.api_base = Config.INFORMAT_API_BASE
# De scope MOET het instellingsnummer bevatten
self.scope = Config.INFORMAT_FULL_SCOPE
def get_informat_token(self, nummer=None):
"""Haalt een token op met de scope van het (eerste of opgegeven) instellingsnummer."""
from tenancy import informat_scope
scope = informat_scope(Config.INFORMAT_SCOPE, nummer) if nummer else self.scope
payload = {
'grant_type': 'client_credentials',
'client_id': self.conf.INFORMAT_ID,
'client_secret': self.conf.INFORMAT_SECRET,
'scope': scope
}
try:
response = requests.post(self.auth_url, data=payload, timeout=10)
if response.status_code == 200:
return response.json().get('access_token')
except Exception:
return None
return None
def tel_leerlingen(self, refdate, schooljaar, nummer=None):
"""Aantal leerlingen in Informat op `refdate` (YYYY-MM-DD) voor `schooljaar` (bv. 2026-27), voor één
instellingsnummer (standaard het eerste). Geeft (True, aantal) of (False, foutmelding)."""
nummer = nummer or self.institute_no
token = self.get_informat_token(nummer)
if not token:
return False, f"Authenticatie bij Informat mislukt (instellingsnummer {nummer})."
url = f"{self.api_base}/students/?schoolYear={schooljaar}&refdate={refdate}"
headers = {
'Authorization': f'BEARER {token}',
'InstituteNo': nummer,
'Timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000Z'), # verplicht bij Informat
'Api-Version': '1',
'Accept': 'application/json'
}
try:
response = informat_get(url, headers)
except Exception as e:
return False, f"Fout tijdens tellen: {e}"
if response.status_code != 200:
return False, f"API fout {response.status_code} (instellingsnummer {nummer}): {response.text[:300]}"
data = response.json()
lijst = data if isinstance(data, list) else data.get('students', [])
self._laatste_lijst = lijst # voor tellen per campusveld en de veldentest
return True, len(lijst)
def informat_lijst(self, refdate, schooljaar, nummer=None):
"""De volledige leerlingenlijst van Informat (zelfde oproep als het tellen). (True, lijst) of (False, fout)."""
ok, res = self.tel_leerlingen(refdate, schooljaar, nummer)
return (True, self._laatste_lijst) if ok else (False, res)
def tel_locatie(self, refdate, schooljaar, locatie_id):
"""Aantal leerlingen op één schoollocatie (Discimus-nummer) via de Informat Student API
(GET /smartschool-users). Geeft (True, aantal) of (False, foutmelding).
Let op: deze API telt enkel leerlingen met een actieve inschrijving (gerealiseerd of parallel, niet GON)
op een locatie die aan het Smartschool-platform gekoppeld is."""
platform = (Config.SS_API_PLATFORM or '').strip()
if not platform:
return False, ("Voor het tellen per campus moet het Smartschool-platform ingevuld zijn "
"(🔌 Koppelingen › Smartschool).")
token = self.get_informat_token(self.institute_no)
if not token:
return False, f"Authenticatie bij Informat mislukt (instellingsnummer {self.institute_no})."
url = f"{str(Config.INFORMAT_STUDENT_API).rstrip('/')}/smartschool-users"
headers = {'Authorization': f'Bearer {token}', 'instituteNo': self.institute_no, 'Accept': 'application/json'}
# Informat vergelijkt het platformadres letterlijk met wat er in Informat staat. Dat kan met of zonder
# https:// of een / op het einde opgeslagen zijn: probeer de varianten tot er één gekend is (en onthoud die).
varianten = list(dict.fromkeys([v for v in (getattr(self, '_ss_platform_url', None), f"https://{platform}",
platform, f"https://{platform}/", f"http://{platform}") if v]))
response, geprobeerd = None, []
for platform_url in varianten:
params = {'smartschoolPlatformUrl': platform_url, 'schoolYear': schooljaar,
'locationIdDiscimus': int(locatie_id) if str(locatie_id).isdigit() else locatie_id,
'referenceDate': refdate}
try:
for poging in (1, 2):
try:
response = requests.get(url, params=params, headers=headers, timeout=INFORMAT_TIMEOUT)
break
except requests.exceptions.Timeout:
if poging == 2:
raise RuntimeError("Informat antwoordde niet op tijd.")
except Exception as e:
return False, f"Fout tijdens tellen: {e}"
geprobeerd.append(platform_url)
if response.status_code == 409 and 'smartschoolplatform' in response.text.lower():
continue # platform onder dit adres niet gekend: volgende variant
self._ss_platform_url = platform_url
break
if response.status_code != 200:
detail = ''
try:
detail = response.json().get('detail') or ''
except ValueError:
detail = response.text[:200]
if response.status_code == 409 and 'smartschoolplatform' in response.text.lower():
return False, (f"Informat kent het Smartschool-platform niet voor instellingsnummer {self.institute_no} "
f"en schooljaar {schooljaar} (geprobeerd: {', '.join(geprobeerd)}). Is in Informat "
"voor dit instellingsnummer en dit schooljaar een Smartschool-koppeling actief?")
return False, f"Student API fout {response.status_code} (campus {locatie_id}): {detail}"
data = response.json()
lijst = data if isinstance(data, list) else data.get('items', [])
# Eén leerling kan meer dan één inschrijving hebben: uniek op persoon
return True, len({u['ppersoon'] if u.get('ppersoon') is not None else (u.get('persoonId') or id(u))
for u in lijst})
def tel_per_campus(self, refdate, schooljaar):
"""Geeft (True, totaal, detail) of (False, fout, None). detail = [{'naam', 'nummer', 'aantal'}] als de
school meerdere campussen heeft, anders None.
- Campussen (Discimus-nummers) ingesteld → per locatie geteld via de Student API.
- Anders: per instellingsnummer (meestal één) via de leerlingen-API zoals voorheen."""
locaties = Config.INFORMAT_LOCATIES or []
veld = (Config.INFORMAT_CAMPUS_VELD or '').strip()
if veld:
return self._tel_op_veld(refdate, schooljaar, veld, dict(locaties))
if locaties:
telling = [(loc, naam, lambda loc=loc: self.tel_locatie(refdate, schooljaar, loc)) for loc, naam in locaties]
else:
campussen = self.campussen or [(self.institute_no, '')]
telling = [(nr, naam, lambda nr=nr: self.tel_leerlingen(refdate, schooljaar, nr)) for nr, naam in campussen]
detail, totaal = [], 0
for nummer, naam, tel in telling:
ok, res = tel()
if not ok:
return False, (f"{naam}: {res}" if len(telling) > 1 else res), None
detail.append({'naam': naam or nummer, 'nummer': nummer, 'aantal': res})
totaal += res
return True, totaal, (detail if len(detail) > 1 else None)
def _tel_op_veld(self, refdate, schooljaar, veld, namen):
"""Haalt de leerlingenlijst één keer op en telt per waarde van `veld` (pad met punten, bv.
'inschrijvingen.vestiging'). `namen` vertaalt een waarde naar een campusnaam (uit 'Campussen')."""
lijst, gezien = [], set()
for nummer, naam in (self.campussen or [(self.institute_no, '')]): # alle instellingsnummers van de school
ok, deel = self.informat_lijst(refdate, schooljaar, nummer)
if not ok:
return False, (f"{naam}: {deel}" if len(self.campussen) > 1 else deel), None
for st in deel:
sleutel = str(st.get('pPersoon') or st.get('id') or id(st))
if sleutel not in gezien:
gezien.add(sleutel)
lijst.append(st)
per = {}
for st in lijst:
for w in set(str(v) for v in veld_waarden(st, veld) if v not in (None, '')) or {'?'}:
per[w] = per.get(w, 0) + 1
volgorde = [w for w in namen if w in per] + sorted(w for w in per if w not in namen)
detail = [{'naam': namen.get(w) or (f"Onbekend ({veld} leeg)" if w == '?' else f"Campus {w}"),
'nummer': '' if w == '?' else w, 'aantal': per[w]} for w in volgorde]
return True, len(lijst), (detail if len(detail) > 1 else None)
def sync_student_count(self, school_naam=None):
"""Huidig aantal (peildatum vandaag + 1 maand, zodat net ingeschreven leerlingen meetellen)."""
refdate = (datetime.now() + relativedelta(months=1)).strftime('%Y-%m-%d')
ok, res, detail = self.tel_per_campus(refdate, Config.SCHOOLYEAR)
if not ok:
return False, res
# Bewaren bij de school in de centrale database (overzicht scholengroep)
from database import central_query
central_query("UPDATE Scholen SET Leerlingenaantal = %s, Leerlingenaantal_detail = %s, "
"Leerlingenaantal_sync = NOW() WHERE Code = %s",
(res, json.dumps(detail) if detail else None, Config.SCHOOLNAME_SHORT), commit=True)
if detail:
return True, f"Synchronisatie voltooid: {res} leerlingen (" + ", ".join(
f"{d['naam']}: {d['aantal']}" for d in detail) + ")."
return True, f"Synchronisatie voltooid: {res} leerlingen."
def tel_op_peildatum(self, peildatum, door=None):
"""Telt de leerlingen op een gekozen datum en bewaart het resultaat in Leerlingenaantal_tellingen."""
from database import central_query
from tenancy import huidige_school, _automatisch_schooljaar
schooljaar = _automatisch_schooljaar(peildatum)
ok, res, detail = self.tel_per_campus(peildatum.strftime('%Y-%m-%d'), schooljaar)
if not ok:
return False, res
central_query(
"INSERT INTO Leerlingenaantal_tellingen (School_ID, Peildatum, Schooljaar, Aantal, Detail, Opgevraagd_op, Door) "
"VALUES (%s, %s, %s, %s, %s, NOW(), %s) "
"ON DUPLICATE KEY UPDATE Schooljaar = VALUES(Schooljaar), Aantal = VALUES(Aantal), Detail = VALUES(Detail), "
"Opgevraagd_op = NOW(), Door = VALUES(Door)",
(huidige_school().id, peildatum, schooljaar, res, json.dumps(detail) if detail else None, door),
commit=True)
return True, res
#Haal ss token op
def get_smartschool_token(self):
"""Haalt een OAuth2 token op bij Smartschool."""
resp_ss = requests.post(
self.conf.SS_TOKEN_URL,
data={"grant_type": "client_credentials"},
auth=(self.conf.SS_CLIENT_ID, self.conf.SS_CLIENT_SECRET)
)
return resp_ss.json().get("access_token")
#Vergelijkt leerlingenlijst uit informat met namen uit DB en vult eventuele ontbrekende leerlingen aan met gegevens van Informat (Internnummer) en Smartschool (Gebruikersnaam)
def sync_leerlingen_informat(self):
"""
Synchroniseert leerlingen door Informat data te vergelijken met de DB
en ontbrekende data aan te vullen via Smartschool.
"""
try:
# 1. Haal Informat data op
inf_token = self.get_informat_token()
#Ik zet als parameter voor het ophalen huidige datum +1 maand om leerlingen die net ingeschreven zijn en pas na een vakantie starten mee te kunnen rekenen
future_date = datetime.now() + relativedelta(months=1)
refdate = future_date.strftime('%Y-%m-%d')
inf_url = f"{self.api_base}/students/?schoolYear={self.conf.INFORMAT_SCHOOLYEAR}&refdate={refdate}"
inf_headers = {
'Authorization': f'BEARER {inf_token}',
'InstituteNo': self.institute_no,
'Api-Version': '1',
'Accept': 'application/json'
}
inf_data = requests.get(inf_url, headers=inf_headers).json()
if not inf_data or 'students' not in inf_data:
return False, "Geen data van Informat ontvangen"
# 2. Haal Bestaande Database data op
# We gebruiken execute_query om alle bestaande Internnummers op te halen
db_rows = execute_query("SELECT Internnummer FROM Leerlingen")
db_numbers = {str(row['Internnummer']) for row in db_rows} if db_rows else set()
# 3. Zoek ontbrekende ID's (Informat - Database)
json_numbers = {str(s['pPersoon']) for s in inf_data['students'] if 'pPersoon' in s}
missing_ids = json_numbers - db_numbers
if not missing_ids:
return True, "Iedereen is al gesynchroniseerd."
# 4. Haal Smartschool data op voor de match
ss_token = self.get_smartschool_token()
ss_res = requests.get(
f"{self.conf.SS_BASE_URL}/students",
headers={"Authorization": f"Bearer {ss_token}"}
)
ss_students = ss_res.json().get("users", [])
added_count = 0
for m_id in missing_ids:
found_username = None
for ss_student in ss_students:
ss_internal = str(ss_student.get("metadata", {}).get("smsc.internalNumber", ""))
if ss_internal.strip() == m_id.strip():
found_username = ss_student.get("username")
break
if found_username:
# 5. DATABASE INVOER
execute_query(
"INSERT INTO Leerlingen (Internnummer, Gebruikersnaam, ss_nummer) VALUES (%s, %s, %s)",
(m_id, found_username, "Niet ingesteld"),
commit=True
)
added_count += 1
return True, f"Succes! {added_count} leerlingen toegevoegd."
except Exception as e:
return False, f"Fout tijdens synchronisatie: {str(e)}"
#---------------------Nieuwe aanpak------------------------
import requests
from datetime import datetime, timezone
from config import Config
from database import execute_query
class InformatSmartschoolSync:
def __init__(self):
self.conf = Config()
# Informat-gegevens van de huidige school (beheerd via /scholen)
self.institute_no = Config.INFORMAT_INST_NUMBER
self.auth_url = Config.INFORMAT_AUTH_URL
self.api_base = Config.INFORMAT_API_BASE
# De scope MOET het instellingsnummer bevatten
self.scope = Config.INFORMAT_FULL_SCOPE
self.campussen = Config.INFORMAT_CAMPUSSEN or [(self.institute_no, '')]
def get_informat_token(self, nummer=None):
"""Haalt token op met de scope van het (eerste of opgegeven) instellingsnummer."""
from tenancy import informat_scope
payload = {
'grant_type': 'client_credentials',
'client_id': self.conf.INFORMAT_ID,
'client_secret': self.conf.INFORMAT_SECRET,
'scope': informat_scope(Config.INFORMAT_SCOPE, nummer) if nummer else self.scope
}
try:
response = requests.post(self.auth_url, data=payload, timeout=10)
if response.status_code == 200:
return response.json().get('access_token')
except Exception as e:
print(f"Fout bij ophalen Informat token: {e}")
return None
def get_smartschool_oneroster_students(self):
"""Haalt actieve leerlingen op uit de Smartschool OneRoster API (v1p1). Bij een fout: {}."""
mapping, fout = self.smartschool_koppeling()
if fout:
print(f"Fout bij ophalen Smartschool OneRoster data: {fout}")
return mapping
def smartschool_koppeling(self):
"""{internnummer: {'username', 'ss_nummer'}} uit Smartschool OneRoster. Geeft (mapping, None) of ({}, fout)."""
if not (self.conf.SS_CLIENT_ID and self.conf.SS_CLIENT_SECRET):
return {}, "de OneRoster-koppeling van Smartschool is niet ingesteld"
try:
token_url = self.conf.SS_TOKEN_URL.strip()
if not token_url.startswith(("http://", "https://")):
token_url = f"https://{token_url}"
resp_ss = requests.post(
token_url,
data={"grant_type": "client_credentials"},
auth=(self.conf.SS_CLIENT_ID, self.conf.SS_CLIENT_SECRET),
timeout=10
)
resp_ss.raise_for_status()
ss_token = resp_ss.json().get("access_token")
if not ss_token:
return {}, "geen access_token ontvangen van Smartschool"
headers = {
"Authorization": f"Bearer {ss_token}",
"Accept": "application/json"
}
base_url = self.conf.SS_BASE_URL.strip()
if not base_url.startswith(("http://", "https://")):
base_url = f"https://{base_url}"
base_url = base_url.rstrip('/')
endpoint_url = f"{base_url}/students"
response = requests.get(endpoint_url, headers=headers, timeout=ONEROSTER_TIMEOUT)
response.raise_for_status()
json_data = response.json()
all_students = json_data.get("users", [])
ss_mapping = {}
for student in all_students:
metadata = student.get("metadata", {})
internal_number = metadata.get("smsc.internalNumber")
username = student.get("username")
sourced_id = student.get("sourcedId")
if internal_number:
ss_mapping[str(internal_number).strip()] = {
"username": username,
"ss_nummer": sourced_id if sourced_id else "Niet ingesteld"
}
if not ss_mapping:
return {}, "Smartschool gaf geen enkele leerling met een internnummer terug"
return ss_mapping, None
except Exception as e:
return {}, str(e)
def _informat_leerlingen(self, nummer, school_year, refdate):
"""Leerlingenlijst van één instellingsnummer. Geeft (lijst, None) of (None, fout)."""
inf_token = self.get_informat_token(nummer)
if not inf_token:
return None, f"Informat authenticatie mislukt (instellingsnummer {nummer})."
base_url_inf = self.api_base.strip().rstrip('/')
url = f"{base_url_inf}/students/?schoolYear={school_year}&refdate={refdate}"
headers = {
'Authorization': f'BEARER {inf_token}',
'InstituteNo': nummer,
'Timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000Z'),
'Api-Version': '1',
'Accept': 'application/json'
}
response = informat_get(url, headers)
if response.status_code != 200:
return None, f"Informat API fout {response.status_code} (instellingsnummer {nummer}): {response.text}"
json_data = response.json()
return (json_data if isinstance(json_data, list) else json_data.get('students', json_data.get('items', []))), None
def sync_actieve_leerlingen(self):
"""
Haalt actieve leerlingen op bij Informat (alle campussen / instellingsnummers van de school),
koppelt Smartschool OneRoster info en update de database 'Leerlingen'.
"""
school_year = Config.SCHOOLYEAR
# +1 maand: ook leerlingen die net ingeschreven zijn en pas na een vakantie starten
refdate = (datetime.now() + relativedelta(months=1)).strftime('%Y-%m-%d')
students_list, gezien = [], set()
try:
for nummer, naam in self.campussen:
lijst, fout = self._informat_leerlingen(nummer, school_year, refdate)
if fout:
return False, (f"{naam}: {fout}" if len(self.campussen) > 1 else fout)
for st in lijst:
sleutel = str(st.get('pPersoon') or st.get('internNummer') or st.get('id', '')).strip()
if sleutel and sleutel not in gezien:
gezien.add(sleutel)
students_list.append(st)
except Exception as e:
return False, f"Fout opgetreden tijdens de synchronisatie: {str(e)}"
if not students_list:
# Nooit iedereen als vertrokken markeren omdat Informat (tijdelijk) een lege lijst gaf
return False, "Informat gaf geen enkele actieve leerling terug; er is niets aangepast."
# Smartschool (gebruikersnaam en Smartschool-nummer). Lukt dat niet, dan blijven die velden zoals ze waren.
ss_leerlingen, ss_fout = self.smartschool_koppeling()
try:
print(f"[DEBUG] Aantal actieve leerlingen gevonden in Informat: {len(students_list)}")
zorg_vertrokken_kolom()
db_rows = execute_query("SELECT Internnummer FROM Leerlingen")
bestaande_internnummers = {str(row['Internnummer']).strip() for row in db_rows} if db_rows else set()
toegevoegd_teller = bijgewerkt_teller = 0
schooljaar_id = Config.SCHOOLYEAR[:4]
actief = set()
for student in students_list:
internnummer = str(student.get('pPersoon') or student.get('internNummer') or student.get('id', '')).strip()
voornaam = (student.get('voornaam' if 'voornaam' in student else 'firstName') or '').strip()
naam = (student.get('naam' if 'naam' in student else 'lastName') or '').strip()
if not internnummer or internnummer == "None":
continue
actief.add(internnummer)
ss_info = ss_leerlingen.get(internnummer)
standaard_gebruikersnaam = f"{voornaam}.{naam}".replace(" ", "").lower()
if internnummer in bestaande_internnummers:
if ss_info:
execute_query("""
UPDATE Leerlingen SET Gebruikersnaam = %s, Voornaam = %s, Naam = %s, SS_nummer = %s,
Schooljaar_ID = %s, Vertrokken_op = NULL
WHERE Internnummer = %s""",
(ss_info.get("username") or standaard_gebruikersnaam, voornaam, naam,
ss_info.get("ss_nummer") or "Niet ingesteld", schooljaar_id, internnummer), commit=True)
else:
# Niet (of niet bereikbaar) in Smartschool: gebruikersnaam en Smartschool-nummer niet overschrijven
execute_query("""
UPDATE Leerlingen SET Voornaam = %s, Naam = %s, Schooljaar_ID = %s, Vertrokken_op = NULL
WHERE Internnummer = %s""", (voornaam, naam, schooljaar_id, internnummer), commit=True)
bijgewerkt_teller += 1
else:
ss_info = ss_info or {}
execute_query("""
INSERT INTO Leerlingen (Internnummer, Gebruikersnaam, Voornaam, Naam, SS_nummer, Schooljaar_ID)
VALUES (%s, %s, %s, %s, %s, %s)""",
(internnummer, ss_info.get("username") or standaard_gebruikersnaam, voornaam, naam,
ss_info.get("ss_nummer") or "Niet ingesteld", schooljaar_id), commit=True)
bestaande_internnummers.add(internnummer)
toegevoegd_teller += 1
# Leerlingen die niet meer actief zijn in Informat: als vertrokken markeren (niet verwijderen)
nog_actief = execute_query("SELECT Internnummer FROM Leerlingen WHERE Vertrokken_op IS NULL") or []
weg = [r['Internnummer'] for r in nog_actief if str(r['Internnummer']).strip() not in actief]
for k in range(0, len(weg), 500):
deel = weg[k:k + 500]
execute_query(f"UPDATE Leerlingen SET Vertrokken_op = CURDATE() WHERE Internnummer IN "
f"({', '.join(['%s'] * len(deel))})", tuple(deel), commit=True)
melding = f"{toegevoegd_teller} nieuw, {bijgewerkt_teller} bijgewerkt"
if weg:
melding += f", {len(weg)} als vertrokken gemarkeerd"
return True, {'melding': melding + '.', 'smartschool_fout': ss_fout}
except Exception as e:
return False, f"Fout opgetreden tijdens de synchronisatie: {str(e)}"
+148
View File
@@ -0,0 +1,148 @@
"""
Tabelnamen met hoofdletters herstellen (Windows → Linux).
MySQL op Windows bewaart tabelnamen standaard in kleine letters (lower_case_table_names = 1) en maakt hoofdletters
niet uit. Op Linux (lower_case_table_names = 0) zijn 'toestellen' en 'Toestellen' twee verschillende tabellen.
Een database die van Windows komt, heeft dan enkel kleine letters, terwijl PIVOT 'Toestellen' zoekt (en een lege
nieuwe aanmaakt).
Bij het opstarten loopt PIVOT daarom de centrale database en de database van elke school na. Enkel op een server
waar hoofdletters tellen (lower_case_table_names = 0), en enkel voor de tabellen die PIVOT kent:
- alleen 'toestellen' bestaat → hernoemd naar 'Toestellen'
- beide bestaan, 'Toestellen' is leeg → de lege 'Toestellen' weg, 'toestellen' hernoemd naar 'Toestellen'
- beide bestaan, 'toestellen' is leeg → niets nodig ('Toestellen' wordt gebruikt), enkel gemeld
- beide bevatten gegevens → niets aangeraakt, gemeld in het logboek (handmatig samenvoegen)
Op Windows (of met lower_case_table_names = 1 of 2) doet dit niets.
"""
import glob
import os
import re
import sys
_RE = re.compile(r"CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+`?([A-Za-z_][A-Za-z0-9_]*)`?", re.I)
_namen = None
def verwachte_namen():
"""Alle tabelnamen met minstens één hoofdletter, zoals PIVOT ze aanmaakt (uit de SQL-bestanden en de code)."""
global _namen
if _namen is None:
basis = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
bestanden = glob.glob(os.path.join(basis, 'sql', '*.sql')) + glob.glob(os.path.join(basis, '*.py')) \
+ glob.glob(os.path.join(basis, 'services', '*.py')) + glob.glob(os.path.join(basis, 'blueprints', '*.py'))
namen = set()
for pad in bestanden:
try:
with open(pad, encoding='utf-8', errors='ignore') as f:
namen.update(n for n in _RE.findall(f.read()) if n != n.lower())
except OSError:
continue
_namen = sorted(namen)
return _namen
def _q(cur, sql, params=()):
cur.execute(sql, params)
return cur.fetchall() if cur.with_rows else []
def herstel(conn, label):
"""Herstelt de tabelnamen in de database van `conn`. Geeft een lijst meldingen (leeg = niets gedaan)."""
meldingen = []
cur = conn.cursor()
try:
lctn = _q(cur, "SELECT @@lower_case_table_names")
if not lctn or int(lctn[0][0]) != 0:
return meldingen # Windows/macOS-gedrag: hoofdletters maken niet uit
bestaand = [r[0] for r in _q(cur, "SELECT TABLE_NAME FROM information_schema.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'")]
if not bestaand:
return meldingen
per_klein = {}
for t in bestaand:
per_klein.setdefault(t.lower(), []).append(t)
te_doen = []
for naam in verwachte_namen():
varianten = [t for t in per_klein.get(naam.lower(), []) if t != naam]
if not varianten:
continue
if len(varianten) > 1:
meldingen.append(f"{label}: meerdere varianten van {naam} ({', '.join(varianten)}), niets gewijzigd")
continue
te_doen.append((naam, varianten[0], naam in per_klein.get(naam.lower(), [])))
if not te_doen:
return meldingen
aantal = lambda t: int(_q(cur, f"SELECT COUNT(*) FROM `{t}`")[0][0])
_q(cur, "SET FOREIGN_KEY_CHECKS = 0")
try:
for naam, oud, juiste_bestaat in te_doen:
try:
if juiste_bestaat:
n_juist, n_oud = aantal(naam), aantal(oud)
if n_oud == 0:
meldingen.append(f"{label}: {oud} is leeg en wordt niet gebruikt ({naam} wel); "
f"je mag {oud} verwijderen")
continue
if n_juist > 0:
meldingen.append(f"{label}: zowel {oud} ({n_oud} rijen) als {naam} ({n_juist} rijen) "
f"bevatten gegevens; niets gewijzigd, voeg ze zelf samen")
continue
_q(cur, f"DROP TABLE `{naam}`")
_q(cur, f"RENAME TABLE `{oud}` TO `{naam}`")
meldingen.append(f"{label}: lege {naam} vervangen door {oud} ({n_oud} rijen)")
else:
_q(cur, f"RENAME TABLE `{oud}` TO `{naam}`")
meldingen.append(f"{label}: {oud} hernoemd naar {naam}")
except Exception as e:
meldingen.append(f"{label}: {oud} → {naam} mislukt: {e}")
finally:
_q(cur, "SET FOREIGN_KEY_CHECKS = 1")
conn.commit()
finally:
cur.close()
return meldingen
def _verbinding(host, user, password, database):
"""Eén losse verbinding (geen pool): bij het opstarten zou een pool per school anders meteen
5 verbindingen per school openen en blijven vasthouden ("Too many connections")."""
import mysql.connector
from config import Config
return mysql.connector.connect(host=host, port=Config.DB_PORT, user=user, password=password, database=database,
connection_timeout=10, charset='utf8mb4', collation='utf8mb4_unicode_ci')
def herstel_centraal():
from config import Config
conn = _verbinding(Config.DB_HOST, Config.DB_USER, Config.DB_PASSWORD, Config.DB_CENTRAAL)
try:
return _log(herstel(conn, 'centrale database'))
finally:
conn.close()
def herstel_school(school):
cfg = school.db_config()
conn = _verbinding(cfg['host'], cfg['user'], cfg['password'], cfg['database'])
try:
return _log(herstel(conn, f"school {school.code}"))
finally:
conn.close()
def herstel_alle_scholen():
from tenancy import alle_scholen
uit = []
for school in alle_scholen(ook_inactief=True):
try:
uit += herstel_school(school)
except Exception as e:
uit += _log([f"school {school.code}: tabelnamen nakijken mislukt: {e}"])
return uit
def _log(meldingen):
for m in meldingen:
print(f"PIVOT tabelnamen: {m}", file=sys.stderr, flush=True)
return meldingen
+121
View File
@@ -0,0 +1,121 @@
"""
Hulpfuncties rond toestellen (tabellen Toestellen, Signpost_DB, Opmerkingen, Eigendom).
Het toestelnummer dat gebruikers intypen (vroeger 'KAMXM_nummer') is Toestellen.Schooleigen_ID.
"""
from datetime import datetime, date
from database import execute_query, execute_insert
# Laatste opmerking van een toestel (subquery, t = alias van Toestellen)
LAATSTE_OPMERKING = ("(SELECT o.Opmerking FROM Opmerkingen o WHERE o.Toestel_ID = t.Toestel_ID "
"ORDER BY o.Timestamp DESC, o.Opmerking_ID DESC LIMIT 1)")
def zoek_toestel(nummer):
"""Toestel op toestelnummer (Schooleigen_ID) of, als dat niet bestaat, op serienummer."""
nummer = str(nummer or '').strip()
if not nummer:
return None
rows = execute_query("SELECT * FROM Toestellen WHERE Schooleigen_ID = %s", (nummer,))
if not rows:
rows = execute_query("SELECT * FROM Toestellen WHERE Serienummer = %s", (nummer,))
return rows[0] if rows else None
def zoek_leerling(nummer):
"""Leerling op internnummer of Smartschool-nummer."""
rows = execute_query("SELECT * FROM Leerlingen WHERE Internnummer = %s OR SS_nummer = %s LIMIT 1",
(str(nummer).strip(), str(nummer).strip()))
return rows[0] if rows else None
def laatste_opmerking(toestel_id):
rows = execute_query("SELECT Opmerking FROM Opmerkingen WHERE Toestel_ID = %s "
"ORDER BY Timestamp DESC, Opmerking_ID DESC LIMIT 1", (toestel_id,))
return rows[0]['Opmerking'] if rows else None
def voeg_opmerking_toe(toestel_id, tekst, gebruiker=None):
"""Bewaart een opmerking als die verschilt van de laatste (zo blijft de geschiedenis bewaard)."""
tekst = (tekst or '').strip()
if tekst and tekst != (laatste_opmerking(toestel_id) or ''):
execute_query("INSERT INTO Opmerkingen (Toestel_ID, Opmerking, Timestamp, Gebruiker) VALUES (%s, %s, NOW(), %s)",
(toestel_id, tekst, gebruiker), commit=True)
def lees_datum(waarde):
"""'2027-08-31', '31-08-2027' of '31/08/2027' → date. Leeg → None. Ongeldig → ValueError."""
if isinstance(waarde, date):
return waarde
w = (waarde or '').strip()
if not w:
return None
for fmt in ('%Y-%m-%d', '%d-%m-%Y', '%d/%m/%Y', '%d.%m.%Y'):
try:
return datetime.strptime(w, fmt).date()
except ValueError:
pass
raise ValueError(f"'{w}' is geen geldige datum (gebruik dd-mm-jjjj).")
def bewaar_signpost(toestel_id, signpostnummer=None, garantie=None):
"""Maakt/werkt de Signpost-gegevens van een toestel bij (enkel ingevulde velden)."""
signpostnummer = (signpostnummer or '').strip() or None
garantie = lees_datum(garantie)
if not signpostnummer and not garantie:
return
execute_query(
"INSERT INTO Signpost_DB (Toestel_ID, Signpostnummer, Garantie) VALUES (%s, %s, %s) "
"ON DUPLICATE KEY UPDATE Signpostnummer = COALESCE(VALUES(Signpostnummer), Signpostnummer), "
"Garantie = COALESCE(VALUES(Garantie), Garantie)",
(toestel_id, signpostnummer, garantie), commit=True)
def nieuw_toestel(schooleigen_id=None, serienummer=None, type_toestel=None, status='Beschikbaar'):
return execute_insert(
"INSERT INTO Toestellen (Schooleigen_ID, Serienummer, Type, Status) VALUES (%s, %s, %s, %s)",
((schooleigen_id or None), (serienummer or None), (type_toestel or None), status))
def actieve_eigendom(toestel_id):
"""Huidige toekenning van een toestel (Datum_inlevering leeg) of None."""
rows = execute_query("SELECT * FROM Eigendom WHERE Toestel_ID = %s AND Datum_inlevering IS NULL "
"ORDER BY Datum_toekenning DESC LIMIT 1", (toestel_id,))
return rows[0] if rows else None
# ---------------------------------------------------------------------------
# Geblokkeerd in Google (kolom Toestellen.Geblokkeerd)
# ---------------------------------------------------------------------------
def ensure_geblokkeerd():
"""Voegt de kolom Toestellen.Geblokkeerd toe als die nog niet bestaat. Bij het toevoegen krijgen toestellen die
bij een leerling geblokkeerd staan (Eigendom.Status = 1) meteen de waarde 1. Eén keer per school en proces."""
from tenancy import huidige_school
school = huidige_school()
if school.cache.get('kol_geblokkeerd'):
return
if not execute_query("SHOW COLUMNS FROM Toestellen LIKE 'Geblokkeerd'"):
execute_query("ALTER TABLE Toestellen ADD COLUMN Geblokkeerd TINYINT(1) NOT NULL DEFAULT 0 AFTER Status",
commit=True)
execute_query("UPDATE Toestellen t JOIN Eigendom e ON e.Toestel_ID = t.Toestel_ID "
"AND e.Datum_inlevering IS NULL AND e.Status = 1 SET t.Geblokkeerd = 1", commit=True)
school.cache['kol_geblokkeerd'] = True
def zet_geblokkeerd(toestel_id, geblokkeerd):
"""Bewaart de blokkeerstatus van een toestel, ook bij de huidige eigenaar (Eigendom.Status)."""
ensure_geblokkeerd()
w = 1 if geblokkeerd else 0
execute_query("UPDATE Toestellen SET Geblokkeerd = %s WHERE Toestel_ID = %s", (w, toestel_id), commit=True)
execute_query("UPDATE Eigendom SET Status = %s WHERE Toestel_ID = %s AND Datum_inlevering IS NULL",
(w, toestel_id), commit=True)
def is_geblokkeerd(toestel_id):
try:
ensure_geblokkeerd()
rows = execute_query("SELECT Geblokkeerd FROM Toestellen WHERE Toestel_ID = %s", (toestel_id,))
return bool(rows and int(rows[0]['Geblokkeerd'] or 0))
except Exception:
return False
+515
View File
@@ -0,0 +1,515 @@
"""
2FA-hulp: back-upcodes, tijdelijke uitzonderingen en overzicht van wie 2FA nog niet heeft ingesteld.
Vereist (eenmalig, Google Admin-console):
1. Beveiliging › API-beheer › Domeinbrede delegatie: aan het service-account toevoegen
https://www.googleapis.com/auth/admin.directory.user.security (back-upcodes)
https://www.googleapis.com/auth/admin.directory.group.member (uitzonderingsgroep)
2. Een groep aanmaken, bv. 2fa-uitzondering@school.be, en bij
Beveiliging › Authenticatie › Verificatie in 2 stappen voor DIE GROEP
'Afdwingen' op UIT zetten (groepsinstellingen gaan boven de OU-instelling).
3. Het adres van die groep invullen in PIVOT › 🔌 Koppelingen van deze school › 🛡️ 2FA-hulp › Uitzonderingsgroep
"""
import sys
import threading
import time
from datetime import datetime, timedelta
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from config import Config
from database import execute_query
from tenancy import google_credentials, school_singleton, alle_scholen, in_school
SECURITY_SCOPE = 'https://www.googleapis.com/auth/admin.directory.user.security'
GROUP_MEMBER_SCOPE = 'https://www.googleapis.com/auth/admin.directory.group.member'
RESET_INTERVAL = 60 # wachtende 2FA-reset elke minuut opnieuw proberen
CHECK_INTERVAL = 5 * 60 # elke 5 minuten verlopen uitzonderingen opruimen
LIJST_CACHE = 10 * 60
def _is_afgedwongen_fout(e):
"""Google weigert turnOff zolang 2FA nog afgedwongen wordt (uitzonderingsgroep nog niet toegepast)."""
msg = str(e).lower()
return 'cannot be turned off' in msg or ('enforced' in msg and 'admin policy' in msg)
def _is_netwerkfout(e):
"""Tijdelijke verbindingsfout (SSL, time-out, Google 5xx): later opnieuw proberen."""
import socket, ssl
if isinstance(e, (ssl.SSLError, socket.timeout, ConnectionError, TimeoutError, OSError)):
return True
status = getattr(getattr(e, 'resp', None), 'status', None)
try:
return int(status) >= 500
except (TypeError, ValueError):
return False
class TweestapsError(Exception):
pass
class TweestapsService:
_scheduler = None # één achtergrondtaak voor alle scholen
def __new__(cls):
# één instantie per school
def init(inst):
inst._clients = threading.local() # Google-clients (httplib2) zijn niet thread-safe: één per thread
inst._directory = None
inst._lock = threading.Lock()
inst._lijst_cache = None
return school_singleton(cls, init)
# ---------------- clients ----------------
def _build(self, scope):
creds = google_credentials([scope], subject=Config.GOOGLE_ADMIN_EMAIL)
return build('admin', 'directory_v1', credentials=creds, static_discovery=False, cache_discovery=False)
def _client(self, naam, scope):
client = getattr(self._clients, naam, None)
if client is None:
client = self._build(scope)
setattr(self._clients, naam, client)
return client
def _security_api(self):
return self._client('security', SECURITY_SCOPE)
def _group_api(self):
return self._client('groups', GROUP_MEMBER_SCOPE)
def _directory_api(self):
if self._directory is None:
from services.google_service import GoogleAdminService
self._directory = GoogleAdminService().service
return self._directory
@staticmethod
def _friendly(e, scope=None):
msg = str(e)
status = getattr(getattr(e, 'resp', None), 'status', None)
if 'unauthorized_client' in msg or 'access_denied' in msg:
return (f"Google weigert de toegang. Voeg de scope {scope} toe aan de domeinbrede delegatie "
"van het service-account." if scope else "Google weigert de toegang (scope ontbreekt).")
if _is_afgedwongen_fout(e):
return ("Google dwingt 2FA nog af voor deze gebruiker. De uitzonderingsgroep is nog niet "
"toegepast (dat kan enkele minuten duren).")
if 'INVALID_OU_ID' in msg:
return ("Een organisatie-eenheid bestaat niet in Google. Controleer de OU's bij 🔌 Koppelingen van "
"deze school › 🛡️ 2FA-hulp.")
if status == 404:
return "Gebruiker of groep niet gevonden."
if status == 403:
return "Geen toestemming voor deze actie bij Google."
return msg
ontbrekende_ous = []
def ou_melding(self):
if not self.ontbrekende_ous:
return None
return (f"Deze organisatie-eenheid bestaat niet in Google: {', '.join(self.ontbrekende_ous)}. "
"Pas ze aan bij 🔌 Koppelingen van deze school › 🛡️ 2FA-hulp › \"OU's voor 'nog geen 2FA'\" "
"(het volledige pad zoals in admin.google.com, bv. /Personeel/Leerkrachten; meerdere met komma's).")
@property
def exception_group(self):
return (Config.TWEESTAPS_UITZONDERING_GROEP or '').strip()
# ---------------- gebruiker opzoeken ----------------
def get_user(self, email):
email = (email or '').strip().lower()
if '@' not in email:
raise TweestapsError("Geef een volledig e-mailadres op.")
try:
u = self._directory_api().users().get(userKey=email).execute()
except HttpError as e:
if getattr(e.resp, 'status', None) == 404:
raise TweestapsError(f"{email} bestaat niet in jullie Google Workspace.")
raise TweestapsError(self._friendly(e))
return {
'email': u['primaryEmail'].lower(),
'naam': (u.get('name') or {}).get('fullName', ''),
'ou': u.get('orgUnitPath', ''),
'ingesteld': bool(u.get('isEnrolledIn2Sv')),
'afgedwongen': bool(u.get('isEnforcedIn2Sv')),
'geschorst': bool(u.get('suspended')),
'laatste_login': (u.get('lastLoginTime') or '')[:16].replace('T', ' ').replace('1970-01-01 00:00', ''),
}
def search_users(self, query):
try:
resp = self._directory_api().users().list(
customer='my_customer', query=query.strip(), maxResults=15, orderBy='email').execute()
except Exception as e:
sys.stderr.write(f"2FA zoeken: {e}\n")
return []
return [{'email': u['primaryEmail'], 'naam': (u.get('name') or {}).get('fullName', ''),
'ou': u.get('orgUnitPath', '')} for u in resp.get('users', [])]
# ---------------- back-upcodes ----------------
def list_codes(self, email):
try:
resp = self._security_api().verificationCodes().list(userKey=email).execute()
except Exception as e:
raise TweestapsError(self._friendly(e, SECURITY_SCOPE))
return [i['verificationCode'] for i in resp.get('items', []) if i.get('verificationCode')]
def generate_codes(self, email):
"""Maakt een nieuwe set (de oude set wordt daardoor ongeldig) en geeft de codes terug."""
try:
self._security_api().verificationCodes().generate(userKey=email).execute()
except Exception as e:
raise TweestapsError(self._friendly(e, SECURITY_SCOPE))
return self.list_codes(email)
def invalidate_codes(self, email):
try:
self._security_api().verificationCodes().invalidate(userKey=email).execute()
except Exception as e:
raise TweestapsError(self._friendly(e, SECURITY_SCOPE))
# ---------------- uitzonderingsgroep ----------------
def _add_to_group(self, email):
if not self.exception_group:
raise TweestapsError("Er is nog geen uitzonderingsgroep ingesteld "
"(🔌 Koppelingen van deze school › 🛡️ 2FA-hulp › Uitzonderingsgroep).")
try:
self._group_api().members().insert(
groupKey=self.exception_group, body={'email': email, 'role': 'MEMBER'}).execute()
except HttpError as e:
if getattr(e.resp, 'status', None) == 409: # zit er al in → prima
return
raise TweestapsError(self._friendly(e, GROUP_MEMBER_SCOPE))
except Exception as e:
raise TweestapsError(self._friendly(e, GROUP_MEMBER_SCOPE))
def _remove_from_group(self, email):
try:
self._group_api().members().delete(groupKey=self.exception_group, memberKey=email).execute()
except HttpError as e:
if getattr(e.resp, 'status', None) == 404: # zit er al niet meer in → prima
return
raise TweestapsError(self._friendly(e, GROUP_MEMBER_SCOPE))
except Exception as e:
raise TweestapsError(self._friendly(e, GROUP_MEMBER_SCOPE))
# ---------------- database ----------------
@staticmethod
def ensure_tables():
execute_query("""
CREATE TABLE IF NOT EXISTS tweestaps_uitzonderingen (
ID INT AUTO_INCREMENT PRIMARY KEY,
Email VARCHAR(255) NOT NULL,
Naam VARCHAR(255) NULL,
Reden VARCHAR(255) NULL,
Van DATETIME NOT NULL,
Tot DATETIME NOT NULL,
Door VARCHAR(100) NULL,
Actief TINYINT(1) NOT NULL DEFAULT 1,
Beeindigd_op DATETIME NULL,
Beeindigd_door VARCHAR(100) NULL,
INDEX idx_actief (Actief, Tot),
INDEX idx_email (Email)
)""", commit=True)
execute_query("""
CREATE TABLE IF NOT EXISTS tweestaps_log (
ID INT AUTO_INCREMENT PRIMARY KEY,
Tijdstip DATETIME NOT NULL,
Door VARCHAR(100) NULL,
Actie VARCHAR(40) NOT NULL,
Email VARCHAR(255) NOT NULL,
Details VARCHAR(255) NULL,
INDEX idx_tijdstip (Tijdstip)
)""", commit=True)
execute_query("""
CREATE TABLE IF NOT EXISTS tweestaps_resets (
ID INT AUTO_INCREMENT PRIMARY KEY,
Email VARCHAR(255) NOT NULL,
Gevraagd_op DATETIME NOT NULL,
Door VARCHAR(100) NULL,
Reden VARCHAR(255) NULL,
Tot DATETIME NOT NULL,
Status VARCHAR(10) NOT NULL DEFAULT 'bezig',
Pogingen INT NOT NULL DEFAULT 0,
Laatste_fout VARCHAR(255) NULL,
Klaar_op DATETIME NULL,
INDEX idx_status (Status),
INDEX idx_email (Email)
)""", commit=True)
@staticmethod
def log(door, actie, email, details=None):
try:
execute_query("INSERT INTO tweestaps_log (Tijdstip, Door, Actie, Email, Details) VALUES (NOW(), %s, %s, %s, %s)",
(door, actie, email, details), commit=True)
except Exception as e:
sys.stderr.write(f"2FA log: {e}\n")
@staticmethod
def recent_log(pagina=1, per_pagina=10):
"""Eén pagina van het logboek (nieuwste eerst). Geeft (rijen, totaal aantal)."""
totaal = execute_query("SELECT COUNT(*) AS n FROM tweestaps_log")[0]['n']
pagina = max(1, int(pagina or 1))
rijen = execute_query("SELECT Tijdstip, Door, Actie, Email, Details FROM tweestaps_log "
"ORDER BY Tijdstip DESC, ID DESC LIMIT %s OFFSET %s",
(int(per_pagina), (pagina - 1) * int(per_pagina)))
return rijen, totaal
@staticmethod
def active_exceptions():
return execute_query("SELECT ID, Email, Naam, Reden, Van, Tot, Door FROM tweestaps_uitzonderingen "
"WHERE Actief = 1 ORDER BY Tot")
@staticmethod
def active_for(email):
rows = execute_query("SELECT ID, Tot FROM tweestaps_uitzonderingen WHERE Actief = 1 AND Email = %s "
"ORDER BY Tot DESC LIMIT 1", (email,))
return rows[0] if rows else None
# ---------------- uitzonderingen ----------------
@staticmethod
def einde_vandaag(nu=None):
"""Een uitzondering (of 2FA-reset) loopt altijd tot 23:59 dezelfde dag."""
return (nu or datetime.now()).replace(hour=23, minute=59, second=0, microsecond=0)
def grant_exception(self, email, eindtijd, door, reden=None):
user = self.get_user(email)
if user['geschorst']:
raise TweestapsError(f"{user['email']} is geschorst.")
bestaand = self.active_for(user['email'])
self._add_to_group(user['email'])
if bestaand:
execute_query("UPDATE tweestaps_uitzonderingen SET Tot = %s WHERE ID = %s",
(eindtijd, bestaand['ID']), commit=True)
else:
execute_query(
"INSERT INTO tweestaps_uitzonderingen (Email, Naam, Reden, Van, Tot, Door, Actief) "
"VALUES (%s, %s, %s, NOW(), %s, %s, 1)",
(user['email'], user['naam'], (reden or '')[:255], eindtijd, door), commit=True)
self.log(door, 'uitzondering', user['email'], f"tot {eindtijd:%d-%m %H:%M}" + (f" – {reden}" if reden else ''))
self._lijst_cache = None
return user
def reset_2fa(self, email, eindtijd, door, reden=None):
"""
2FA resetten (nieuwe telefoon, toestel kwijt): 2FA uitzetten voor de gebruiker zodat hij het opnieuw
kan instellen. Wordt 2FA afgedwongen, dan krijgt hij eerst een tijdelijke uitzondering; anders zou hij
na het uitzetten niet meer kunnen inloggen om 2FA opnieuw in te stellen.
Google: users.twoStepVerification.turnOff (scope admin.directory.user.security).
"""
user = self.get_user(email)
if user['geschorst']:
raise TweestapsError(f"{user['email']} is geschorst.")
if not user['ingesteld']:
raise TweestapsError(f"{user['email']} heeft nog geen 2FA ingesteld; er valt niets te resetten. "
"Moet hij 2FA nog instellen maar kan hij niet inloggen, geef dan een tijdelijke uitzondering.")
uitzondering = False
if user['afgedwongen']:
if not self.exception_group:
raise TweestapsError("2FA wordt afgedwongen voor deze gebruiker. Stel eerst een uitzonderingsgroep in "
"(🔌 Koppelingen van deze school › 🛡️ 2FA-hulp), anders kan hij na de reset niet "
"meer inloggen om 2FA opnieuw in te stellen.")
self.grant_exception(user['email'], eindtijd, door, reden or '2FA-reset')
uitzondering = True
try:
self._turn_off(user['email'])
except Exception as e:
if uitzondering and _is_afgedwongen_fout(e):
# Google past de uitzonderingsgroep pas na enkele minuten toe → op de achtergrond opnieuw proberen.
self._plan_reset(user['email'], eindtijd, door, reden)
user['uitzondering'] = True
user['wacht'] = True
return user
raise TweestapsError(f"2FA uitzetten mislukt: {self._friendly(e, SECURITY_SCOPE)}"
+ (" De tijdelijke uitzondering blijft lopen tot ze afloopt." if uitzondering else ""))
self._lijst_cache = None
self.log(door, '2FA gereset', user['email'], reden or None)
user['uitzondering'] = uitzondering
user['wacht'] = False
return user
def _turn_off(self, email):
self._security_api().twoStepVerification().turnOff(userKey=email).execute()
# ---------------- 2FA-reset die op Google wacht ----------------
def _plan_reset(self, email, tot, door, reden):
bestaand = self.reset_bezig(email)
if bestaand:
execute_query("UPDATE tweestaps_resets SET Tot = %s WHERE ID = %s", (tot, bestaand['ID']), commit=True)
rid = bestaand['ID']
else:
execute_query(
"INSERT INTO tweestaps_resets (Email, Gevraagd_op, Door, Reden, Tot, Status, Pogingen) "
"VALUES (%s, NOW(), %s, %s, %s, 'bezig', 1)", (email, door, (reden or '')[:255], tot), commit=True)
rid = execute_query("SELECT ID FROM tweestaps_resets WHERE Email = %s AND Status = 'bezig' "
"ORDER BY ID DESC LIMIT 1", (email,))[0]['ID']
self.log(door, '2FA reset wacht', email, 'Google past de uitzondering nog toe; PIVOT probeert opnieuw')
self._volg_reset_op(rid)
@staticmethod
def reset_bezig(email=None):
if email:
rows = execute_query("SELECT * FROM tweestaps_resets WHERE Status = 'bezig' AND Email = %s "
"ORDER BY ID DESC LIMIT 1", ((email or '').strip().lower(),))
return rows[0] if rows else None
return execute_query("SELECT * FROM tweestaps_resets WHERE Status = 'bezig' ORDER BY Gevraagd_op")
def probeer_reset(self, rij):
"""Eén poging voor een wachtende reset. Geeft 'klaar', 'mislukt' of 'bezig' terug."""
email = rij['Email']
try:
self._turn_off(email)
except Exception as e:
nog_tijd = rij['Tot'] > datetime.now()
tijdelijk = _is_afgedwongen_fout(e) or _is_netwerkfout(e)
if _is_netwerkfout(e):
self._clients.security = None # verbinding kapot → volgende poging met een nieuwe client
if tijdelijk and nog_tijd:
execute_query("UPDATE tweestaps_resets SET Pogingen = Pogingen + 1, Laatste_fout = %s WHERE ID = %s",
(self._friendly(e)[:255], rij['ID']), commit=True)
return 'bezig'
fout = (self._friendly(e, SECURITY_SCOPE) if nog_tijd else
"Google heeft de uitzondering niet toegepast voor ze afliep. Controleer of 'Afdwingen' op 'Uit' "
"staat voor de uitzonderingsgroep in admin.google.com en probeer opnieuw.")
execute_query("UPDATE tweestaps_resets SET Status = 'mislukt', Pogingen = Pogingen + 1, Laatste_fout = %s, "
"Klaar_op = NOW() WHERE ID = %s", (fout[:255], rij['ID']), commit=True)
self.log('automatisch', '2FA reset mislukt', email, fout[:255])
return 'mislukt'
execute_query("UPDATE tweestaps_resets SET Status = 'klaar', Pogingen = Pogingen + 1, Laatste_fout = NULL, "
"Klaar_op = NOW() WHERE ID = %s", (rij['ID'],), commit=True)
self.log(rij['Door'] or 'automatisch', '2FA gereset', email,
(rij['Reden'] or '') + f" (na {rij['Pogingen'] + 1} pogingen)")
self._lijst_cache = None
return 'klaar'
def verwerk_resets(self):
"""Alle wachtende resets van deze school één keer proberen (vangnet van de 5-minutenplanner)."""
n = 0
for rij in self.reset_bezig():
if self.probeer_reset(rij) == 'klaar':
n += 1
return n
def _volg_reset_op(self, rid):
from tenancy import met_school
svc = self
def loop():
while True:
time.sleep(RESET_INTERVAL)
try:
rows = execute_query("SELECT * FROM tweestaps_resets WHERE ID = %s", (rid,))
if not rows or rows[0]['Status'] != 'bezig':
return
if svc.probeer_reset(rows[0]) != 'bezig':
return
except Exception as e:
sys.stderr.write(f"2FA-reset {rid}: {e}\n")
threading.Thread(target=met_school(loop), daemon=True).start()
def end_exception(self, uitz_id, door):
rows = execute_query("SELECT Email FROM tweestaps_uitzonderingen WHERE ID = %s AND Actief = 1", (uitz_id,))
if not rows:
raise TweestapsError("Deze uitzondering is al beëindigd.")
email = rows[0]['Email']
self._remove_from_group(email)
execute_query("UPDATE tweestaps_uitzonderingen SET Actief = 0, Beeindigd_op = NOW(), Beeindigd_door = %s "
"WHERE ID = %s", (door, uitz_id), commit=True)
self.log(door, 'uitzondering beëindigd', email)
self._lijst_cache = None
return email
def expire_due(self):
"""Haalt iedereen met een verlopen uitzondering terug uit de groep. Geeft het aantal terug."""
if not self._lock.acquire(blocking=False):
return 0
try:
rows = execute_query("SELECT ID, Email FROM tweestaps_uitzonderingen WHERE Actief = 1 AND Tot <= NOW()")
klaar = 0
for r in rows:
try:
self._remove_from_group(r['Email'])
execute_query("UPDATE tweestaps_uitzonderingen SET Actief = 0, Beeindigd_op = NOW(), "
"Beeindigd_door = 'automatisch' WHERE ID = %s", (r['ID'],), commit=True)
self.log('automatisch', 'uitzondering verlopen', r['Email'])
klaar += 1
except Exception as e:
# blijft actief → volgende ronde opnieuw proberen
sys.stderr.write(f"2FA: kon {r['Email']} niet uit de groep halen: {e}\n")
if klaar:
self._lijst_cache = None
return klaar
finally:
self._lock.release()
@classmethod
def start_scheduler(cls):
"""Ruimt elke 5 minuten verlopen uitzonderingen op, voor elke school met een Google-sleutel."""
if TweestapsService._scheduler:
return
def loop():
time.sleep(20)
while True:
for school in alle_scholen():
if not school.google_sleutel:
continue
try:
with in_school(school):
svc = cls()
svc.ensure_tables()
svc.verwerk_resets()
n = svc.expire_due()
if n:
print(f"[{school.code}] 2FA: {n} verlopen uitzondering(en) opgeruimd.", flush=True)
except Exception as e:
sys.stderr.write(f"[{school.code}] 2FA scheduler: {e}\n")
time.sleep(CHECK_INTERVAL)
TweestapsService._scheduler = threading.Thread(target=loop, daemon=True)
TweestapsService._scheduler.start()
# ---------------- wie heeft 2FA nog niet ingesteld ----------------
def niet_ingesteld(self, force=False):
if not force and self._lijst_cache and time.time() - self._lijst_cache[0] < LIJST_CACHE:
return self._lijst_cache[1]
result = []
ous = [o.strip() for o in Config.TWEESTAPS_OUS.split(',') if o.strip()]
self.ontbrekende_ous = []
for ou in ous:
token = None
while True:
try:
resp = self._directory_api().users().list(
customer='my_customer', query=f"orgUnitPath='{ou}' isSuspended=false",
maxResults=500, pageToken=token, projection='basic',
fields='nextPageToken,users(primaryEmail,name/fullName,orgUnitPath,isEnrolledIn2Sv,'
'isEnforcedIn2Sv,archived,lastLoginTime,creationTime)').execute()
except Exception as e:
if 'INVALID_OU_ID' in str(e):
self.ontbrekende_ous.append(ou) # deze OU bestaat niet in Google: overslaan
break
raise
for u in resp.get('users', []):
if u.get('archived') or u.get('isEnrolledIn2Sv'):
continue
result.append({
'email': u['primaryEmail'].lower(),
'naam': (u.get('name') or {}).get('fullName', ''),
'ou': u.get('orgUnitPath', ''),
'afgedwongen': bool(u.get('isEnforcedIn2Sv')),
'laatste_login': (u.get('lastLoginTime') or '')[:10].replace('1970-01-01', ''),
'aangemaakt': (u.get('creationTime') or '')[:10],
})
token = resp.get('nextPageToken')
if not token:
break
if ous and len(self.ontbrekende_ous) == len(ous):
raise TweestapsError(self.ou_melding())
uniek = {r['email']: r for r in result}
lijst = sorted(uniek.values(), key=lambda r: (r['ou'], r['naam']))
self._lijst_cache = (time.time(), lijst)
return lijst
+173
View File
@@ -0,0 +1,173 @@
"""
Verzendlijsten (Google Groepen) van de huidige school beheren via de Admin SDK.
Directory API (scope admin.directory.group) → groepen en leden: bekijken, aanmaken, aanpassen
Groups Settings API (scope apps.groups.settings) → "wie mag e-mails sturen naar de lijst" (optioneel: ontbreekt de
scope, dan werkt de rest gewoon en wordt dit veld verborgen)
Beide scopes moeten in Google Admin › Beveiliging › API-beheer › Domeinbrede delegatie bij het serviceaccount staan.
"""
import re
import threading
from googleapiclient.discovery import build
from config import Config
from tenancy import google_credentials, school_singleton
GROUP_SCOPE = 'https://www.googleapis.com/auth/admin.directory.group'
SETTINGS_SCOPE = 'https://www.googleapis.com/auth/apps.groups.settings'
ROLLEN = [('MEMBER', 'Lid'), ('MANAGER', 'Beheerder'), ('OWNER', 'Eigenaar')]
WIE_MAILT = [
('ANYONE_CAN_POST', 'Iedereen, ook van buiten de school'),
('ALL_IN_DOMAIN_CAN_POST', 'Iedereen van de school'),
('ALL_MEMBERS_CAN_POST', 'Enkel leden van de lijst'),
('ALL_MANAGERS_CAN_POST', 'Enkel beheerders en eigenaars'),
]
EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
class VerzendlijstFout(Exception):
pass
def _uitleg(e):
"""Google-fout → begrijpelijke melding."""
t = str(e)
laag = t.lower()
if 'unauthorized_client' in laag or 'insufficient authentication scopes' in laag or 'access_denied' in laag:
return ("Google weigert de toegang tot de groepen. Voeg in Google Admin › Beveiliging › API-beheer › "
f"Domeinbrede delegatie bij het serviceaccount de scope {GROUP_SCOPE} toe "
f"(en voor 'wie mag mailen' ook {SETTINGS_SCOPE}). Een wijziging kan enkele minuten duren.")
if 'entity already exists' in laag or 'duplicate' in laag or 'member already exists' in laag:
return "Dat adres bestaat al (als groep, gebruiker of lid)."
if 'resource not found' in laag or 'notfound' in laag.replace(' ', ''):
return "Niet gevonden in Google (bestaat het adres of de groep wel?)."
if 'domain' in laag and ('not found' in laag or 'invalid' in laag):
return "Dat domein hoort niet bij jullie Google Workspace."
m = re.search(r'"message":\s*"([^"]+)"', t) or re.search(r'returned "([^"]+)"', t)
return f"Google: {m.group(1) if m else t[:300]}"
def adressen(tekst):
"""Komma-, puntkomma-, spatie- of regelgescheiden lijst → [geldige adressen], [ongeldige stukken]."""
goed, fout = [], []
for d in re.split(r'[\s,;]+', str(tekst or '')):
d = d.strip().strip('<>').lower()
if not d:
continue
(goed if EMAIL_RE.match(d) else fout).append(d)
return list(dict.fromkeys(goed)), fout
class VerzendlijstenService:
def __new__(cls):
def init(inst):
inst._clients = threading.local() # Google-clients zijn niet thread-safe: één per thread
return school_singleton(cls, init)
def _api(self, naam, scope, api, versie):
client = getattr(self._clients, naam, None)
if client is None:
creds = google_credentials([scope], subject=Config.GOOGLE_ADMIN_EMAIL)
client = build(api, versie, credentials=creds, static_discovery=False, cache_discovery=False)
setattr(self._clients, naam, client)
return client
def _dir(self):
return self._api('directory', GROUP_SCOPE, 'admin', 'directory_v1')
def _settings(self):
return self._api('settings', SETTINGS_SCOPE, 'groupssettings', 'v1')
def _uitvoeren(self, verzoek):
try:
return verzoek.execute()
except Exception as e:
raise VerzendlijstFout(_uitleg(e))
# ---------------- groepen ----------------
def alle(self):
uit, token = [], None
while True:
res = self._uitvoeren(self._dir().groups().list(customer='my_customer', maxResults=200, pageToken=token,
orderBy='email'))
for g in res.get('groups', []):
uit.append({'email': g.get('email', ''), 'naam': g.get('name', ''),
'beschrijving': g.get('description', ''),
'leden': int(g.get('directMembersCount') or 0), 'aliassen': g.get('aliases', [])})
token = res.get('nextPageToken')
if not token:
return uit
def groep(self, email):
g = self._uitvoeren(self._dir().groups().get(groupKey=email))
return {'email': g.get('email', ''), 'naam': g.get('name', ''), 'beschrijving': g.get('description', ''),
'leden': int(g.get('directMembersCount') or 0), 'aliassen': g.get('aliases', [])}
def aanmaken(self, email, naam, beschrijving=''):
email = (email or '').strip().lower()
if not EMAIL_RE.match(email):
raise VerzendlijstFout("Geef een geldig e-mailadres voor de verzendlijst.")
if not (naam or '').strip():
raise VerzendlijstFout("Geef de verzendlijst een naam.")
self._uitvoeren(self._dir().groups().insert(body={'email': email, 'name': naam.strip(),
'description': (beschrijving or '').strip()}))
return email
def bijwerken(self, email, naam, beschrijving):
if not (naam or '').strip():
raise VerzendlijstFout("De naam mag niet leeg zijn.")
self._uitvoeren(self._dir().groups().patch(groupKey=email, body={
'name': naam.strip(), 'description': (beschrijving or '').strip()}))
# ---------------- wie mag mailen (Groups Settings API) ----------------
def wie_mailt(self, email):
"""Huidige instelling, of None als de Groups Settings-scope (nog) ontbreekt."""
try:
s = self._settings().groups().get(groupUniqueId=email, alt='json').execute()
return s.get('whoCanPostMessage')
except Exception:
return None
def zet_wie_mailt(self, email, waarde):
if waarde not in dict(WIE_MAILT):
raise VerzendlijstFout("Onbekende keuze voor 'wie mag mailen'.")
body = {'whoCanPostMessage': waarde}
if waarde == 'ANYONE_CAN_POST':
body['allowExternalMembers'] = 'false' # extern mailen mag, extern lid worden niet automatisch
self._uitvoeren(self._settings().groups().patch(groupUniqueId=email, body=body))
# ---------------- leden ----------------
def leden(self, email):
uit, token = [], None
while True:
res = self._uitvoeren(self._dir().members().list(groupKey=email, maxResults=200, pageToken=token))
for m in res.get('members', []):
uit.append({'email': m.get('email', ''), 'rol': m.get('role', 'MEMBER'),
'soort': m.get('type', 'USER'), 'status': m.get('status', '')})
token = res.get('nextPageToken')
if not token:
volgorde = {'OWNER': 0, 'MANAGER': 1, 'MEMBER': 2}
return sorted(uit, key=lambda m: (volgorde.get(m['rol'], 3), m['email']))
def leden_toevoegen(self, email, lijst, rol='MEMBER'):
"""Geeft (toegevoegd, [(adres, fout)])."""
if rol not in dict(ROLLEN):
rol = 'MEMBER'
gelukt, fouten = 0, []
for adres in lijst:
try:
self._uitvoeren(self._dir().members().insert(groupKey=email, body={'email': adres, 'role': rol}))
gelukt += 1
except VerzendlijstFout as e:
fouten.append((adres, str(e)))
return gelukt, fouten
def lid_rol(self, email, lid, rol):
if rol not in dict(ROLLEN):
raise VerzendlijstFout("Onbekende rol.")
self._uitvoeren(self._dir().members().patch(groupKey=email, memberKey=lid, body={'role': rol}))
def lid_verwijderen(self, email, lid):
self._uitvoeren(self._dir().members().delete(groupKey=email, memberKey=lid))