Files
Pivot/blueprints/gopro_bp.py
T
2026-10-14 12:29:29 +02:00

376 lines
18 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.
"""
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))