Files
Pivot/services/personeel_service.py
T
2026-10-14 13:34:37 +02:00

372 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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))