372 lines
16 KiB
Python
372 lines
16 KiB
Python
"""
|
||
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))
|