PIVOT 1.1.0

This commit is contained in:
marijn committed 2026-10-14 13:34:37 +02:00
commit 8a53234eb6
161 files changed
+31109

No files matched your search

+371
View File
@@ -0,0 +1,371 @@
"""
Personeel: afwezigheden van leerkrachten, nascholingen en uitstappen.
Rollen (config.ini › [personeel]):
- leerkracht : iedereen in personeel_ous die met Google inlogt (PIVOT-groep 5).
Ziet en beheert enkel zijn eigen meldingen/aanvragen.
- directie : e-mailadressen in 'directie' → keurt nascholingen en uitstappen goed.
- secretariaat : e-mailadressen in 'secretariaat' → dagoverzicht, vervangingen,
melden namens een leerkracht.
PIVOT-gebruikers met het recht 'personeel_secretariaat' / 'personeel_directie' in deze
school krijgen die rol ook. Administrators hebben de secretariaatsrechten, en ook de
directierechten zolang er nog geen directie is ingesteld.
Soorten:
- afwezigheid : geen goedkeuring nodig, meteen zichtbaar voor het secretariaat
- nascholing : goedkeuring door de directie
- uitstap : goedkeuring door de directie (klassen + begeleiders zijn weg)
"""
import json
import sys
import threading
import time
from datetime import date, timedelta
from config import Config
from database import execute_query, get_db_connection
from tenancy import huidige_school, met_school
LEERKRACHT_GROEP = 5
SOORTEN = {
'afwezigheid': {'label': 'Afwezigheid', 'icoon': '🤒', 'goedkeuring': False},
'nascholing': {'label': 'Nascholing', 'icoon': '🎓', 'goedkeuring': True},
'uitstap': {'label': 'Uitstap', 'icoon': '🚌', 'goedkeuring': True},
}
STATUS = {
'gemeld': ('Gemeld', 'info'),
'ingediend': ('Wacht op directie', 'warning'),
'vraag': ('Vraag van directie', 'warning'),
'goedgekeurd': ('Goedgekeurd', 'success'),
'geweigerd': ('Geweigerd', 'danger'),
'geannuleerd': ('Geannuleerd', ''),
}
DAGDELEN = {'dag': 'Hele dag', 'vm': 'Voormiddag', 'nm': 'Namiddag', 'uren': 'Bepaalde lesuren'}
# Statussen die in het dagoverzicht meetellen als "is weg"
ACTIEF = ('gemeld', 'goedgekeurd')
def _lijst(waarde):
return [x.strip().lower() for x in (waarde or '').replace(';', ',').split(',') if x.strip()]
# ---------------- rollen ----------------
def directie():
return _lijst(Config.PERSONEEL_DIRECTIE)
def secretariaat():
return _lijst(Config.PERSONEEL_SECRETARIAAT)
def rollen(sess):
"""Rollen in de personeelsmodule: via de e-maillijsten van de school of via de PIVOT-rechten
'personeel_directie' / 'personeel_secretariaat' (Gebruikers & rechten)."""
from rechten import heeft_recht, is_admin
email = (sess.get('user') or '').lower()
admin = is_admin()
r = {'leerkracht'}
if email in directie() or (heeft_recht('personeel_directie') and (not admin or not directie())):
r.add('directie')
if email in secretariaat() or heeft_recht('personeel_secretariaat'):
r.add('secretariaat')
return r
_ou_cache = {}
def is_personeelslid(email):
"""True als het Google-account in een van de personeels-OU's zit (cache 10 min)."""
email = (email or '').lower()
school = huidige_school()
if email.rsplit('@', 1)[-1] not in school.domeinen:
return False
sleutel = (school.code, email)
hit = _ou_cache.get(sleutel)
if hit and time.time() - hit[0] < 600:
return hit[1]
ok = False
try:
from services.google_service import GoogleAdminService
u = GoogleAdminService().get_user_info(email) or {}
ou = u.get('orgUnitPath', '')
ok = (not u.get('suspended')) and any(ou == p or ou.startswith(p.rstrip('/') + '/')
for p in [x.strip() for x in Config.PERSONEEL_OUS.split(',') if x.strip()])
except Exception as e:
sys.stderr.write(f"Personeel: OU-controle mislukt voor {email}: {e}\n")
_ou_cache[sleutel] = (time.time(), ok)
return ok
# ---------------- klassen uit Smartschool ----------------
def klassen():
"""Officiële klassen uit Smartschool (cache 6 uur, per school). Lege lijst als het niet lukt."""
_klassen_cache = huidige_school().object('klassen_cache', lambda: {'t': 0, 'lijst': []})
if _klassen_cache['lijst'] and time.time() - _klassen_cache['t'] < 6 * 3600:
return _klassen_cache['lijst']
try:
from services.smartschool_api import SmartschoolService
ss = SmartschoolService(platform=Config.SS_API_PLATFORM, webservice_pwd=Config.SS_API_PW)
raw = ss.client.service.getClassListJson(Config.SS_API_PW)
data = json.loads(raw) if isinstance(raw, str) else raw
if isinstance(data, dict):
data = list(data.values())
namen = []
for k in data or []:
if not isinstance(k, dict):
continue
officieel = k.get('isKlas', k.get('isOfficial', True))
if str(officieel).lower() in ('false', '0', 'none'):
continue
naam = (k.get('name') or k.get('code') or '').strip()
if naam:
namen.append(naam)
lijst = sorted(set(namen), key=lambda n: (len(n) > 3, n))
if lijst:
_klassen_cache.update(t=time.time(), lijst=lijst)
return lijst
except Exception as e:
sys.stderr.write(f"Personeel: klassenlijst uit Smartschool mislukt: {e}\n")
return _klassen_cache['lijst']
# ---------------- database ----------------
def ensure_tables():
execute_query("""
CREATE TABLE IF NOT EXISTS personeel_aanvragen (
ID INT AUTO_INCREMENT PRIMARY KEY,
Soort VARCHAR(20) NOT NULL,
Status VARCHAR(20) NOT NULL,
Email VARCHAR(255) NOT NULL,
Naam VARCHAR(255) NULL,
Ingediend_door VARCHAR(255) NULL,
Titel VARCHAR(255) NOT NULL,
Omschrijving TEXT NULL,
Locatie VARCHAR(255) NULL,
Van_datum DATE NOT NULL,
Tot_datum DATE NOT NULL,
Dagdeel VARCHAR(10) NOT NULL DEFAULT 'dag',
Lesuren VARCHAR(50) NULL,
Klassen TEXT NULL,
Begeleiders TEXT NULL,
Kost DECIMAL(10,2) NULL,
Vervanging_ok TINYINT(1) NOT NULL DEFAULT 0,
Vervanging_info VARCHAR(255) NULL,
Aangemaakt DATETIME NOT NULL,
Bijgewerkt DATETIME NOT NULL,
INDEX idx_status (Status),
INDEX idx_datum (Van_datum, Tot_datum),
INDEX idx_email (Email)
)""", commit=True)
execute_query("""
CREATE TABLE IF NOT EXISTS personeel_reacties (
ID INT AUTO_INCREMENT PRIMARY KEY,
Aanvraag_ID INT NOT NULL,
Tijdstip DATETIME NOT NULL,
Email VARCHAR(255) NULL,
Naam VARCHAR(255) NULL,
Actie VARCHAR(30) NOT NULL,
Tekst TEXT NULL,
INDEX idx_aanvraag (Aanvraag_ID)
)""", commit=True)
def _reactie(aanvraag_id, email, naam, actie, tekst=None):
execute_query("INSERT INTO personeel_reacties (Aanvraag_ID, Tijdstip, Email, Naam, Actie, Tekst) "
"VALUES (%s, NOW(), %s, %s, %s, %s)", (aanvraag_id, email, naam, actie, tekst), commit=True)
def aanmaken(data, door_email, door_naam):
soort = data['soort']
status = 'ingediend' if SOORTEN[soort]['goedkeuring'] else 'gemeld'
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute("""
INSERT INTO personeel_aanvragen (Soort, Status, Email, Naam, Ingediend_door, Titel, Omschrijving, Locatie,
Van_datum, Tot_datum, Dagdeel, Lesuren, Klassen, Begeleiders, Kost, Aangemaakt, Bijgewerkt)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW(),NOW())""",
(soort, status, data['email'], data['naam'], door_email if door_email != data['email'] else None,
data['titel'], data.get('omschrijving'), data.get('locatie'), data['van'], data['tot'],
data['dagdeel'], data.get('lesuren'), ', '.join(data.get('klassen') or []),
data.get('begeleiders'), data.get('kost')))
conn.commit()
aid = cur.lastrowid
finally:
cur.close()
conn.close()
tekst = None if door_email == data['email'] else f"Gemeld door {door_naam or door_email}"
_reactie(aid, door_email, door_naam, 'gemeld' if status == 'gemeld' else 'ingediend', tekst)
return aid
def get(aid):
rows = execute_query("SELECT * FROM personeel_aanvragen WHERE ID = %s", (aid,))
return rows[0] if rows else None
def reacties(aid):
return execute_query(
"SELECT Tijdstip, Email, Naam, Actie, Tekst FROM personeel_reacties WHERE Aanvraag_ID = %s ORDER BY Tijdstip, ID",
(aid,))
def wijzig_status(aid, nieuwe_status, door_email, door_naam, actie, tekst=None):
execute_query("UPDATE personeel_aanvragen SET Status = %s, Bijgewerkt = NOW() WHERE ID = %s",
(nieuwe_status, aid), commit=True)
_reactie(aid, door_email, door_naam, actie, tekst)
def commentaar(aid, door_email, door_naam, tekst):
execute_query("UPDATE personeel_aanvragen SET Bijgewerkt = NOW() WHERE ID = %s", (aid,), commit=True)
_reactie(aid, door_email, door_naam, 'commentaar', tekst)
def vervanging(aid, ok, info, door_email, door_naam):
execute_query("UPDATE personeel_aanvragen SET Vervanging_ok = %s, Vervanging_info = %s, Bijgewerkt = NOW() "
"WHERE ID = %s", (1 if ok else 0, (info or '')[:255] or None, aid), commit=True)
_reactie(aid, door_email, door_naam, 'vervanging',
('Vervanging geregeld' if ok else 'Vervanging nog niet geregeld') + (f": {info}" if info else ''))
def mijn(email):
return execute_query("SELECT * FROM personeel_aanvragen WHERE Email = %s ORDER BY Van_datum DESC, ID DESC LIMIT 200",
(email,))
def te_beoordelen():
return execute_query("SELECT * FROM personeel_aanvragen WHERE Status IN ('ingediend','vraag') "
"ORDER BY Status = 'vraag', Van_datum, ID")
def recent_beslist(limit=20):
return execute_query("SELECT * FROM personeel_aanvragen WHERE Status IN ('goedgekeurd','geweigerd') "
"AND Soort <> 'afwezigheid' ORDER BY Bijgewerkt DESC LIMIT %s", (int(limit),))
def periode(van, tot, statussen=ACTIEF):
ph = ','.join(['%s'] * len(statussen))
return execute_query(
f"SELECT * FROM personeel_aanvragen WHERE Status IN ({ph}) AND Van_datum <= %s AND Tot_datum >= %s "
"ORDER BY Van_datum, Naam", tuple(statussen) + (tot, van))
def aantal_te_beoordelen():
try:
return execute_query("SELECT COUNT(*) AS n FROM personeel_aanvragen WHERE Status = 'ingediend'")[0]['n']
except Exception:
return 0
# ---------------- dagoverzicht ----------------
def dagoverzicht(van, tot):
"""Per dag: wie is weg en welke klassen zijn weg (enkel gemeld/goedgekeurd), plus wat nog wacht."""
items = periode(van, tot)
wachtend = periode(van, tot, ('ingediend', 'vraag'))
dagen = []
d = van
while d <= tot:
if d.weekday() < 5:
vandaag = [i for i in items if i['Van_datum'] <= d <= i['Tot_datum']]
personen, klassen_weg = [], []
for i in vandaag:
personen.append({'naam': i['Naam'] or i['Email'], 'email': i['Email'], 'item': i, 'rol': None})
if i['Soort'] == 'uitstap':
for b in [x.strip() for x in (i['Begeleiders'] or '').split(',') if x.strip()]:
personen.append({'naam': b, 'email': None, 'item': i, 'rol': 'begeleider'})
for k in [x.strip() for x in (i['Klassen'] or '').split(',') if x.strip()]:
klassen_weg.append({'klas': k, 'item': i})
dagen.append({
'datum': d, 'personen': personen,
'klassen': sorted(klassen_weg, key=lambda k: k['klas']),
'wachtend': [w for w in wachtend if w['Van_datum'] <= d <= w['Tot_datum']],
'open_vervanging': sum(1 for i in vandaag if i['Klassen'] and i['Soort'] != 'uitstap' and not i['Vervanging_ok']),
})
d += timedelta(days=1)
return dagen
# ---------------- e-mail ----------------
def _mail_async(ontvangers, onderwerp, html):
ontvangers = sorted({o for o in ontvangers if o and '@' in o})
if not Config.PERSONEEL_MAILS or not ontvangers:
return
def verstuur():
try:
from services.google_service import GoogleAdminService
svc = GoogleAdminService()
for o in ontvangers:
ok, msg = svc.send_custom_mail(o, onderwerp, html)
if not ok:
sys.stderr.write(f"Personeel: mail naar {o} mislukt: {msg}\n")
except Exception as e:
sys.stderr.write(f"Personeel: mail mislukt: {e}\n")
threading.Thread(target=met_school(verstuur), daemon=True).start()
def _mailtekst(item, intro, link, extra=None):
soort = SOORTEN[item['Soort']]
periode_txt = item['Van_datum'].strftime('%d/%m/%Y')
if item['Tot_datum'] != item['Van_datum']:
periode_txt += ' – ' + item['Tot_datum'].strftime('%d/%m/%Y')
dagdeel = DAGDELEN.get(item['Dagdeel'], '')
if item['Dagdeel'] == 'uren' and item['Lesuren']:
dagdeel += f" ({item['Lesuren']})"
rijen = [('Wie', item['Naam'] or item['Email']), ('Wat', f"{soort['icoon']} {soort['label']}: {item['Titel']}"),
('Wanneer', f"{periode_txt}, {dagdeel}")]
if item['Klassen']:
rijen.append(('Klassen', item['Klassen']))
if item['Begeleiders']:
rijen.append(('Begeleiders', item['Begeleiders']))
if item['Kost']:
rijen.append(('Kost', f"€ {item['Kost']}"))
tabel = ''.join(f"<tr><td style='padding:4px 12px 4px 0;color:#64748b'>{k}</td><td style='padding:4px 0'><b>{v}</b></td></tr>"
for k, v in rijen)
extra_html = f"<p style='background:#f1f5f9;padding:10px 14px;border-radius:6px'>{extra}</p>" if extra else ''
return (f"<div style='font-family:Segoe UI,Arial,sans-serif;color:#1e293b'>"
f"<p>{intro}</p><table>{tabel}</table>{extra_html}"
f"<p><a href='{link}' style='background:#1e6fa5;color:#fff;padding:10px 16px;border-radius:6px;text-decoration:none'>"
f"Openen in PIVOT</a></p></div>")
def secretariaat_adressen():
return _lijst(Config.PERSONEEL_SECRETARIAAT_MAIL) or secretariaat()
def mail_nieuw(item, link):
soort = SOORTEN[item['Soort']]
if soort['goedkeuring']:
_mail_async(directie(), f"Nieuwe aanvraag {soort['label'].lower()}: {item['Naam']} – {item['Titel']}",
_mailtekst(item, f"Er is een nieuwe aanvraag voor een {soort['label'].lower()} die je goedkeuring vraagt.", link))
else:
_mail_async(secretariaat_adressen(), f"Afwezig: {item['Naam']} ({item['Van_datum']:%d/%m})",
_mailtekst(item, "Er is een afwezigheid gemeld.", link, item['Omschrijving']))
def mail_beslissing(item, beslissing, tekst, link):
soort = SOORTEN[item['Soort']]
woorden = {'goedgekeurd': 'is goedgekeurd ✅', 'geweigerd': 'is geweigerd ❌', 'vraag': 'heeft een vraag van de directie ❓'}
_mail_async([item['Email']], f"Je aanvraag {soort['label'].lower()} {woorden[beslissing]}",
_mailtekst(item, f"Je aanvraag <b>{item['Titel']}</b> {woorden[beslissing]}.", link,
f"Commentaar: {tekst}" if tekst else None))
if beslissing == 'goedgekeurd':
_mail_async(secretariaat_adressen(), f"Goedgekeurd: {soort['label'].lower()} {item['Naam']} ({item['Van_datum']:%d/%m})",
_mailtekst(item, f"Een {soort['label'].lower()} is goedgekeurd en staat nu in het dagoverzicht.", link))
def mail_antwoord(item, tekst, link):
_mail_async(directie(), f"Antwoord van {item['Naam']}: {item['Titel']}",
_mailtekst(item, "De leerkracht heeft geantwoord op je vraag; de aanvraag wacht opnieuw op je beslissing.",
link, tekst))
def mail_geannuleerd(item, link):
ontvangers = secretariaat_adressen() if item['Status'] in ('gemeld', 'goedgekeurd') else []
if SOORTEN[item['Soort']]['goedkeuring']:
ontvangers = ontvangers + directie()
_mail_async(ontvangers, f"Geannuleerd: {SOORTEN[item['Soort']]['label'].lower()} {item['Naam']} ({item['Van_datum']:%d/%m})",
_mailtekst(item, "Deze melding/aanvraag is geannuleerd.", link))