PIVOT 1.1.0
This commit is contained in:
commit
8a53234eb6
161 files changed
+31109
No files matched your search
@@ -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"
|
||||
@@ -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)
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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()))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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),))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)}"
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
"""
|
||||
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}
|
||||
_tabel_klaar = {'ok': False} # tabel/kolommen/index maar één keer per proces nakijken
|
||||
|
||||
|
||||
def verzamel_ensure():
|
||||
from database import central_query
|
||||
if _tabel_klaar['ok']:
|
||||
_periode_bijwerken()
|
||||
return
|
||||
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),
|
||||
KEY idx_gopro_verz_soort (Soort, School_naam)
|
||||
) 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)
|
||||
indexen = {r['Key_name'] for r in central_query("SHOW INDEX FROM GoPro_verzameltabel") or []}
|
||||
if 'idx_gopro_verz_soort' not in indexen:
|
||||
central_query("ALTER TABLE GoPro_verzameltabel ADD KEY idx_gopro_verz_soort (Soort, School_naam)", commit=True)
|
||||
_tabel_klaar['ok'] = True
|
||||
_periode_bijwerken()
|
||||
|
||||
|
||||
def _periode_bijwerken():
|
||||
from database import central_query
|
||||
# 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 aantallen():
|
||||
"""{'groen': n, 'genegeerd': n, 'verzonden': n} in één query (in plaats van alle rijen op te halen)."""
|
||||
from database import central_query
|
||||
verzamel_ensure()
|
||||
return {r['Soort']: int(r['n']) for r in
|
||||
central_query("SELECT Soort, COUNT(*) AS n FROM GoPro_verzameltabel GROUP BY 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
|
||||
@@ -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 & 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>"<API> 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,
|
||||
},
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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 []
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,419 @@
|
||||
"""
|
||||
Beheer › Database › Toestellen: de tabel Toestellen aanvullen vanuit een Excel-bestand.
|
||||
|
||||
Werkwijze (twee stappen, zodat er niets in de database komt zonder dat je het gezien hebt):
|
||||
1. lees_excel() : het geüploade bestand → ruwe rijen (enkel tekst / ISO-datums)
|
||||
2. maak_plan() : ruwe rijen → per rij 'nieuw', 'bijwerken', 'ongewijzigd' of 'fout' (niets wordt bewaard)
|
||||
3. voer_uit() : het plan in één transactie uitvoeren (het plan wordt opnieuw gemaakt, nooit blind vertrouwd)
|
||||
|
||||
Een toestel wordt herkend aan het toestelnummer (Schooleigen_ID) en anders aan het serienummer.
|
||||
Er wordt nooit iets verwijderd. Lege cellen wissen nooit een bestaande waarde.
|
||||
"""
|
||||
import io
|
||||
from datetime import date, datetime
|
||||
|
||||
from database import execute_query, get_db_connection
|
||||
from services import toestellen as tst
|
||||
|
||||
STATUSSEN = ('Beschikbaar', 'Uitgeleend', 'Defect')
|
||||
MAX_RIJEN = 5000
|
||||
|
||||
# (sleutel, kolomkop in het sjabloon, max. lengte, uitleg, andere namen die ook herkend worden)
|
||||
KOLOMMEN = [
|
||||
('schooleigen_id', 'Toestelnummer', 50,
|
||||
"Het eigen toestelnummer van de school (Schooleigen_ID). Uniek. Toestelnummer of serienummer is verplicht.",
|
||||
('schooleigen_id', 'schooleigen id', 'kamxm_nummer', 'kamxm nummer', 'kamxm', 'nummer', 'toestel')),
|
||||
('serienummer', 'Serienummer', 100,
|
||||
"Serienummer van de fabrikant (nodig om het toestel in Google te blokkeren). "
|
||||
"Toestelnummer of serienummer is verplicht.",
|
||||
('serial', 'serial number', 'serienr', 'serie')),
|
||||
('type', 'Type', 100, "Model van het toestel, bv. Lenovo 300e Gen 4.", ('model', 'toesteltype')),
|
||||
('status', 'Status', None,
|
||||
"Beschikbaar, Uitgeleend of Defect. Leeg bij een nieuw toestel = Beschikbaar.", ()),
|
||||
('afschrijfdatum', 'Afschrijfdatum', None,
|
||||
"Datum (dd-mm-jjjj) of een Excel-datum. Mag leeg.", ('afschrijving', 'afschrijf datum')),
|
||||
('lokaal', 'Lokaal', None,
|
||||
"Naam van een lokaal dat al in PIVOT bestaat (zie het tabblad Lokalen). Mag leeg.", ('lokaalnaam', 'lokaal_id')),
|
||||
]
|
||||
SLEUTELS = [k[0] for k in KOLOMMEN]
|
||||
KOPPEN = {k[0]: k[1] for k in KOLOMMEN}
|
||||
|
||||
# Databasekolom per sleutel (lokaal → Lokaal_ID wordt apart opgezocht)
|
||||
DB_KOLOM = {'schooleigen_id': 'Schooleigen_ID', 'serienummer': 'Serienummer', 'type': 'Type',
|
||||
'status': 'Status', 'afschrijfdatum': 'Afschrijfdatum', 'lokaal': 'Lokaal_ID'}
|
||||
|
||||
|
||||
class ImportFout(Exception):
|
||||
"""Het bestand zelf kan niet gelezen worden (geen rijfout)."""
|
||||
|
||||
|
||||
def _norm(tekst):
|
||||
return ' '.join(str(tekst or '').strip().lower().replace('_', ' ').replace('-', ' ').split())
|
||||
|
||||
|
||||
_HERKEN = {}
|
||||
for _k, _kop, _m, _u, _alias in KOLOMMEN:
|
||||
for _naam in (_k, _kop) + tuple(_alias):
|
||||
_HERKEN[_norm(_naam)] = _k
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sjabloon
|
||||
# ---------------------------------------------------------------------------
|
||||
def lokalen():
|
||||
"""Alle lokalen van de huidige school: [{'Lokaal_ID', 'Naam', 'Campus'}]."""
|
||||
return execute_query(
|
||||
"SELECT l.Lokaal_ID, l.Naam, c.Campus_naam AS Campus FROM Lokalen l "
|
||||
"LEFT JOIN Campus c ON c.Campus_ID = l.Campus_ID ORDER BY l.Naam") or []
|
||||
|
||||
|
||||
def maak_sjabloon(schoolnaam=''):
|
||||
"""Excel-sjabloon (bytes): tabblad Toestellen (in te vullen), Uitleg en Lokalen."""
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
from openpyxl.worksheet.datavalidation import DataValidation
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = 'Toestellen'
|
||||
ws.append([k[1] for k in KOLOMMEN])
|
||||
kop_vulling = PatternFill('solid', fgColor='1E6FA5')
|
||||
for c in ws[1]:
|
||||
c.font = Font(bold=True, color='FFFFFF')
|
||||
c.fill = kop_vulling
|
||||
c.alignment = Alignment(vertical='center')
|
||||
breedtes = {'Toestelnummer': 16, 'Serienummer': 20, 'Type': 26, 'Status': 14, 'Afschrijfdatum': 16, 'Lokaal': 18}
|
||||
for i, k in enumerate(KOLOMMEN):
|
||||
letter = ws.cell(row=1, column=i + 1).column_letter
|
||||
ws.column_dimensions[letter].width = breedtes.get(k[1], 16)
|
||||
ws.freeze_panes = 'A2'
|
||||
ws.row_dimensions[1].height = 22
|
||||
|
||||
kol = {k[0]: ws.cell(row=1, column=i + 1).column_letter for i, k in enumerate(KOLOMMEN)}
|
||||
# Nummers als tekst (anders maakt Excel van 00123 → 123)
|
||||
for sleutel in ('schooleigen_id', 'serienummer'):
|
||||
for r in range(2, MAX_RIJEN + 2):
|
||||
ws[f"{kol[sleutel]}{r}"].number_format = '@'
|
||||
for r in range(2, MAX_RIJEN + 2):
|
||||
ws[f"{kol['afschrijfdatum']}{r}"].number_format = 'DD-MM-YYYY'
|
||||
|
||||
dv_status = DataValidation(type='list', formula1='"' + ','.join(STATUSSEN) + '"', allow_blank=True,
|
||||
showErrorMessage=True, errorTitle='Status',
|
||||
error='Kies Beschikbaar, Uitgeleend of Defect (of laat leeg).')
|
||||
dv_status.add(f"{kol['status']}2:{kol['status']}{MAX_RIJEN + 1}")
|
||||
ws.add_data_validation(dv_status)
|
||||
dv_datum = DataValidation(type='date', operator='greaterThan', formula1='1', allow_blank=True,
|
||||
showErrorMessage=True, errorTitle='Afschrijfdatum',
|
||||
error='Vul een datum in, bv. 31-08-2030.')
|
||||
dv_datum.add(f"{kol['afschrijfdatum']}2:{kol['afschrijfdatum']}{MAX_RIJEN + 1}")
|
||||
ws.add_data_validation(dv_datum)
|
||||
|
||||
# Lokalen van deze school (keuzelijst in de kolom Lokaal)
|
||||
wl = wb.create_sheet('Lokalen')
|
||||
wl.append(['Lokaal', 'Campus'])
|
||||
for c in wl[1]:
|
||||
c.font = Font(bold=True)
|
||||
lijst = []
|
||||
try:
|
||||
lijst = lokalen()
|
||||
except Exception:
|
||||
pass
|
||||
for l in lijst:
|
||||
wl.append([l['Naam'], l.get('Campus') or ''])
|
||||
wl.column_dimensions['A'].width = 24
|
||||
wl.column_dimensions['B'].width = 28
|
||||
if lijst:
|
||||
dv_lokaal = DataValidation(type='list', formula1=f"=Lokalen!$A$2:$A${len(lijst) + 1}", allow_blank=True,
|
||||
showErrorMessage=True, errorTitle='Lokaal',
|
||||
error='Kies een lokaal uit de lijst (tabblad Lokalen) of laat leeg.')
|
||||
dv_lokaal.add(f"{kol['lokaal']}2:{kol['lokaal']}{MAX_RIJEN + 1}")
|
||||
ws.add_data_validation(dv_lokaal)
|
||||
|
||||
# Uitleg
|
||||
wu = wb.create_sheet('Uitleg')
|
||||
wu.column_dimensions['A'].width = 18
|
||||
wu.column_dimensions['B'].width = 95
|
||||
wu.append([f"Toestellen importeren in PIVOT{' – ' + schoolnaam if schoolnaam else ''}"])
|
||||
wu['A1'].font = Font(bold=True, size=14)
|
||||
regels = [
|
||||
"Vul het tabblad 'Toestellen' in: één toestel per rij, vanaf rij 2. Laat de kolomkoppen in rij 1 staan.",
|
||||
"Een toestel wordt herkend aan het toestelnummer, en anders aan het serienummer.",
|
||||
"Bestaat het toestel nog niet, dan wordt het toegevoegd.",
|
||||
"Bestaat het al, dan worden (afhankelijk van je keuze bij het uploaden) enkel lege velden aangevuld "
|
||||
"of de ingevulde velden overschreven. Een lege cel wist nooit iets.",
|
||||
"Er wordt nooit een toestel verwijderd. Je krijgt eerst een voorbeeld te zien voordat er iets bewaard wordt.",
|
||||
f"Maximaal {MAX_RIJEN} rijen per bestand.",
|
||||
]
|
||||
for t in regels:
|
||||
wu.append(['', t])
|
||||
wu.append([])
|
||||
wu.append(['Kolom', 'Uitleg'])
|
||||
for c in wu[wu.max_row]:
|
||||
c.font = Font(bold=True)
|
||||
for _k, kop, maxlen, uitleg, _a in KOLOMMEN:
|
||||
wu.append([kop, uitleg + (f" Max. {maxlen} tekens." if maxlen else '')])
|
||||
for rij in wu.iter_rows(min_row=2):
|
||||
for c in rij:
|
||||
c.alignment = Alignment(wrap_text=True, vertical='top')
|
||||
wu.append([])
|
||||
wu.append(['Voorbeeld', 'Toestelnummer CB-0123 · Serienummer PF3ABC12 · Type Lenovo 300e Gen 4 · '
|
||||
'Status Beschikbaar · Afschrijfdatum 31-08-2030 · Lokaal A101'])
|
||||
|
||||
wb.active = 0
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Excel lezen
|
||||
# ---------------------------------------------------------------------------
|
||||
def _cel(waarde):
|
||||
"""Celwaarde → tekst (datums als ISO 'jjjj-mm-dd', 123.0 → '123')."""
|
||||
if waarde is None:
|
||||
return ''
|
||||
if isinstance(waarde, datetime):
|
||||
return waarde.date().isoformat()
|
||||
if isinstance(waarde, date):
|
||||
return waarde.isoformat()
|
||||
if isinstance(waarde, float) and waarde.is_integer():
|
||||
return str(int(waarde))
|
||||
return str(waarde).strip()
|
||||
|
||||
|
||||
def lees_excel(bestand):
|
||||
"""Leest een .xlsx (bestandsobject of bytes). Geeft (rijen, onbekende_kolommen).
|
||||
rijen = [{'rij': <rijnummer in Excel>, 'schooleigen_id': '...', ...}] (enkel niet-lege rijen)."""
|
||||
from openpyxl import load_workbook
|
||||
data = bestand if isinstance(bestand, (bytes, bytearray)) else bestand.read()
|
||||
if not data:
|
||||
raise ImportFout("Het bestand is leeg.")
|
||||
try:
|
||||
wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
||||
except Exception:
|
||||
raise ImportFout("Dit bestand kan niet gelezen worden. Gebruik een Excel-bestand (.xlsx), "
|
||||
"bv. het sjabloon van deze pagina.")
|
||||
try:
|
||||
ws = wb['Toestellen'] if 'Toestellen' in wb.sheetnames else wb.worksheets[0]
|
||||
rijen_iter = ws.iter_rows(values_only=True)
|
||||
# Kopregel zoeken in de eerste 10 rijen: de eerste rij met een herkende kolom
|
||||
kop_index, onbekend, rijnr = None, [], 0
|
||||
for rij in rijen_iter:
|
||||
rijnr += 1
|
||||
gevonden = {i: _HERKEN[_norm(v)] for i, v in enumerate(rij or ()) if _norm(v) in _HERKEN}
|
||||
if gevonden:
|
||||
kop_index = {}
|
||||
for i, sleutel in gevonden.items():
|
||||
kop_index.setdefault(sleutel, i) # dubbele kolom: de eerste telt
|
||||
onbekend = [_cel(v) for i, v in enumerate(rij) if _cel(v) and i not in gevonden]
|
||||
break
|
||||
if rijnr >= 10:
|
||||
break
|
||||
if not kop_index:
|
||||
raise ImportFout("Geen kolomkoppen gevonden. Rij 1 moet de kolomnamen van het sjabloon bevatten "
|
||||
f"({', '.join(KOPPEN.values())}).")
|
||||
if 'schooleigen_id' not in kop_index and 'serienummer' not in kop_index:
|
||||
raise ImportFout("De kolom Toestelnummer of Serienummer ontbreekt.")
|
||||
|
||||
uit = []
|
||||
for rij in rijen_iter:
|
||||
rijnr += 1
|
||||
waarden = {s: _cel(rij[i]) if rij and i < len(rij) else '' for s, i in kop_index.items()}
|
||||
if not any(waarden.values()):
|
||||
continue
|
||||
if len(uit) >= MAX_RIJEN:
|
||||
raise ImportFout(f"Het bestand bevat meer dan {MAX_RIJEN} rijen. Splits het op in kleinere bestanden.")
|
||||
uit.append({'rij': rijnr, **{s: waarden.get(s, '') for s in SLEUTELS}})
|
||||
return uit, onbekend
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plan maken (niets bewaren)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _datum_tekst(w):
|
||||
"""'2030-08-31' / '31-08-2030' / '31/08/2030' → date, of ValueError."""
|
||||
w = (w or '').strip()
|
||||
if not w:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(w[:10]) if len(w) >= 10 and w[4] == '-' else tst.lees_datum(w)
|
||||
except ValueError:
|
||||
return tst.lees_datum(w)
|
||||
|
||||
|
||||
def _toon(w):
|
||||
if isinstance(w, date):
|
||||
return w.strftime('%d-%m-%Y')
|
||||
return '' if w is None else str(w)
|
||||
|
||||
|
||||
def maak_plan(rijen, modus='aanvullen'):
|
||||
"""Bepaalt per rij wat er zou gebeuren. modus: 'aanvullen' (enkel lege velden) of 'overschrijven'.
|
||||
Geeft {'rijen': [...], 'tellers': {...}}; elke rij heeft 'actie', 'meldingen' en 'waarden' (voor de database)."""
|
||||
overschrijven = modus == 'overschrijven'
|
||||
bestaand = execute_query("SELECT Toestel_ID, Schooleigen_ID, Serienummer, Type, Status, Afschrijfdatum, Lokaal_ID "
|
||||
"FROM Toestellen") or []
|
||||
op_nummer = {(r['Schooleigen_ID'] or '').strip().upper(): r for r in bestaand if (r['Schooleigen_ID'] or '').strip()}
|
||||
op_serie = {}
|
||||
for r in bestaand:
|
||||
s = (r['Serienummer'] or '').strip().upper()
|
||||
if s:
|
||||
op_serie.setdefault(s, r)
|
||||
lok = {}
|
||||
for l in lokalen():
|
||||
lok.setdefault((l['Naam'] or '').strip().upper(), l)
|
||||
lok_naam = {l['Lokaal_ID']: l['Naam'] for l in lok.values()}
|
||||
|
||||
gezien_nummer, gezien_serie = {}, {}
|
||||
uit = []
|
||||
tellers = {'nieuw': 0, 'bijwerken': 0, 'ongewijzigd': 0, 'fout': 0}
|
||||
|
||||
for invoer in rijen:
|
||||
r = {'rij': invoer.get('rij'), 'invoer': {s: str(invoer.get(s) or '').strip() for s in SLEUTELS},
|
||||
'meldingen': [], 'wijzigingen': [], 'actie': None, 'toestel_id': None, 'waarden': {}}
|
||||
v = r['invoer']
|
||||
fouten = []
|
||||
|
||||
# --- waarden controleren ---
|
||||
nummer, serie = v['schooleigen_id'], v['serienummer']
|
||||
if not nummer and not serie:
|
||||
fouten.append("Toestelnummer of serienummer is verplicht.")
|
||||
for s in ('schooleigen_id', 'serienummer', 'type'):
|
||||
maxlen = next(k[2] for k in KOLOMMEN if k[0] == s)
|
||||
if len(v[s]) > maxlen:
|
||||
fouten.append(f"{KOPPEN[s]} is te lang (max. {maxlen} tekens).")
|
||||
status = None
|
||||
if v['status']:
|
||||
status = next((x for x in STATUSSEN if x.lower() == v['status'].lower()), None)
|
||||
if not status:
|
||||
fouten.append(f"Status '{v['status']}' is ongeldig (kies {', '.join(STATUSSEN)}).")
|
||||
afschrijf = None
|
||||
if v['afschrijfdatum']:
|
||||
try:
|
||||
afschrijf = _datum_tekst(v['afschrijfdatum'])
|
||||
except ValueError:
|
||||
fouten.append(f"Afschrijfdatum '{v['afschrijfdatum']}' is geen geldige datum (gebruik dd-mm-jjjj).")
|
||||
lokaal_id = None
|
||||
if v['lokaal']:
|
||||
l = lok.get(v['lokaal'].upper())
|
||||
if l:
|
||||
lokaal_id = l['Lokaal_ID']
|
||||
else:
|
||||
fouten.append(f"Lokaal '{v['lokaal']}' bestaat niet in PIVOT.")
|
||||
|
||||
# --- dubbels in het bestand (enkel t.o.v. eerdere rijen zonder fout) ---
|
||||
if nummer and gezien_nummer.get(nummer.upper()):
|
||||
fouten.append(f"Toestelnummer {nummer} staat al op rij {gezien_nummer[nummer.upper()]}.")
|
||||
if serie and gezien_serie.get(serie.upper()):
|
||||
fouten.append(f"Serienummer {serie} staat al op rij {gezien_serie[serie.upper()]}.")
|
||||
|
||||
# --- bestaand toestel? ---
|
||||
t_nummer = op_nummer.get(nummer.upper()) if nummer else None
|
||||
t_serie = op_serie.get(serie.upper()) if serie else None
|
||||
toestel = t_nummer or t_serie
|
||||
if t_nummer and t_serie and t_nummer['Toestel_ID'] != t_serie['Toestel_ID']:
|
||||
fouten.append(f"Toestelnummer {nummer} en serienummer {serie} horen in PIVOT bij twee verschillende "
|
||||
f"toestellen. Pas dit eerst aan in 💻 Alle toestellen.")
|
||||
elif toestel:
|
||||
db_nummer = (toestel['Schooleigen_ID'] or '').strip()
|
||||
db_serie = (toestel['Serienummer'] or '').strip()
|
||||
if t_nummer and serie and db_serie and db_serie.upper() != serie.upper():
|
||||
fouten.append(f"Toestel {nummer} heeft in PIVOT serienummer {db_serie}, niet {serie}.")
|
||||
if not t_nummer and nummer and db_nummer and db_nummer.upper() != nummer.upper():
|
||||
fouten.append(f"Serienummer {serie} hoort in PIVOT bij toestel {db_nummer}, niet bij {nummer}.")
|
||||
|
||||
if fouten:
|
||||
r['actie'] = 'fout'
|
||||
r['meldingen'] = fouten
|
||||
tellers['fout'] += 1
|
||||
uit.append(r)
|
||||
continue
|
||||
|
||||
if nummer:
|
||||
gezien_nummer[nummer.upper()] = r['rij']
|
||||
if serie:
|
||||
gezien_serie[serie.upper()] = r['rij']
|
||||
|
||||
nieuw = {'schooleigen_id': nummer or None, 'serienummer': serie or None, 'type': v['type'] or None,
|
||||
'status': status, 'afschrijfdatum': afschrijf, 'lokaal': lokaal_id}
|
||||
|
||||
if not toestel:
|
||||
nieuw['status'] = status or 'Beschikbaar'
|
||||
r['actie'] = 'nieuw'
|
||||
r['waarden'] = {DB_KOLOM[s]: w for s, w in nieuw.items() if w is not None}
|
||||
tellers['nieuw'] += 1
|
||||
uit.append(r)
|
||||
continue
|
||||
|
||||
# --- bijwerken ---
|
||||
r['toestel_id'] = toestel['Toestel_ID']
|
||||
r['label'] = toestel['Schooleigen_ID'] or toestel['Serienummer'] or str(toestel['Toestel_ID'])
|
||||
overgeslagen = []
|
||||
for s, w in nieuw.items():
|
||||
if w is None:
|
||||
continue
|
||||
kolom = DB_KOLOM[s]
|
||||
huidig = toestel[kolom]
|
||||
if isinstance(huidig, datetime):
|
||||
huidig = huidig.date()
|
||||
gelijk = (str(huidig).strip().upper() == str(w).strip().upper()) if huidig not in (None, '') else False
|
||||
if gelijk:
|
||||
continue
|
||||
leeg = huidig is None or (isinstance(huidig, str) and not huidig.strip())
|
||||
if s == 'status' and not overschrijven:
|
||||
leeg = False # status heeft altijd een waarde: enkel wijzigen bij overschrijven
|
||||
if leeg or overschrijven:
|
||||
r['waarden'][kolom] = w
|
||||
oud = lok_naam.get(huidig, huidig) if s == 'lokaal' else huidig
|
||||
nw = lok_naam.get(w, w) if s == 'lokaal' else w
|
||||
r['wijzigingen'].append(f"{KOPPEN[s]}: {_toon(oud) or '–'} → {_toon(nw)}")
|
||||
else:
|
||||
oud = lok_naam.get(huidig, huidig) if s == 'lokaal' else huidig
|
||||
overgeslagen.append(f"{KOPPEN[s]} ({_toon(oud)})")
|
||||
if overgeslagen:
|
||||
r['meldingen'].append("Niet overschreven: " + ', '.join(overgeslagen) + '.')
|
||||
if r['waarden']:
|
||||
r['actie'] = 'bijwerken'
|
||||
tellers['bijwerken'] += 1
|
||||
else:
|
||||
r['actie'] = 'ongewijzigd'
|
||||
tellers['ongewijzigd'] += 1
|
||||
uit.append(r)
|
||||
|
||||
return {'rijen': uit, 'tellers': tellers, 'modus': 'overschrijven' if overschrijven else 'aanvullen'}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Uitvoeren
|
||||
# ---------------------------------------------------------------------------
|
||||
def voer_uit(rijen, modus, gebruiker=None):
|
||||
"""Maakt het plan opnieuw en voert 'nieuw' en 'bijwerken' uit in één transactie.
|
||||
Geeft (plan, aantal_nieuw, aantal_bijgewerkt). Bij een databasefout wordt niets bewaard."""
|
||||
plan = maak_plan(rijen, modus)
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
nieuw = bijgewerkt = 0
|
||||
try:
|
||||
conn.start_transaction()
|
||||
for r in plan['rijen']:
|
||||
w = r['waarden']
|
||||
if r['actie'] == 'nieuw':
|
||||
kolommen = list(w.keys())
|
||||
cur.execute(f"INSERT INTO Toestellen ({', '.join(kolommen)}) "
|
||||
f"VALUES ({', '.join(['%s'] * len(kolommen))})", tuple(w[k] for k in kolommen))
|
||||
nieuw += 1
|
||||
elif r['actie'] == 'bijwerken':
|
||||
kolommen = list(w.keys())
|
||||
cur.execute(f"UPDATE Toestellen SET {', '.join(k + ' = %s' for k in kolommen)} WHERE Toestel_ID = %s",
|
||||
tuple(w[k] for k in kolommen) + (r['toestel_id'],))
|
||||
bijgewerkt += 1
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
return plan, nieuw, bijgewerkt
|
||||
@@ -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
|
||||
@@ -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))
|
||||
Reference in new issue
Block a user