Initial commit

This commit is contained in:
Kristof committed 2026-10-14 12:29:29 +02:00
commit 536ff43ce4
158 files changed
+30355

No files matched your search

+375
View File
@@ -0,0 +1,375 @@
"""
Scholengroep › GO! Pro: personeelsleden van de actieve school uit Informat, met een rol per personeelslid en
functie, en een export in de kolommen van het GO! Pro-importbestand.
"""
import csv
import io
import time
from datetime import date
from flask import Blueprint, render_template, request, jsonify, Response, session, redirect, url_for, flash
from blueprints.auth import login_required
from config import Config
from rechten import recht_vereist
from services import gopro_service as gp
from tenancy import huidige_school
gopro_bp = Blueprint('gopro', __name__)
CACHE_SECONDEN = 15 * 60 # Informat niet bij elke klik opnieuw bevragen
def _rijen(vernieuw=False):
"""Personeel van de actieve school (15 minuten bewaard per school). De rollen komen altijd vers uit de database."""
school = huidige_school()
bewaard = school.cache.get('gopro')
if vernieuw or not bewaard or time.time() - bewaard[0] > CACHE_SECONDEN:
rijen = gp.GoProService().personeel(school.domein, Config.INSTELLINGSNUMMER)
bewaard = (time.time(), rijen)
school.cache['gopro'] = bewaard
gekozen = gp.keuzes()
for r in bewaard[1]:
gp.pas_keuzes_toe(r, gekozen)
gp.markeer_dubbel(bewaard[1])
return bewaard[1], bewaard[0]
def _niet_verzameld(rijen, per_soort=False):
"""Laat de personeelsleden weg die al in de verzameltabel of in de rode lijst (genegeerd) staan.
Geeft (rijen, aantal weggelaten) of met per_soort=True (rijen, {'groen': n, 'genegeerd': m})."""
sleutels = gp.verzameld_sleutels()
code = huidige_school().code
over, weg = [], {'groen': 0, 'genegeerd': 0}
for r in rijen:
soort = gp.is_verzameld(r, code, sleutels)
if soort:
weg[soort] = weg.get(soort, 0) + 1
else:
over.append(r)
return over, (weg if per_soort else len(rijen) - len(over))
@gopro_bp.route("/", methods=["GET"])
@login_required
@recht_vereist('gopro')
def overzicht():
school = huidige_school()
rijen, fout, opgehaald, verzameld_hier = [], None, None, {'groen': 0, 'genegeerd': 0, 'verzonden': 0}
if not school.heeft('INFORMAT_INST_NUMBER', 'INFORMAT_ID', 'INFORMAT_SECRET'):
fout = "Informat is nog niet ingesteld voor deze school (🔌 Koppelingen › Informat)."
else:
try:
rijen, t = _rijen(vernieuw=bool(request.args.get('vernieuw')))
rijen, verzameld_hier = _niet_verzameld(rijen, per_soort=True) # verzameld of genegeerd: niet meer tonen
opgehaald = time.strftime('%d-%m-%Y %H:%M', time.localtime(t))
except Exception as e:
fout = str(e)
return render_template("gopro.html", rijen=rijen, fout=fout, opgehaald=opgehaald, rollen=gp.ROLLEN,
domein=school.domein, school_naam=school.naam,
dubbel=sum(1 for r in rijen if r['dubbel']), verzameld_hier=verzameld_hier,
verzameld_totaal=len(gp.verzameld_alle()),
genegeerd_totaal=len(gp.verzameld_alle(soort='genegeerd')),
verzonden_school=gp.aantal_verzonden(school.code),
nacht=gp.nacht_instelling(), periode=gp.schooljaar_periode())
@gopro_bp.route("/verplaats", methods=["POST"])
@login_required
@recht_vereist('gopro')
def verplaats():
"""Eén personeelslid met de gegevens uit het venster (aangepast of niet) naar de verzameltabel."""
from datetime import datetime as _dt
data = request.get_json(silent=True) or {}
pid = str(data.get('person_id') or '')
try:
rijen, _ = _rijen()
except Exception as e:
return jsonify(ok=False, fout=str(e)), 400
rij = next((r for r in rijen if str(r['person_id']) == pid), None)
if not rij:
return jsonify(ok=False, fout="Personeelslid niet gevonden (haal de lijst opnieuw op)."), 404
def tekst(veld, maxlen):
return str(data.get(veld) or '').strip()[:maxlen]
def datum(veld):
w = tekst(veld, 10)
if not w:
return None
try:
return _dt.strptime(w, '%Y-%m-%d').date()
except ValueError:
raise ValueError(f"Ongeldige datum: {w}")
try:
nieuw = dict(rij)
nieuw.update(voornaam=tekst('voornaam', 100), achternaam=tekst('achternaam', 100),
email=tekst('email', 255).lower(), upn=tekst('upn', 255).lower(),
instellingsnummer=tekst('instellingsnummer', 20), functie=tekst('functie', 100),
rol=tekst('rol', 100), start=datum('start'), einde=datum('einde'), telefoon=tekst('telefoon', 50))
except ValueError as e:
return jsonify(ok=False, fout=str(e)), 400
if not nieuw['voornaam'] or not nieuw['achternaam']:
return jsonify(ok=False, fout="Voornaam en achternaam zijn verplicht."), 400
if nieuw['email'] and '@' not in nieuw['email']:
return jsonify(ok=False, fout="Het e-mailadres is niet geldig."), 400
if nieuw['start'] and nieuw['einde'] and nieuw['einde'] < nieuw['start']:
return jsonify(ok=False, fout="De einddatum ligt voor de startdatum."), 400
toegevoegd, _ = gp.verzamel_toevoegen([nieuw], huidige_school(), session.get('naam') or session.get('user'))
if not toegevoegd:
return jsonify(ok=False, fout=f"{nieuw['email']} staat al in de verzameltabel of op de rode lijst."), 409
return jsonify(ok=True, totaal=len(gp.verzameld_alle()))
@gopro_bp.route("/nachtelijk", methods=["POST"])
@login_required
@recht_vereist('gopro')
def nachtelijk():
"""Schakelaar: elke nacht van alle scholen de groene naar de verzameltabel (of ?nu=1: meteen één keer)."""
door = session.get('naam') or session.get('user')
if request.form.get('nu') == '1':
if gp.verzamel_alle_scholen_op_achtergrond(door):
flash("De groene personeelsleden van alle scholen worden nu verzameld. Dit kan enkele minuten duren; "
"het resultaat verschijnt onder de schakelaar.", "info")
else:
flash("Er loopt al een verzameling.", "warning")
return redirect(url_for('gopro.overzicht'))
aan = request.form.get('aan') == '1'
gp.zet_nacht(aan, door)
from rechten import log
log(f"GO! Pro: nachtelijk verzamelen {'aangezet' if aan else 'uitgezet'}")
flash("Elke nacht (tussen 2 en 6 uur) worden de groene personeelsleden van alle scholen automatisch naar de "
"verzameltabel gezet." if aan else "Het automatisch verzamelen 's nachts staat uit.", "success")
return redirect(url_for('gopro.overzicht'))
@gopro_bp.route("/negeer", methods=["POST"])
@login_required
@recht_vereist('gopro')
def negeer():
"""Eén personeelslid naar de rode lijst: wordt genegeerd (niet geëxporteerd en niet meer getoond)."""
data = request.get_json(silent=True) or request.form
pid = str(data.get('person_id') or '')
try:
rijen, _ = _rijen()
except Exception as e:
return jsonify(ok=False, fout=str(e)), 400
rij = next((r for r in rijen if str(r['person_id']) == pid), None)
if not rij:
return jsonify(ok=False, fout="Personeelslid niet gevonden (haal de lijst opnieuw op)."), 404
toegevoegd, _ = gp.verzamel_toevoegen([rij], huidige_school(), session.get('naam') or session.get('user'),
soort='genegeerd')
return jsonify(ok=True, toegevoegd=toegevoegd, totaal=len(gp.verzameld_alle(soort='genegeerd')))
@gopro_bp.route("/keuze", methods=["POST"])
@login_required
@recht_vereist('gopro')
def keuze():
"""Bewaart de gekozen functie of rol van één personeelslid (veld = functie | rol)."""
data = request.get_json(silent=True) or request.form
pid, veld = data.get('person_id') or '', data.get('veld') or ''
try:
gp.keuze_opslaan(pid, veld, data.get('waarde') or '', session.get('user'))
except ValueError as e:
return jsonify(ok=False, fout=str(e)), 400
# De waarde die nu geldt (bij een leeg gemaakt e-mail/UPN-veld: weer het standaardadres)
waarde = data.get('waarde')
bewaard = huidige_school().cache.get('gopro')
if bewaard and veld in ('email', 'upn'):
rij = next((r for r in bewaard[1] if r['person_id'] == pid), None)
if rij:
gp.pas_keuzes_toe(rij, gp.keuzes())
waarde = rij[veld]
return jsonify(ok=True, waarde=waarde)
@gopro_bp.route("/google.json", methods=["POST"])
@login_required
@recht_vereist('gopro')
def google_check():
"""Welke van de doorgestuurde adressen bestaan in Google Workspace: {adres: 'primair' | 'alias'}."""
data = request.get_json(silent=True) or {}
gevraagd = [str(a).strip().lower() for a in data.get('adressen') or [] if a][:5000]
try:
bestaand = gp.google_adressen(vernieuw=bool(data.get('vernieuw')))
except Exception as e:
return jsonify(ok=False, fout=f"Google kon niet bevraagd worden: {e}"), 200
return jsonify(ok=True, adressen={a: bestaand[a] for a in set(gevraagd) if a in bestaand})
@gopro_bp.route("/export.csv", methods=["GET"])
@login_required
@recht_vereist('gopro')
def export():
"""CSV in de kolommen van het GO! Pro-importbestand (; als scheidingsteken, UTF-8, datums dd/mm/jjjj)."""
try:
rijen, _ = _rijen()
rijen, _ = _niet_verzameld(rijen)
except Exception as e:
flash(str(e), "error")
return redirect(url_for('gopro.overzicht'))
# ?kleur=account|alias|geen → enkel die rijen, net als de kleurfilter op de pagina
kleur = request.args.get('kleur') or ''
if kleur in ('account', 'alias', 'geen'):
try:
google = gp.google_adressen()
except Exception as e:
flash(f"Google kon niet bevraagd worden, dus de kleurfilter kan niet toegepast worden: {e}", "error")
return redirect(url_for('gopro.overzicht'))
rijen = [r for r in rijen if gp.google_kleur(r, google) == kleur]
datum = lambda d: d.strftime('%d/%m/%Y') if d else ''
buf = io.StringIO()
w = csv.writer(buf, delimiter=';')
w.writerow(gp.EXPORT_KOLOMMEN)
for r in rijen:
w.writerow([r['voornaam'], r['achternaam'], r['email'], r['upn'], r['email'], r['instellingsnummer'],
r['functie'], r['rol'], datum(r['start']), datum(r['einde']), r['telefoon']])
return Response('\ufeff' + buf.getvalue(), mimetype='text/csv; charset=utf-8', headers={
'Content-Disposition': f"attachment; filename=GO_Pro_{huidige_school().code}"
f"{'_' + {'account': 'met_account', 'alias': 'alias', 'geen': 'zonder_account'}[kleur] if kleur in ('account', 'alias', 'geen') else ''}"
f"_{date.today():%Y%m%d}.csv"})
def _csv(rijen, bestandsnaam):
"""CSV in de kolommen van het GO! Pro-importbestand. `rijen` = [(voornaam, achternaam, email, upn, instelling,
functie, rol, start, einde, telefoon)]."""
datum = lambda d: d.strftime('%d/%m/%Y') if d else ''
buf = io.StringIO()
w = csv.writer(buf, delimiter=';')
w.writerow(gp.EXPORT_KOLOMMEN)
for vn, an, em, upn, inst, fu, rol, st, ei, tel in rijen:
w.writerow([vn, an, em, upn, em, inst, fu, rol, datum(st), datum(ei), tel])
return Response('\ufeff' + buf.getvalue(), mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': f"attachment; filename={bestandsnaam}"})
# ---------------------------------------------------------------------
# Verzameltabel: de groene personeelsleden van alle scholen samen
# ---------------------------------------------------------------------
@gopro_bp.route("/verzamel", methods=["POST"])
@login_required
@recht_vereist('gopro')
def verzamel():
"""Zet alle personeelsleden met een account in Google (groen) van de actieve school in de verzameltabel."""
school = huidige_school()
try:
rijen, _ = _rijen()
rijen, _ = _niet_verzameld(rijen)
google = gp.google_adressen()
except Exception as e:
flash(f"Kon niet verzamelen: {e}", "error")
return redirect(url_for('gopro.overzicht'))
groen = [r for r in rijen if gp.google_kleur(r, google) == 'account']
if not groen:
flash("Er zijn geen (nieuwe) groene personeelsleden om toe te voegen.", "warning")
return redirect(url_for('gopro.overzicht'))
toegevoegd, overgeslagen = gp.verzamel_toevoegen(groen, school, session.get('naam') or session.get('user'))
from rechten import log
log(f"GO! Pro: {toegevoegd} personeelsleden van {school.code} naar de verzameltabel")
flash(f"{toegevoegd} personeelsleden van {school.naam} toegevoegd aan de verzameltabel"
f"{f' ({overgeslagen} stonden er al in met hetzelfde e-mailadres)' if overgeslagen else ''}. "
"Ze worden hier niet meer getoond.", "success")
return redirect(url_for('gopro.overzicht'))
LIJSTEN = {'groen': 'groen', 'rood': 'genegeerd', 'verzonden': 'verzonden'}
LIJSTNAMEN = {'groen': 'de verzameltabel', 'rood': 'de rode lijst', 'verzonden': 'de lijst met verzonden personeelsleden'}
def _lijst(waarde):
return waarde if waarde in LIJSTEN else 'groen'
@gopro_bp.route("/verzameltabel", methods=["GET"])
@login_required
@recht_vereist('gopro')
def verzameltabel():
"""?lijst=rood → de rode lijst (genegeerd), ?lijst=verzonden → al verzonden, anders de groene verzameltabel."""
lijst = _lijst(request.args.get('lijst'))
rijen = gp.verzameld_alle(soort=LIJSTEN[lijst])
scholen = sorted({(r['School_code'], r['School_naam'] or r['School_code']) for r in rijen}, key=lambda x: x[1].lower())
aantal = {k: (len(rijen) if k == lijst else len(gp.verzameld_alle(soort=v))) for k, v in LIJSTEN.items()}
return render_template("gopro_verzameld.html", rijen=rijen, scholen=scholen, rollen=gp.ROLLEN,
huidige=huidige_school().code, lijst=lijst, rood=lijst == 'rood',
verzonden=lijst == 'verzonden', aantal=aantal,
aantal_groen=aantal['groen'], aantal_rood=aantal['rood'])
@gopro_bp.route("/verzameltabel/naar-rood", methods=["POST"])
@login_required
@recht_vereist('gopro')
def verzameltabel_naar_rood():
"""Eén personeelslid uit de verzameltabel naar de rode lijst (genegeerd: niet exporteren, nooit meer tonen)."""
from database import central_query
from rechten import log
gp.verzamel_ensure()
n = central_query("UPDATE GoPro_verzameltabel SET Soort = 'genegeerd', Door = %s, Toegevoegd = NOW() "
"WHERE ID = %s AND Soort = 'groen'",
(session.get('naam') or session.get('user'), request.form.get('id')), commit=True)
if n:
log(f"GO! Pro: {request.form.get('naam')} van de verzameltabel naar de rode lijst")
melding = (f"{request.form.get('naam')} staat nu op de rode lijst." if n
else "Dit personeelslid staat niet (meer) in de verzameltabel.")
if request.headers.get('X-Requested-With') == 'fetch': # vanuit de tabel: zoekopdracht blijft staan
return jsonify(ok=bool(n), melding=melding)
flash(melding, "success" if n else "error")
return redirect(url_for('gopro.verzameltabel'))
@gopro_bp.route("/verzameltabel/verzonden", methods=["POST"])
@login_required
@recht_vereist('gopro')
def verzameltabel_verzonden():
"""De verzameltabel (of één school, school=...) markeren als verzonden naar GO! Pro."""
from rechten import log
code = request.form.get('school') or None
n = gp.markeer_verzonden(session.get('naam') or session.get('user'), code)
log(f"GO! Pro: {n} personeelsleden{' van ' + code if code else ''} gemarkeerd als verzonden")
flash(f"{n} personeelsleden gemarkeerd als verzonden. Ze worden niet meer aan de verzameltabel toegevoegd.", "success")
return redirect(url_for('gopro.verzameltabel'))
@gopro_bp.route("/verzameltabel/export.csv", methods=["GET"])
@login_required
@recht_vereist('gopro')
def verzameltabel_export():
"""Eén CSV voor GO! Pro met alle scholen (of ?school=code voor één school)."""
code = request.args.get('school') or None
lijst = 'verzonden' if request.args.get('lijst') == 'verzonden' else 'groen'
rijen = gp.verzameld_alle(code, LIJSTEN[lijst])
return _csv([(r['Voornaam'], r['Achternaam'], r['Email'], r['UPN'], r['Instellingsnummer'], r['Functie'], r['Rol'],
r['Startdatum'], r['Einddatum'], r['Telefoon']) for r in rijen],
f"GO_Pro_{'verzonden_' if lijst == 'verzonden' else ''}{code or 'alle_scholen'}_{date.today():%Y%m%d}.csv")
@gopro_bp.route("/verzameltabel/verwijderen", methods=["POST"])
@login_required
@recht_vereist('gopro')
def verzameltabel_verwijderen():
"""Eén rij (id=...), alle rijen van één school (school=...) of alles (alles=1) uit de verzameltabel halen.
Die personeelsleden verschijnen dan weer op de GO! Pro-pagina van hun school."""
from database import central_query
from rechten import log
gp.verzamel_ensure()
lijst = _lijst(request.form.get('lijst'))
soort, naam = LIJSTEN[lijst], LIJSTNAMEN[lijst]
if request.form.get('id'):
n = central_query("DELETE FROM GoPro_verzameltabel WHERE ID = %s AND Soort = %s",
(request.form.get('id'), soort), commit=True)
if request.headers.get('X-Requested-With') == 'fetch': # vanuit de tabel: zoekopdracht blijft staan
return jsonify(ok=bool(n), melding=f"Personeelslid uit {naam} gehaald." if n else "Niet (meer) gevonden.")
flash(f"Personeelslid uit {naam} gehaald.", "success")
elif request.form.get('school'):
code = request.form.get('school')
n = len(gp.verzameld_alle(code, soort))
central_query("DELETE FROM GoPro_verzameltabel WHERE School_code = %s AND Soort = %s", (code, soort), commit=True)
log(f"GO! Pro: {n} rijen van {code} uit {naam} gehaald")
flash(f"{n} personeelsleden van deze school uit {naam} gehaald.", "success")
elif request.form.get('alles') == '1':
n = len(gp.verzameld_alle(soort=soort))
central_query("DELETE FROM GoPro_verzameltabel WHERE Soort = %s", (soort,), commit=True)
log(f"GO! Pro: {naam} leeggemaakt ({n} rijen)")
flash(f"{naam[0].upper() + naam[1:]} leeggemaakt ({n} personeelsleden).", "success")
return redirect(url_for('gopro.verzameltabel', lijst=None if lijst == 'groen' else lijst))