""" 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))