379 lines
18 KiB
Python
379 lines
18 KiB
Python
"""
|
||
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())
|
||
per_soort = gp.aantallen()
|
||
aantal = {k: (len(rijen) if k == lijst else per_soort.get(v, 0)) for k, v in LIJSTEN.items()}
|
||
from collections import Counter
|
||
per_school = Counter(r['School_code'] for r in rijen)
|
||
return render_template("gopro_verzameld.html", rijen=rijen, scholen=scholen, rollen=gp.ROLLEN, per_school=per_school,
|
||
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))
|