620 lines
30 KiB
Python
620 lines
30 KiB
Python
"""
|
||
GO! Pro: personeelsleden van de actieve school uit Informat (Personeel WEB API, versie 2).
|
||
|
||
- GET /employees → naam, voornaam, isActive, comnrs (telefoonnummers), ...
|
||
- GET /employees/assignments → opdrachten met ambt (functie), begindatum en einddatum
|
||
Scope: api_informat_sas_personeel.personeel.{instellingsnummer} (zelfde client-ID/secret als de leerlingen-API,
|
||
maar de client moet ook deze scope hebben).
|
||
|
||
Per personeelslid komt er één rij. Functie en rol kies je in PIVOT uit dezelfde lijst (standaard Leerkracht,
|
||
een directeur Directeur) en worden per personeelslid bewaard in de schooldatabase (tabel gopro_keuzes).
|
||
"""
|
||
import re
|
||
import unicodedata
|
||
from datetime import date, datetime, timezone
|
||
|
||
import requests
|
||
|
||
from config import Config
|
||
from database import execute_query
|
||
from services.sync_service import informat_get
|
||
|
||
ROLLEN = [
|
||
'Algemeen Directeur', 'Coordinerend Directeur', 'Communicatie', 'Financiën', 'Infrastructuur', 'HR', 'ICT',
|
||
'Preventie en veiligheid', 'MVD', 'Administratief', 'RVB-lid', 'RVB', 'Voorzitter RVB', 'Beleidsondersteuner',
|
||
'Directeur Coordinator', 'Directeur', 'Internationalisering', 'Leerkracht', 'GO Studio Schoolbeheerder',
|
||
'Medewerker SGR', 'Adjunct-directeur', 'Administratief school', 'Beleidsondersteuner school',
|
||
]
|
||
STANDAARD_ROL = 'Leerkracht'
|
||
|
||
|
||
|
||
def standaard_rol(ambten):
|
||
"""Functie en rol zolang er in PIVOT niets gekozen is: een directeur blijft Directeur (adjunct-directeur blijft
|
||
Adjunct-directeur), alle anderen Leerkracht. `ambten` = de ambten uit Informat (lopende opdrachten + hoofdambt)."""
|
||
tekst = ' '.join(a.lower() for a in ambten if a)
|
||
if 'adjunct' in tekst and 'directeur' in tekst:
|
||
return 'Adjunct-directeur'
|
||
if 'directeur' in tekst:
|
||
return 'Directeur'
|
||
return STANDAARD_ROL
|
||
|
||
|
||
# Kolommen van het GO! Pro-importbestand (zelfde namen en volgorde)
|
||
EXPORT_KOLOMMEN = ['Naam', 'Achternaam', 'AanmeldEmailadres', 'UPN', 'CommunicatiefEmailadres',
|
||
'OnderwijsinstellingNr', 'Functie', 'rol', 'StartDatum', 'EindDatum', 'ProfessioneelTelefoonNr']
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# Hulpfuncties
|
||
# ---------------------------------------------------------------------
|
||
def naamdeel(tekst):
|
||
"""'Van den Broeck' → 'vandenbroeck', 'Zoë' → 'zoe': kleine letters, geen accenten, spaties of leestekens
|
||
(een koppelteken blijft staan)."""
|
||
t = unicodedata.normalize('NFKD', str(tekst or '')).encode('ascii', 'ignore').decode()
|
||
return re.sub(r'[^a-z0-9-]', '', t.lower()).strip('-')
|
||
|
||
|
||
def upn_van(voornaam, achternaam):
|
||
v, a = naamdeel(voornaam), naamdeel(achternaam)
|
||
return f"{v}.{a}" if v and a else (v or a)
|
||
|
||
|
||
def _datum(waarde):
|
||
"""'2026-09-01T00:00:00' → date; leeg of ongeldig → None."""
|
||
if not waarde:
|
||
return None
|
||
try:
|
||
return datetime.strptime(str(waarde)[:10], '%Y-%m-%d').date()
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _lijst(data, *sleutels):
|
||
if isinstance(data, list):
|
||
return data
|
||
for s in sleutels:
|
||
if isinstance(data, dict) and isinstance(data.get(s), list):
|
||
return data[s]
|
||
return []
|
||
|
||
|
||
def _telefoon(personeelslid):
|
||
"""Enkel een professioneel nummer (type 'Werk'): privénummers gaan niet naar GO! Pro."""
|
||
for c in personeelslid.get('comnrs') or []:
|
||
if str(c.get('type') or '').strip().lower() == 'werk' and c.get('nr'):
|
||
return str(c['nr']).strip()
|
||
return ''
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# Keuzes voor functie en rol (schooldatabase)
|
||
# ---------------------------------------------------------------------
|
||
VELDEN = ('functie', 'rol', 'email', 'upn')
|
||
ADRES_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
|
||
|
||
|
||
def ensure_tables():
|
||
execute_query("""
|
||
CREATE TABLE IF NOT EXISTS gopro_keuzes (
|
||
Person_ID VARCHAR(64) NOT NULL PRIMARY KEY,
|
||
Functie VARCHAR(100) NULL,
|
||
Rol VARCHAR(100) NULL,
|
||
Email VARCHAR(255) NULL,
|
||
UPN VARCHAR(255) NULL,
|
||
Bijgewerkt DATETIME NOT NULL,
|
||
Door VARCHAR(255) NULL
|
||
)""", commit=True)
|
||
# Bestaande tabel van vóór de bewerkbare e-mail/UPN
|
||
kolommen = {r['Field'].lower() for r in execute_query("SHOW COLUMNS FROM gopro_keuzes") or []}
|
||
for kolom in ('Email', 'UPN'):
|
||
if kolom.lower() not in kolommen:
|
||
execute_query(f"ALTER TABLE gopro_keuzes ADD COLUMN {kolom} VARCHAR(255) NULL AFTER Rol", commit=True)
|
||
|
||
|
||
def keuzes():
|
||
"""{person_id: {'functie': ..., 'rol': ...}} met wat in PIVOT gekozen is (None = niets gekozen).
|
||
Rollen uit de vroegere tabel gopro_rollen (indien die bestaat) tellen mee als er nog geen nieuwe keuze is."""
|
||
ensure_tables()
|
||
uit = {r['Person_ID']: {'functie': r['Functie'], 'rol': r['Rol'], 'email': r['Email'], 'upn': r['UPN']}
|
||
for r in execute_query("SELECT Person_ID, Functie, Rol, Email, UPN FROM gopro_keuzes") or []}
|
||
if execute_query("SHOW TABLES LIKE 'gopro_rollen'"):
|
||
for r in execute_query("SELECT Person_ID, Rol FROM gopro_rollen ORDER BY Bijgewerkt DESC") or []:
|
||
k = uit.setdefault(r['Person_ID'], {'functie': None, 'rol': None, 'email': None, 'upn': None})
|
||
k['rol'] = k['rol'] or r['Rol']
|
||
return uit
|
||
|
||
|
||
def keuze_opslaan(person_id, veld, waarde, door=None):
|
||
"""Bewaart één keuze. Voor e-mail en UPN: leeg = terug naar het standaardadres (voornaam.achternaam@domein)."""
|
||
if veld not in VELDEN:
|
||
raise ValueError(f"Onbekend veld '{veld}'")
|
||
if not person_id:
|
||
raise ValueError("Geen personeelslid opgegeven")
|
||
if veld in ('email', 'upn'):
|
||
waarde = (waarde or '').strip().lower() or None
|
||
if waarde and not ADRES_RE.match(waarde):
|
||
raise ValueError(f"'{waarde}' is geen geldig e-mailadres.")
|
||
elif waarde not in ROLLEN:
|
||
raise ValueError(f"Onbekende waarde '{waarde}'")
|
||
ensure_tables()
|
||
kolom = {'functie': 'Functie', 'rol': 'Rol', 'email': 'Email', 'upn': 'UPN'}[veld]
|
||
execute_query(
|
||
f"INSERT INTO gopro_keuzes (Person_ID, {kolom}, Bijgewerkt, Door) VALUES (%s, %s, NOW(), %s) "
|
||
f"ON DUPLICATE KEY UPDATE {kolom} = VALUES({kolom}), Bijgewerkt = NOW(), Door = VALUES(Door)",
|
||
(person_id, waarde, door), commit=True)
|
||
|
||
|
||
def pas_keuzes_toe(rij, gekozen):
|
||
"""Zet functie en rol van een rij: wat in PIVOT gekozen is, anders de standaard uit Informat."""
|
||
k = gekozen.get(rij['person_id']) or {}
|
||
rij['functie'] = k.get('functie') or rij['standaard']
|
||
rij['rol'] = k.get('rol') or rij['standaard']
|
||
rij['email'] = k.get('email') or rij['standaard_email']
|
||
rij['upn'] = k.get('upn') or rij['standaard_upn']
|
||
rij['email_aangepast'] = bool(k.get('email'))
|
||
rij['upn_aangepast'] = bool(k.get('upn'))
|
||
return rij
|
||
|
||
|
||
def markeer_dubbel(rijen):
|
||
"""Rijen waarvan het e-mailadres ook bij een ander personeelslid staat (bv. zelfde naam) krijgen 'dubbel'."""
|
||
per_mail = {}
|
||
for r in rijen:
|
||
per_mail.setdefault(r['email'], set()).add(r['person_id'])
|
||
for r in rijen:
|
||
r['dubbel'] = bool(r['email']) and len(per_mail[r['email']]) > 1
|
||
return rijen
|
||
|
||
|
||
def google_kleur(rij, google):
|
||
"""'account' als e-mail of UPN een account is in Google, 'alias' als één ervan een alias is, anders 'geen'.
|
||
Zelfde regel als de kleuren op de pagina."""
|
||
soorten = [google.get((rij.get(v) or '').strip().lower()) for v in ('email', 'upn')]
|
||
return 'account' if 'primair' in soorten else 'alias' if 'alias' in soorten else 'geen'
|
||
|
||
|
||
def google_adressen(vernieuw=False):
|
||
"""{adres: 'primair' | 'alias'} voor alle gebruikers in Google Workspace van de huidige school, 10 minuten
|
||
bewaard. Gebruikt de bestaande Directory-koppeling (scope admin.directory.user)."""
|
||
import time
|
||
from tenancy import huidige_school
|
||
school = huidige_school()
|
||
bewaard = school.cache.get('gopro_google')
|
||
if bewaard and not vernieuw and time.time() - bewaard[0] < 600:
|
||
return bewaard[1]
|
||
from services.google_service import GoogleAdminService
|
||
users = GoogleAdminService().service.users()
|
||
adressen, token = {}, None
|
||
while True:
|
||
res = users.list(customer='my_customer', maxResults=500, pageToken=token,
|
||
fields='nextPageToken,users(primaryEmail,aliases)').execute()
|
||
for u in res.get('users', []):
|
||
for a in u.get('aliases') or []:
|
||
adressen.setdefault(str(a).lower(), 'alias')
|
||
if u.get('primaryEmail'):
|
||
adressen[str(u['primaryEmail']).lower()] = 'primair'
|
||
token = res.get('nextPageToken')
|
||
if not token:
|
||
break
|
||
school.cache['gopro_google'] = (time.time(), adressen)
|
||
return adressen
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# Informat
|
||
# ---------------------------------------------------------------------
|
||
class GoProService:
|
||
def __init__(self):
|
||
self.api = str(Config.INFORMAT_PERSONEEL_API or '').rstrip('/')
|
||
self.scope_basis = re.sub(r'\.\d+$', '', str(Config.INFORMAT_PERSONEEL_SCOPE or '').strip())
|
||
self.nummers = [n for n, _ in (Config.INFORMAT_CAMPUSSEN or [])] or [Config.INFORMAT_INST_NUMBER]
|
||
|
||
def _token(self, nummer):
|
||
try:
|
||
r = requests.post(Config.INFORMAT_AUTH_URL, timeout=15, data={
|
||
'grant_type': 'client_credentials', 'client_id': Config.INFORMAT_ID,
|
||
'client_secret': Config.INFORMAT_SECRET, 'scope': f"{self.scope_basis}.{nummer}"})
|
||
except requests.RequestException as e:
|
||
raise RuntimeError(f"Informat niet bereikbaar: {e}")
|
||
try:
|
||
antwoord = r.json()
|
||
except ValueError:
|
||
antwoord = {}
|
||
if r.status_code == 200 and antwoord.get('access_token'):
|
||
return antwoord['access_token']
|
||
# Het echte antwoord van de Informat-aanmeldserver tonen (bv. invalid_scope of invalid_client)
|
||
reden = ' – '.join(str(antwoord[k]) for k in ('error', 'error_description') if antwoord.get(k)) \
|
||
or r.text[:200] or f"HTTP {r.status_code}"
|
||
uitleg = {
|
||
'invalid_scope': f"de client heeft de scope {self.scope_basis}.{nummer} (nog) niet.",
|
||
'invalid_client': "de client-ID of het client-secret van deze school klopt niet.",
|
||
'unauthorized_client': "deze client mag zich niet op deze manier aanmelden.",
|
||
}.get(antwoord.get('error'), "")
|
||
raise RuntimeError(
|
||
f"Aanmelden bij de Informat Personeel-API mislukt voor instellingsnummer {nummer} "
|
||
f"(scope {self.scope_basis}.{nummer}). Informat antwoordt: {reden}" + (f". Dat betekent: {uitleg}" if uitleg else ""))
|
||
|
||
def _get(self, pad, nummer, token, schooljaar):
|
||
headers = {'Authorization': f'BEARER {token}', 'InstituteNo': nummer, 'Api-Version': '2',
|
||
'Accept': 'application/json',
|
||
'Timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000Z')}
|
||
r = informat_get(f"{self.api}{pad}?schoolYear={schooljaar}", headers)
|
||
if r.status_code in (401, 403):
|
||
raise RuntimeError(f"Informat weigert de toegang tot {pad} (instellingsnummer {nummer}). Heeft de client "
|
||
f"de scope {self.scope_basis}.{nummer}?")
|
||
if r.status_code != 200:
|
||
raise RuntimeError(f"Informat Personeel-API fout {r.status_code} op {pad} "
|
||
f"(instellingsnummer {nummer}): {r.text[:300]}")
|
||
return r.json()
|
||
|
||
def personeel(self, domein, instellingsnummer_officieel='', schooljaar=None, vandaag=None):
|
||
"""Lijst van rijen (dicts) voor het scherm en de export, gesorteerd op achternaam en voornaam."""
|
||
schooljaar = schooljaar or Config.SCHOOLYEAR
|
||
vandaag = vandaag or date.today()
|
||
domein = (domein or '').lstrip('@')
|
||
gekozen = keuzes()
|
||
rijen, gezien = [], set()
|
||
|
||
for nummer in self.nummers:
|
||
token = self._token(nummer)
|
||
leden = _lijst(self._get('/employees', nummer, token, schooljaar), 'employees', 'items', 'data')
|
||
opdrachten = _lijst(self._get('/employees/assignments', nummer, token, schooljaar),
|
||
'assignments', 'items', 'data')
|
||
|
||
# Lopende opdrachten per personeelslid (alle ambten samen): vroegste begin, laatste einde
|
||
per = {}
|
||
for o in opdrachten:
|
||
begin, einde = _datum(o.get('begindatum')), _datum(o.get('einddatum'))
|
||
if begin and begin > vandaag or einde and einde < vandaag:
|
||
continue
|
||
f = per.setdefault(str(o.get('personId')), {'begin': begin, 'einde': einde, 'open': einde is None,
|
||
'ambten': set()})
|
||
f['ambten'].add((o.get('ambt') or '').strip())
|
||
if begin and (not f['begin'] or begin < f['begin']):
|
||
f['begin'] = begin
|
||
if einde is None:
|
||
f['open'] = True
|
||
elif not f['open'] and (not f['einde'] or einde > f['einde']):
|
||
f['einde'] = einde
|
||
|
||
for p in leden:
|
||
pid = str(p.get('personId') or p.get('pPersoon') or '')
|
||
if not pid or p.get('isActive') is False or p.get('isOverleden'):
|
||
continue
|
||
voornaam, achternaam = (p.get('voornaam') or '').strip(), (p.get('naam') or '').strip()
|
||
# Eén rij per persoon, ook als die in meer dan één instellingsnummer voorkomt
|
||
# (daar kan Informat een ander personId geven: dan herkennen we de persoon aan naam + geboortedatum)
|
||
geboren = str(p.get('geboortedatum') or '')[:10]
|
||
persoon = (naamdeel(voornaam), naamdeel(achternaam), geboren) if geboren else ('pid', pid)
|
||
f = per.get(pid) or {'begin': None, 'einde': None, 'open': True, 'ambten': set()}
|
||
ambten = f['ambten'] | {((p.get('hoofdAmbt') or {}).get('naam') or '').strip()}
|
||
if pid in gezien or persoon in gezien:
|
||
bestaand = next(r for r in rijen if r['person_id'] == pid or r['_persoon'] == persoon)
|
||
if f['begin'] and (not bestaand['start'] or f['begin'] < bestaand['start']):
|
||
bestaand['start'] = f['begin']
|
||
if f['open']:
|
||
bestaand['einde'] = None
|
||
elif bestaand['einde'] and f['einde'] and f['einde'] > bestaand['einde']:
|
||
bestaand['einde'] = f['einde']
|
||
bestaand['telefoon'] = bestaand['telefoon'] or _telefoon(p)
|
||
nieuw = standaard_rol(ambten) # bv. directeur in het tweede instellingsnummer
|
||
if nieuw != STANDAARD_ROL and bestaand['standaard'] == STANDAARD_ROL:
|
||
bestaand['standaard'] = nieuw
|
||
pas_keuzes_toe(bestaand, gekozen)
|
||
continue
|
||
gezien.update((pid, persoon))
|
||
upn = upn_van(voornaam, achternaam)
|
||
rijen.append({
|
||
'id': pid,
|
||
'person_id': pid,
|
||
'_persoon': persoon,
|
||
'voornaam': voornaam,
|
||
'achternaam': achternaam,
|
||
'standaard_email': f"{upn}@{domein}" if upn and domein else '',
|
||
'standaard_upn': f"{upn}@{domein}" if upn and domein else upn, # UPN = het volledige e-mailadres
|
||
'instellingsnummer': instellingsnummer_officieel or nummer,
|
||
'standaard': standaard_rol(ambten),
|
||
'start': f['begin'],
|
||
'einde': None if f['open'] else f['einde'],
|
||
'telefoon': _telefoon(p),
|
||
})
|
||
pas_keuzes_toe(rijen[-1], gekozen)
|
||
|
||
markeer_dubbel(rijen)
|
||
rijen.sort(key=lambda r: (naamdeel(r['achternaam']), naamdeel(r['voornaam'])))
|
||
return rijen
|
||
|
||
def tel(self, peildatum):
|
||
"""Aantal personeelsleden met een lopende opdracht op `peildatum` (één keer per persoon, ook als die in
|
||
meerdere instellingsnummers werkt). Geeft (totaal, detail) met detail = [{'naam', 'nummer', 'aantal'}]
|
||
per instellingsnummer als de school er meer dan één heeft, anders None."""
|
||
from tenancy import _automatisch_schooljaar
|
||
schooljaar = _automatisch_schooljaar(peildatum)
|
||
namen = dict(Config.INFORMAT_CAMPUSSEN or [])
|
||
alle, detail = set(), []
|
||
for nummer in self.nummers:
|
||
token = self._token(nummer)
|
||
leden = _lijst(self._get('/employees', nummer, token, schooljaar), 'employees', 'items', 'data')
|
||
opdrachten = _lijst(self._get('/employees/assignments', nummer, token, schooljaar),
|
||
'assignments', 'items', 'data')
|
||
# personId → persoon (naam + geboortedatum), om dezelfde persoon in een ander nummer te herkennen
|
||
persoon = {}
|
||
for p in leden:
|
||
pid = str(p.get('personId') or p.get('pPersoon') or '')
|
||
geboren = str(p.get('geboortedatum') or '')[:10]
|
||
persoon[pid] = ((naamdeel(p.get('voornaam')), naamdeel(p.get('naam')), geboren) if geboren
|
||
else (nummer, pid))
|
||
hier = set()
|
||
for o in opdrachten:
|
||
begin, einde = _datum(o.get('begindatum')), _datum(o.get('einddatum'))
|
||
if (begin and begin > peildatum) or (einde and einde < peildatum):
|
||
continue
|
||
pid = str(o.get('personId') or '')
|
||
if pid:
|
||
hier.add(persoon.get(pid, (nummer, pid)))
|
||
alle |= hier
|
||
detail.append({'naam': namen.get(nummer) or nummer, 'nummer': nummer, 'aantal': len(hier)})
|
||
return len(alle), (detail if len(detail) > 1 else None)
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# Verzameltabel (centrale database): personeel van alle scholen samen, voor één grote GO! Pro-import
|
||
# ---------------------------------------------------------------------
|
||
def schooljaar_periode(vandaag=None):
|
||
"""(1 september, 31 augustus) van het lopende schooljaar: in de verzameltabel heeft iedereen deze periode."""
|
||
vandaag = vandaag or date.today()
|
||
jaar = vandaag.year if vandaag.month >= 9 else vandaag.year - 1
|
||
return date(jaar, 9, 1), date(jaar + 1, 8, 31)
|
||
|
||
|
||
_periode_gezet = {'dag': None}
|
||
|
||
|
||
def verzamel_ensure():
|
||
from database import central_query
|
||
central_query("""
|
||
CREATE TABLE IF NOT EXISTS GoPro_verzameltabel (
|
||
ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||
School_code VARCHAR(32) NOT NULL,
|
||
School_naam VARCHAR(255) NULL,
|
||
Person_ID VARCHAR(64) NOT NULL,
|
||
Voornaam VARCHAR(100) NULL,
|
||
Achternaam VARCHAR(100) NULL,
|
||
Email VARCHAR(255) NULL,
|
||
UPN VARCHAR(255) NULL,
|
||
Instellingsnummer VARCHAR(20) NULL,
|
||
Functie VARCHAR(100) NULL,
|
||
Rol VARCHAR(100) NULL,
|
||
Startdatum DATE NULL,
|
||
Einddatum DATE NULL,
|
||
Telefoon VARCHAR(50) NULL,
|
||
Toegevoegd DATETIME NOT NULL,
|
||
Door VARCHAR(255) NULL,
|
||
Soort VARCHAR(10) NOT NULL DEFAULT 'groen',
|
||
Verzonden DATETIME NULL,
|
||
Verzonden_door VARCHAR(255) NULL,
|
||
UNIQUE KEY uq_gopro_verz (School_code, Person_ID),
|
||
KEY idx_gopro_verz_email (Email)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True)
|
||
# Soort: 'groen' = gaat mee in de GO! Pro-CSV, 'genegeerd' = rode lijst (nooit meer tonen, niet exporteren),
|
||
# 'verzonden' = al naar GO! Pro doorgestuurd (niet meer in de verzameltabel, nooit opnieuw toevoegen)
|
||
kolommen = {r['Field'].lower() for r in central_query("SHOW COLUMNS FROM GoPro_verzameltabel") or []}
|
||
if 'soort' not in kolommen:
|
||
central_query("ALTER TABLE GoPro_verzameltabel ADD COLUMN Soort VARCHAR(10) NOT NULL DEFAULT 'groen'", commit=True)
|
||
if 'verzonden' not in kolommen:
|
||
central_query("ALTER TABLE GoPro_verzameltabel ADD COLUMN Verzonden DATETIME NULL, "
|
||
"ADD COLUMN Verzonden_door VARCHAR(255) NULL", commit=True)
|
||
# Iedereen in de verzameltabel: periode = het lopende schooljaar (1 keer per dag nagekeken)
|
||
if _periode_gezet['dag'] != date.today():
|
||
start, einde = schooljaar_periode()
|
||
central_query("UPDATE GoPro_verzameltabel SET Startdatum = %s, Einddatum = %s WHERE Soort = 'groen' "
|
||
"AND (Startdatum IS NULL OR Einddatum IS NULL OR Startdatum <> %s OR Einddatum <> %s)",
|
||
(start, einde, start, einde), commit=True)
|
||
_periode_gezet['dag'] = date.today()
|
||
|
||
|
||
def verzameld_sleutels():
|
||
"""({(school_code, person_id): soort}, {e-mailadres: soort}) van alles in de verzameltabel (groen én genegeerd)."""
|
||
from database import central_query
|
||
verzamel_ensure()
|
||
rijen = central_query("SELECT School_code, Person_ID, Email, Soort FROM GoPro_verzameltabel") or []
|
||
return ({(r['School_code'], str(r['Person_ID'])): r['Soort'] for r in rijen},
|
||
{(r['Email'] or '').strip().lower(): r['Soort'] for r in rijen if r['Email']})
|
||
|
||
|
||
def is_verzameld(rij, school_code, sleutels):
|
||
"""In welke lijst staat dit personeelslid al ('groen' / 'genegeerd'), of None.
|
||
Herkend aan school + personeelslid, of aan het e-mailadres."""
|
||
paren, mails = sleutels
|
||
return paren.get((school_code, str(rij['person_id']))) or mails.get((rij.get('email') or '').strip().lower())
|
||
|
||
|
||
def verzamel_toevoegen(rijen, school, door=None, soort='groen'):
|
||
"""Voegt de rijen toe aan de verzameltabel (soort 'groen') of aan de rode lijst ('genegeerd').
|
||
Geeft (toegevoegd, overgeslagen)."""
|
||
from database import central_query
|
||
sleutels = verzameld_sleutels()
|
||
periode = schooljaar_periode() # iedereen: 1 september – 31 augustus van het lopende schooljaar
|
||
toegevoegd = overgeslagen = 0
|
||
for r in rijen:
|
||
if is_verzameld(r, school.code, sleutels):
|
||
overgeslagen += 1
|
||
continue
|
||
central_query(
|
||
"INSERT INTO GoPro_verzameltabel (School_code, School_naam, Person_ID, Voornaam, Achternaam, Email, UPN, "
|
||
"Instellingsnummer, Functie, Rol, Startdatum, Einddatum, Telefoon, Toegevoegd, Door, Soort) "
|
||
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), %s, %s)",
|
||
(school.code, school.naam, str(r['person_id']), r['voornaam'], r['achternaam'], r['email'], r['upn'],
|
||
r['instellingsnummer'], r['functie'], r['rol'], periode[0], periode[1], r['telefoon'], door, soort),
|
||
commit=True)
|
||
sleutels[0][(school.code, str(r['person_id']))] = soort
|
||
if r.get('email'):
|
||
sleutels[1][r['email'].strip().lower()] = soort
|
||
toegevoegd += 1
|
||
return toegevoegd, overgeslagen
|
||
|
||
|
||
def verzameld_alle(school_code=None, soort='groen'):
|
||
from database import central_query
|
||
verzamel_ensure()
|
||
if school_code:
|
||
return central_query("SELECT * FROM GoPro_verzameltabel WHERE School_code = %s AND Soort = %s "
|
||
"ORDER BY Achternaam, Voornaam", (school_code, soort)) or []
|
||
return central_query("SELECT * FROM GoPro_verzameltabel WHERE Soort = %s "
|
||
"ORDER BY School_naam, Achternaam, Voornaam", (soort,)) or []
|
||
|
||
|
||
def markeer_verzonden(door=None, school_code=None):
|
||
"""Alle personeelsleden in de verzameltabel (of enkel die van één school) als verzonden markeren.
|
||
Ze verdwijnen uit de verzameltabel en worden nooit opnieuw toegevoegd. Geeft het aantal."""
|
||
from database import central_query
|
||
verzamel_ensure()
|
||
n = len(verzameld_alle(school_code))
|
||
if school_code:
|
||
central_query("UPDATE GoPro_verzameltabel SET Soort = 'verzonden', Verzonden = NOW(), Verzonden_door = %s "
|
||
"WHERE Soort = 'groen' AND School_code = %s", (door, school_code), commit=True)
|
||
else:
|
||
central_query("UPDATE GoPro_verzameltabel SET Soort = 'verzonden', Verzonden = NOW(), Verzonden_door = %s "
|
||
"WHERE Soort = 'groen'", (door,), commit=True)
|
||
return n
|
||
|
||
|
||
def aantal_verzonden(school_code):
|
||
from database import central_query
|
||
verzamel_ensure()
|
||
r = central_query("SELECT COUNT(*) AS n FROM GoPro_verzameltabel WHERE School_code = %s AND Soort = 'verzonden'",
|
||
(school_code,)) or [{}]
|
||
return int(r[0].get('n') or 0)
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# Nachtelijke verzameling: elke nacht van alle scholen de groene personeelsleden naar de verzameltabel
|
||
# ---------------------------------------------------------------------
|
||
NACHT_SLEUTEL = 'gopro_nachtelijk' # '1' = aan
|
||
NACHT_DAG_SLEUTEL = 'gopro_nachtelijk_dag' # laatste datum waarop het liep (zodat het één keer per nacht loopt)
|
||
NACHT_RESULTAAT_SLEUTEL = 'gopro_nachtelijk_resultaat'
|
||
NACHT_UUR_VAN, NACHT_UUR_TOT = 2, 6 # tussen 02:00 en 06:00
|
||
NACHT_DOOR = 'Automatisch (nacht)'
|
||
_nacht = {'bezig': False}
|
||
|
||
|
||
def _instelling_ensure():
|
||
from services import ipblokkade
|
||
ipblokkade.ensure_table() # maakt PIVOT_instellingen aan als die nog niet bestaat
|
||
|
||
|
||
def nacht_instelling():
|
||
"""{'aan': bool, 'door', 'bijgewerkt', 'resultaat', 'resultaat_op'}"""
|
||
from database import central_query
|
||
_instelling_ensure()
|
||
rijen = {r['Sleutel']: r for r in central_query(
|
||
"SELECT Sleutel, Waarde, Bijgewerkt, Door FROM PIVOT_instellingen WHERE Sleutel IN (%s, %s)",
|
||
(NACHT_SLEUTEL, NACHT_RESULTAAT_SLEUTEL)) or []}
|
||
aan, res = rijen.get(NACHT_SLEUTEL) or {}, rijen.get(NACHT_RESULTAAT_SLEUTEL) or {}
|
||
return {'aan': aan.get('Waarde') == '1', 'door': aan.get('Door'), 'bijgewerkt': aan.get('Bijgewerkt'),
|
||
'resultaat': res.get('Waarde'), 'resultaat_op': res.get('Bijgewerkt'), 'bezig': _nacht['bezig']}
|
||
|
||
|
||
def zet_nacht(aan, door=None):
|
||
from database import central_query
|
||
_instelling_ensure()
|
||
central_query("INSERT INTO PIVOT_instellingen (Sleutel, Waarde, Bijgewerkt, Door) VALUES (%s, %s, NOW(), %s) "
|
||
"ON DUPLICATE KEY UPDATE Waarde = VALUES(Waarde), Bijgewerkt = NOW(), Door = VALUES(Door)",
|
||
(NACHT_SLEUTEL, '1' if aan else '0', door), commit=True)
|
||
|
||
|
||
def verzamel_groene_van_school(school, door=NACHT_DOOR):
|
||
"""Haalt het personeel van `school` op (Informat), kijkt na wie een account heeft in Google en zet de groene
|
||
die nog niet in de verzameltabel of op de rode lijst staan in de verzameltabel. Geeft het aantal toegevoegd."""
|
||
from tenancy import in_school
|
||
with in_school(school):
|
||
rijen = GoProService().personeel(school.domein, Config.INSTELLINGSNUMMER)
|
||
school.cache.pop('gopro', None) # de GO! Pro-pagina haalt daarna vers op
|
||
google = google_adressen(vernieuw=True)
|
||
sleutels = verzameld_sleutels()
|
||
groen = [r for r in rijen if not is_verzameld(r, school.code, sleutels) and google_kleur(r, google) == 'account']
|
||
toegevoegd, _ = verzamel_toevoegen(groen, school, door)
|
||
return toegevoegd
|
||
|
||
|
||
def verzamel_alle_scholen(door=NACHT_DOOR):
|
||
"""Voor elke actieve school met Informat en een e-maildomein. Geeft een korte samenvatting."""
|
||
from database import central_query
|
||
from tenancy import alle_scholen
|
||
from rechten import log
|
||
_nacht['bezig'] = True
|
||
try:
|
||
totaal, scholen, fouten, details = 0, 0, [], []
|
||
for school in alle_scholen():
|
||
if not school.heeft('INFORMAT_INST_NUMBER', 'INFORMAT_ID', 'INFORMAT_SECRET') or not school.domein:
|
||
continue
|
||
try:
|
||
n = verzamel_groene_van_school(school, door)
|
||
totaal += n
|
||
scholen += 1
|
||
details.append(f"{school.naam}: {n}")
|
||
except Exception as e:
|
||
fouten.append(f"{school.naam}: {str(e)[:120]}")
|
||
import sys
|
||
sys.stderr.write(f"[{school.code}] GO! Pro nachtelijk verzamelen: {e}\n")
|
||
samenvatting = f"{totaal} toegevoegd uit {scholen} school/scholen"
|
||
if fouten:
|
||
samenvatting += f"; {len(fouten)} mislukt ({' | '.join(fouten)})"
|
||
try:
|
||
log(f"GO! Pro automatisch verzamelen: {samenvatting}. " + '; '.join(details), url='(nachtelijke taak)')
|
||
except Exception:
|
||
pass
|
||
_instelling_ensure()
|
||
central_query("INSERT INTO PIVOT_instellingen (Sleutel, Waarde, Bijgewerkt, Door) VALUES (%s, %s, NOW(), %s) "
|
||
"ON DUPLICATE KEY UPDATE Waarde = VALUES(Waarde), Bijgewerkt = NOW(), Door = VALUES(Door)",
|
||
(NACHT_RESULTAAT_SLEUTEL, samenvatting[:255], door), commit=True)
|
||
return samenvatting
|
||
finally:
|
||
_nacht['bezig'] = False
|
||
|
||
|
||
def _nacht_claim(vandaag):
|
||
"""True als dit proces de run van vandaag mag doen (ook als PIVOT in meerdere processen draait)."""
|
||
from database import central_query
|
||
central_query("INSERT IGNORE INTO PIVOT_instellingen (Sleutel, Waarde, Bijgewerkt) VALUES (%s, '', NOW())",
|
||
(NACHT_DAG_SLEUTEL,), commit=True)
|
||
return central_query("UPDATE PIVOT_instellingen SET Waarde = %s, Bijgewerkt = NOW() "
|
||
"WHERE Sleutel = %s AND (Waarde IS NULL OR Waarde <> %s)",
|
||
(vandaag, NACHT_DAG_SLEUTEL, vandaag), commit=True) == 1
|
||
|
||
|
||
_nacht_thread = None
|
||
|
||
|
||
def start_scheduler():
|
||
"""Achtergrondtaak: elk kwartier kijken of het nacht is en de schakelaar aan staat."""
|
||
import threading, time, sys
|
||
global _nacht_thread
|
||
if _nacht_thread:
|
||
return
|
||
|
||
def loop():
|
||
time.sleep(60)
|
||
while True:
|
||
try:
|
||
nu = datetime.now()
|
||
if NACHT_UUR_VAN <= nu.hour < NACHT_UUR_TOT and nacht_instelling()['aan'] \
|
||
and _nacht_claim(nu.strftime('%Y-%m-%d')):
|
||
verzamel_alle_scholen()
|
||
except Exception as e:
|
||
sys.stderr.write(f"GO! Pro nachtelijke taak: {e}\n")
|
||
time.sleep(15 * 60)
|
||
|
||
_nacht_thread = threading.Thread(target=loop, daemon=True)
|
||
_nacht_thread.start()
|
||
|
||
|
||
def verzamel_alle_scholen_op_achtergrond(door):
|
||
import threading
|
||
if _nacht['bezig']:
|
||
return False
|
||
_nacht['bezig'] = True
|
||
threading.Thread(target=lambda: verzamel_alle_scholen(door), daemon=True).start()
|
||
return True
|