Initial commit
This commit is contained in:
commit
536ff43ce4
158 files changed
+30355
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
Gebruikers & rechten.
|
||||
|
||||
- Administrators: alle gebruikers, alle scholen, rol (Administrator/Gebruiker) wijzigen.
|
||||
- Gebruikers met het recht 'gebruikersbeheer' in een school: gebruikers aanmaken en hun
|
||||
rechten in DIE school(en) instellen. Ze kunnen geen administrators wijzigen.
|
||||
|
||||
Gebruikers staan in de centrale database (één account voor alle scholen); de rechten per
|
||||
school in Gebruiker_rechten.
|
||||
"""
|
||||
from collections import OrderedDict
|
||||
|
||||
from flask import Blueprint, render_template, request, session, redirect, url_for, flash
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
import rechten
|
||||
from rechten import ADMIN, GEBRUIKER, VEILIGHEIDSGROEPEN, ONDERDELEN, is_admin
|
||||
from database import central_query, central_insert
|
||||
from tenancy import alle_scholen, school_op_code
|
||||
|
||||
admin_bp = Blueprint('admin', __name__)
|
||||
|
||||
MIN_WACHTWOORD = 8
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hulpfuncties
|
||||
# ------------------------------------------------------------------
|
||||
def beheerbare_scholen():
|
||||
"""Scholen waarvan de ingelogde gebruiker de rechten mag instellen."""
|
||||
scholen = alle_scholen(ook_inactief=True)
|
||||
if is_admin():
|
||||
return scholen
|
||||
gid = session.get('gebruiker_id')
|
||||
if not gid:
|
||||
return []
|
||||
rows = central_query(
|
||||
"SELECT r.School_ID FROM Gebruiker_rechten r JOIN Onderdelen o ON o.Onderdeel_ID = r.Onderdeel_ID "
|
||||
"WHERE r.Gebruiker_ID = %s AND o.Code = 'gebruikersbeheer'", (gid,))
|
||||
ids = {r['School_ID'] for r in rows}
|
||||
return [s for s in scholen if s.id in ids]
|
||||
|
||||
|
||||
def _toegang():
|
||||
"""None als de gebruiker hier mag komen, anders een redirect."""
|
||||
if not session.get('auth') or not session.get('school'):
|
||||
return redirect(url_for('auth.login'))
|
||||
if not is_admin() and not rechten.heeft_recht('gebruikersbeheer'):
|
||||
flash("Je hebt geen rechten om gebruikers te beheren.", "error")
|
||||
return redirect(url_for('main.dashboard'))
|
||||
return None
|
||||
|
||||
|
||||
def _aantal_admins(behalve=None):
|
||||
rows = central_query("SELECT COUNT(*) AS n FROM Gebruikers WHERE Veiligheidsgroep = %s AND Actief = 1 "
|
||||
"AND Gebruiker_ID <> %s", (ADMIN, behalve or 0))
|
||||
return rows[0]['n']
|
||||
|
||||
|
||||
def _onderdelen_per_categorie():
|
||||
groepen = OrderedDict()
|
||||
for code, naam, cat in ONDERDELEN:
|
||||
groepen.setdefault(cat, []).append((code, naam))
|
||||
return groepen
|
||||
|
||||
|
||||
def _wachtwoord_ok(pw, pw2):
|
||||
if pw != pw2:
|
||||
return "De wachtwoorden komen niet overeen."
|
||||
if len(pw) < MIN_WACHTWOORD:
|
||||
return f"Het wachtwoord moet minstens {MIN_WACHTWOORD} tekens hebben."
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Overzicht + nieuwe gebruiker
|
||||
# ------------------------------------------------------------------
|
||||
@admin_bp.route("/settings", methods=["GET"])
|
||||
def settings():
|
||||
weg = _toegang()
|
||||
if weg:
|
||||
return weg
|
||||
users = central_query(
|
||||
"SELECT g.Gebruiker_ID, g.Gebruikersnaam, g.Emailadres, g.Veiligheidsgroep, g.Actief, g.Last_login, "
|
||||
" v.Naam AS Rol "
|
||||
"FROM Gebruikers g JOIN Veiligheidsgroepen v ON v.Veiligheidsgroep = g.Veiligheidsgroep "
|
||||
"ORDER BY g.Veiligheidsgroep, g.Gebruikersnaam")
|
||||
if not is_admin():
|
||||
users = [u for u in users if int(u['Veiligheidsgroep']) != ADMIN]
|
||||
|
||||
# Aantal rechten per gebruiker per school (voor het overzicht)
|
||||
per = {}
|
||||
for r in central_query("SELECT Gebruiker_ID, School_ID, COUNT(*) AS n FROM Gebruiker_rechten "
|
||||
"GROUP BY Gebruiker_ID, School_ID"):
|
||||
per.setdefault(r['Gebruiker_ID'], {})[r['School_ID']] = r['n']
|
||||
scholen = {s.id: s for s in alle_scholen(ook_inactief=True)}
|
||||
for u in users:
|
||||
u['scholen'] = sorted(((scholen[sid].naam, n) for sid, n in per.get(u['Gebruiker_ID'], {}).items()
|
||||
if sid in scholen), key=lambda x: (-x[1], x[0].lower()))
|
||||
|
||||
return render_template("admin_accounts.html", users=users, groepen=VEILIGHEIDSGROEPEN,
|
||||
ADMIN=ADMIN, totaal_onderdelen=len(ONDERDELEN))
|
||||
|
||||
|
||||
@admin_bp.route("/gebruiker/nieuw", methods=["POST"])
|
||||
def nieuw():
|
||||
weg = _toegang()
|
||||
if weg:
|
||||
return weg
|
||||
naam = (request.form.get('username') or '').strip()
|
||||
email = (request.form.get('email') or '').strip().lower() or None
|
||||
pw = request.form.get('password') or ''
|
||||
pw2 = request.form.get('password_confirm') or ''
|
||||
groep = int(request.form.get('veiligheidsgroep') or GEBRUIKER)
|
||||
if groep not in VEILIGHEIDSGROEPEN or (groep == ADMIN and not is_admin()):
|
||||
groep = GEBRUIKER
|
||||
|
||||
fout = None
|
||||
if not naam:
|
||||
fout = "Vul een gebruikersnaam in."
|
||||
elif not pw and not email:
|
||||
fout = "Geef een wachtwoord, of een e-mailadres om met Google in te loggen."
|
||||
elif pw:
|
||||
fout = _wachtwoord_ok(pw, pw2)
|
||||
if not fout and rechten.zoek_gebruiker(naam):
|
||||
fout = f"Er bestaat al een gebruiker '{naam}'."
|
||||
if not fout and email and rechten.zoek_gebruiker(email):
|
||||
fout = f"Het e-mailadres {email} is al in gebruik."
|
||||
if fout:
|
||||
flash(fout, "error")
|
||||
return redirect(url_for('admin.settings', nieuw=1))
|
||||
|
||||
nieuw_id = central_insert(
|
||||
"INSERT INTO Gebruikers (Gebruikersnaam, Veiligheidsgroep, Passwoordhash, Emailadres, Actief) "
|
||||
"VALUES (%s, %s, %s, %s, 1)",
|
||||
(naam, groep, generate_password_hash(pw) if pw else None, email))
|
||||
rechten.log(actie=f"Gebruiker {naam} aangemaakt")
|
||||
if groep == ADMIN:
|
||||
flash(f"Administrator '{naam}' aangemaakt.", "success")
|
||||
return redirect(url_for('admin.settings'))
|
||||
flash(f"Gebruiker '{naam}' aangemaakt. Vink nu aan wat hij per school mag.", "success")
|
||||
return redirect(url_for('admin.gebruiker_rechten', gid=nieuw_id))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Eén gebruiker: account (gegevens, rol, wachtwoord)
|
||||
# ------------------------------------------------------------------
|
||||
def _gebruiker_of_terug(gid):
|
||||
"""(gebruiker, None) of (None, redirect)."""
|
||||
u = rechten.gebruiker_op_id(gid)
|
||||
if not u:
|
||||
flash("Onbekende gebruiker.", "error")
|
||||
return None, redirect(url_for('admin.settings'))
|
||||
if int(u['Veiligheidsgroep']) == ADMIN and not is_admin():
|
||||
flash("Enkel een administrator kan een administrator wijzigen.", "error")
|
||||
return None, redirect(url_for('admin.settings'))
|
||||
return u, None
|
||||
|
||||
|
||||
@admin_bp.route("/gebruiker/<int:gid>", methods=["GET", "POST"])
|
||||
def gebruiker(gid):
|
||||
weg = _toegang()
|
||||
if weg:
|
||||
return weg
|
||||
u, weg = _gebruiker_of_terug(gid)
|
||||
if weg:
|
||||
return weg
|
||||
zelf = gid == session.get('gebruiker_id')
|
||||
if request.method == 'POST':
|
||||
return _bewaar_account(u, zelf)
|
||||
return render_template("admin_gebruiker.html", u=u, groepen=VEILIGHEIDSGROEPEN, ADMIN=ADMIN, zelf=zelf,
|
||||
min_wachtwoord=MIN_WACHTWOORD)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Eén gebruiker: rechten, één school tegelijk
|
||||
# ------------------------------------------------------------------
|
||||
@admin_bp.route("/gebruiker/<int:gid>/rechten", methods=["GET", "POST"])
|
||||
def gebruiker_rechten(gid):
|
||||
weg = _toegang()
|
||||
if weg:
|
||||
return weg
|
||||
u, weg = _gebruiker_of_terug(gid)
|
||||
if weg:
|
||||
return weg
|
||||
scholen = beheerbare_scholen()
|
||||
per_id = {s.id: s for s in scholen}
|
||||
aantallen = {}
|
||||
for r in central_query("SELECT School_ID, COUNT(*) AS n FROM Gebruiker_rechten WHERE Gebruiker_ID = %s "
|
||||
"GROUP BY School_ID", (gid,)):
|
||||
aantallen[r['School_ID']] = r['n']
|
||||
|
||||
if request.method == 'POST':
|
||||
sid = request.form.get('school_id', type=int)
|
||||
if sid not in per_id:
|
||||
flash("Je mag de rechten in deze school niet instellen.", "error")
|
||||
return redirect(url_for('admin.gebruiker_rechten', gid=gid))
|
||||
codes = [c for c in rechten.CODES if request.form.get(f"r|{c}")]
|
||||
doel = [sid]
|
||||
if request.form.get('ook_alle'):
|
||||
doel = list(per_id)
|
||||
else:
|
||||
doel += [int(x) for x in request.form.getlist('ook') if x.isdigit() and int(x) in per_id]
|
||||
doel = list(dict.fromkeys(doel))
|
||||
door = session.get('user')
|
||||
for school_id in doel:
|
||||
rechten.zet_rechten(gid, school_id, codes, door=door)
|
||||
if len(doel) == 1:
|
||||
rechten.log(actie=f"Rechten van {u['Gebruikersnaam']} in {per_id[sid].naam} gewijzigd")
|
||||
flash(f"Rechten van {u['Gebruikersnaam']} in {per_id[sid].naam} opgeslagen.", "success")
|
||||
else:
|
||||
rechten.log(actie=f"Rechten van {u['Gebruikersnaam']} in {len(doel)} scholen gewijzigd")
|
||||
flash(f"Rechten van {u['Gebruikersnaam']} opgeslagen in {len(doel)} scholen.", "success")
|
||||
return redirect(url_for('admin.gebruiker_rechten', gid=gid, school=sid))
|
||||
|
||||
# Welke school tonen: gevraagd > huidige school > eerste school met rechten > eerste school
|
||||
gekozen = request.args.get('school', type=int)
|
||||
if gekozen not in per_id:
|
||||
huidig = school_op_code(session.get('school') or '')
|
||||
huidig_id = huidig.id if huidig else None
|
||||
if huidig_id in per_id and (aantallen.get(huidig_id) or not aantallen):
|
||||
gekozen = huidig_id
|
||||
else:
|
||||
met = [s.id for s in scholen if aantallen.get(s.id)]
|
||||
gekozen = met[0] if met else (scholen[0].id if scholen else None)
|
||||
|
||||
lijst = sorted(scholen, key=lambda s: (not aantallen.get(s.id), s.naam.lower()))
|
||||
return render_template("admin_rechten.html", u=u, scholen=lijst, gekozen=per_id.get(gekozen),
|
||||
aantallen=aantallen, huidig=rechten.rechten_van(gid, gekozen) if gekozen else set(),
|
||||
categorieen=_onderdelen_per_categorie(), ADMIN=ADMIN, totaal=len(ONDERDELEN))
|
||||
|
||||
|
||||
def _bewaar_account(u, zelf):
|
||||
gid = u['Gebruiker_ID']
|
||||
email = (request.form.get('email') or '').strip().lower() or None
|
||||
pw = request.form.get('new_password') or ''
|
||||
pw2 = request.form.get('new_password_confirm') or ''
|
||||
actief = 1 if request.form.get('actief') else 0
|
||||
groep = int(u['Veiligheidsgroep'])
|
||||
if is_admin() and request.form.get('veiligheidsgroep'):
|
||||
groep = int(request.form['veiligheidsgroep'])
|
||||
if groep not in VEILIGHEIDSGROEPEN:
|
||||
groep = int(u['Veiligheidsgroep'])
|
||||
|
||||
terug = redirect(url_for('admin.gebruiker', gid=gid))
|
||||
if zelf and (not actief or groep != int(u['Veiligheidsgroep'])):
|
||||
flash("Je kan je eigen rol niet wijzigen en jezelf niet uitschakelen.", "error")
|
||||
return terug
|
||||
if int(u['Veiligheidsgroep']) == ADMIN and (groep != ADMIN or not actief) and _aantal_admins(behalve=gid) == 0:
|
||||
flash("Er moet minstens één actieve administrator overblijven.", "error")
|
||||
return terug
|
||||
if email:
|
||||
ander = rechten.zoek_gebruiker(email)
|
||||
if ander and ander['Gebruiker_ID'] != gid:
|
||||
flash(f"Het e-mailadres {email} is al in gebruik.", "error")
|
||||
return terug
|
||||
if pw:
|
||||
fout = _wachtwoord_ok(pw, pw2)
|
||||
if fout:
|
||||
flash(fout, "error")
|
||||
return terug
|
||||
central_query("UPDATE Gebruikers SET Passwoordhash = %s WHERE Gebruiker_ID = %s",
|
||||
(generate_password_hash(pw), gid), commit=True)
|
||||
|
||||
central_query("UPDATE Gebruikers SET Emailadres = %s, Veiligheidsgroep = %s, Actief = %s WHERE Gebruiker_ID = %s",
|
||||
(email, groep, actief, gid), commit=True)
|
||||
rechten.log(actie=f"Account {u['Gebruikersnaam']} gewijzigd")
|
||||
flash(f"Account van {u['Gebruikersnaam']} opgeslagen.", "success")
|
||||
return terug
|
||||
|
||||
|
||||
@admin_bp.route("/gebruiker/<int:gid>/verwijderen", methods=["POST"])
|
||||
def verwijderen(gid):
|
||||
weg = _toegang()
|
||||
if weg:
|
||||
return weg
|
||||
u = rechten.gebruiker_op_id(gid)
|
||||
if not u:
|
||||
return redirect(url_for('admin.settings'))
|
||||
if not is_admin():
|
||||
flash("Enkel een administrator kan gebruikers verwijderen.", "error")
|
||||
elif gid == session.get('gebruiker_id'):
|
||||
flash("Je kan jezelf niet verwijderen.", "error")
|
||||
elif int(u['Veiligheidsgroep']) == ADMIN and _aantal_admins(behalve=gid) == 0:
|
||||
flash("Er moet minstens één actieve administrator overblijven.", "error")
|
||||
else:
|
||||
central_query("DELETE FROM Gebruikers WHERE Gebruiker_ID = %s", (gid,), commit=True)
|
||||
rechten.log(actie=f"Gebruiker {u['Gebruikersnaam']} verwijderd")
|
||||
flash(f"Gebruiker '{u['Gebruikersnaam']}' is verwijderd.", "success")
|
||||
return redirect(url_for('admin.settings'))
|
||||
return redirect(url_for('admin.gebruiker', gid=gid))
|
||||
@@ -0,0 +1,157 @@
|
||||
import csv
|
||||
import io
|
||||
from datetime import date, datetime
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, Response
|
||||
from tenancy import huidige_school, per_school
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist
|
||||
from services.afwezigheden_service import AfwezighedenService, schooljaar_start
|
||||
|
||||
afwezigheden_bp = Blueprint('afwezigheden', __name__)
|
||||
service = per_school(AfwezighedenService)
|
||||
|
||||
_tables_ready = set() # scholen waarvoor de tabellen al gecontroleerd zijn
|
||||
|
||||
|
||||
def _ensure_tables():
|
||||
if huidige_school().code not in _tables_ready:
|
||||
try:
|
||||
service.ensure_tables()
|
||||
_tables_ready.add(huidige_school().code)
|
||||
except Exception as e:
|
||||
flash(f"Kon de afwezigheidstabellen niet aanmaken: {e}", "error")
|
||||
|
||||
|
||||
def _filters():
|
||||
"""Leest de filters uit de URL, met het huidige schooljaar als standaard."""
|
||||
def parse(v, standaard):
|
||||
try:
|
||||
return datetime.strptime(v, '%Y-%m-%d').date() if v else standaard
|
||||
except ValueError:
|
||||
return standaard
|
||||
van = parse(request.args.get('van'), schooljaar_start())
|
||||
tot = parse(request.args.get('tot'), date.today())
|
||||
if van > tot:
|
||||
van, tot = tot, van
|
||||
code = (request.args.get('code') or '').upper() or None
|
||||
if code and code not in service.codes:
|
||||
code = None
|
||||
try:
|
||||
minimum = max(1, int(request.args.get('min') or 1))
|
||||
except ValueError:
|
||||
minimum = 1
|
||||
return dict(van=van, tot=tot, code=code, zoek=(request.args.get('zoek') or '').strip(), minimum=minimum)
|
||||
|
||||
|
||||
#---------------------------------------------
|
||||
# Overzicht
|
||||
#---------------------------------------------
|
||||
@afwezigheden_bp.route('/', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('afwezigheden')
|
||||
def overzicht():
|
||||
_ensure_tables()
|
||||
f = _filters()
|
||||
rows, fout, laatste = [], None, {'m': None, 'd': None}
|
||||
try:
|
||||
rows = service.overzicht(f['van'], f['tot'], f['code'], f['zoek'], f['minimum'])
|
||||
laatste = service.laatste_sync()
|
||||
except Exception as e:
|
||||
fout = f"Gegevens konden niet gelezen worden: {e}"
|
||||
|
||||
totaal_halve_dagen = sum(r['Halve_dagen'] for r in rows)
|
||||
return render_template('afwezigheden.html', rows=rows, f=f, fout=fout, laatste=laatste,
|
||||
codes=service.codes, status=service.status,
|
||||
totaal_halve_dagen=totaal_halve_dagen)
|
||||
|
||||
|
||||
#---------------------------------------------
|
||||
# Detail per leerling (JSON, voor het uitklappen in de tabel)
|
||||
#---------------------------------------------
|
||||
@afwezigheden_bp.route('/leerling/<internnummer>.json', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('afwezigheden')
|
||||
def leerling(internnummer):
|
||||
f = _filters()
|
||||
dagen = service.detail(internnummer, f['van'], f['tot'], f['code'])
|
||||
return jsonify([{'datum': d['Datum'].strftime('%d-%m-%Y'),
|
||||
'weekdag': ['ma', 'di', 'wo', 'do', 'vr', 'za', 'zo'][d['Datum'].weekday()],
|
||||
'vm': d['VM'], 'nm': d['NM']} for d in dagen])
|
||||
|
||||
|
||||
#---------------------------------------------
|
||||
# Synchronisatie
|
||||
#---------------------------------------------
|
||||
@afwezigheden_bp.route('/sync', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('afwezigheden', 'laatkomers')
|
||||
def sync():
|
||||
if not huidige_school().heeft('SS_API_PLATFORM', 'SS_API_PW'):
|
||||
flash("Smartschool is nog niet ingesteld voor deze school: vul het platform (bv. school.smartschool.be) "
|
||||
"en het webservice-wachtwoord in via 🔌 Koppelingen van deze school.", "error")
|
||||
return redirect(url_for('afwezigheden.laatkomers' if request.form.get('terug') == 'laatkomers'
|
||||
else 'afwezigheden.overzicht'))
|
||||
_ensure_tables()
|
||||
volledig = request.form.get('volledig') == '1'
|
||||
if service.sync_in_background(volledig=volledig):
|
||||
flash("Synchronisatie met Smartschool gestart. Dit kan enkele minuten duren.", "info")
|
||||
else:
|
||||
flash("Er loopt al een synchronisatie.", "warning")
|
||||
if request.form.get('terug') == 'laatkomers':
|
||||
return redirect(url_for('afwezigheden.laatkomers', **{k: v for k, v in request.args.items()}))
|
||||
return redirect(url_for('afwezigheden.overzicht', **{k: v for k, v in request.args.items()}))
|
||||
|
||||
|
||||
@afwezigheden_bp.route('/status.json', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('afwezigheden', 'laatkomers')
|
||||
def status():
|
||||
s = service.status
|
||||
return jsonify(bezig=s['bezig'], voortgang=s['voortgang'], totaal=s['totaal'],
|
||||
resultaat=s['laatste_resultaat'], fout=s['laatste_fout'])
|
||||
|
||||
|
||||
#---------------------------------------------
|
||||
# Export
|
||||
#---------------------------------------------
|
||||
@afwezigheden_bp.route('/export.csv', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('afwezigheden')
|
||||
def export_csv():
|
||||
f = _filters()
|
||||
rows = service.overzicht(f['van'], f['tot'], f['code'], f['zoek'], f['minimum'])
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf, delimiter=';')
|
||||
w.writerow(['Internnummer', 'Voornaam', 'Naam', 'Gebruikersnaam', 'Halve dagen', 'Dagen',
|
||||
'Eerste', 'Laatste', 'Datums'])
|
||||
for r in rows:
|
||||
dagen = service.detail(r['Internnummer'], f['van'], f['tot'], f['code'])
|
||||
datums = ', '.join(d['Datum'].strftime('%d/%m') + ('' if d['VM'] and d['NM'] else (' VM' if d['VM'] else ' NM'))
|
||||
for d in sorted(dagen, key=lambda x: x['Datum']))
|
||||
w.writerow([r['Internnummer'], r['Voornaam'] or '', r['Naam'] or '', r['Gebruikersnaam'] or '',
|
||||
r['Halve_dagen'], r['Dagen'], r['Eerste'].strftime('%d-%m-%Y'),
|
||||
r['Laatste'].strftime('%d-%m-%Y'), datums])
|
||||
naam = f"afwezigheden_{f['code'] or '-'.join(service.codes)}_{f['van']:%Y%m%d}-{f['tot']:%Y%m%d}.csv"
|
||||
return Response('' + buf.getvalue(), mimetype='text/csv',
|
||||
headers={'Content-Disposition': f'attachment; filename={naam}'})
|
||||
|
||||
|
||||
#---------------------------------------------
|
||||
# Laatkomers
|
||||
#---------------------------------------------
|
||||
@afwezigheden_bp.route('/laatkomers', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('laatkomers')
|
||||
def laatkomers():
|
||||
"""Alle leerlingen die dit schooljaar (of in de gekozen periode) te laat waren."""
|
||||
_ensure_tables()
|
||||
f = _filters()
|
||||
rows, fout, laatste = [], None, {'m': None, 'd': None}
|
||||
try:
|
||||
rows = service.laatkomers(f['van'], f['tot'])
|
||||
laatste = service.laatste_sync()
|
||||
except Exception as e:
|
||||
fout = f"Gegevens konden niet gelezen worden: {e}"
|
||||
return render_template('laatkomers.html', rows=rows, f=f, fout=fout, laatste=laatste,
|
||||
codes=service.laat_codes, status=service.status,
|
||||
totaal=sum(r['Aantal'] for r in rows))
|
||||
@@ -0,0 +1,326 @@
|
||||
from flask import Blueprint, render_template, request, session, redirect, url_for, flash, current_app, make_response
|
||||
from werkzeug.security import check_password_hash
|
||||
import time
|
||||
import functools
|
||||
from authlib.integrations.flask_client import OAuth
|
||||
from config import Config
|
||||
from tenancy import (alle_scholen, school_op_code, school_voor_email, scholen_voor_email, school_voor_host, in_school,
|
||||
huidige_school)
|
||||
import rechten
|
||||
from rechten import recht_vereist, ADMIN, GEBRUIKER
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
oauth = OAuth()
|
||||
|
||||
SCHOOL_COOKIE = 'pivot_school' # onthoudt de laatst gekozen school op dit toestel
|
||||
GOOGLE_METADATA = 'https://accounts.google.com/.well-known/openid-configuration'
|
||||
|
||||
|
||||
def _google_client(school=None):
|
||||
"""
|
||||
Google-login client: die van de school als die een eigen OAuth-client heeft,
|
||||
anders de gedeelde client uit config.ini. Geeft (naam, client) of (None, None).
|
||||
"""
|
||||
if school and school.heeft('GOOGLE_SSO_CLIENTID', 'GOOGLE_SSO_CLIENTSECRET'):
|
||||
naam = f"google_{school.code}_{''.join(c for c in school.versie if c.isdigit())}"
|
||||
cid, secret = school.get('GOOGLE_SSO_CLIENTID'), school.get('GOOGLE_SSO_CLIENTSECRET')
|
||||
elif Config.SSO_CLIENT_ID and Config.SSO_CLIENT_SECRET:
|
||||
naam, cid, secret = 'google', Config.SSO_CLIENT_ID, Config.SSO_CLIENT_SECRET
|
||||
else:
|
||||
return None, None
|
||||
client = oauth.create_client(naam)
|
||||
if client is None:
|
||||
client = oauth.register(name=naam, client_id=cid, client_secret=secret,
|
||||
server_metadata_url=GOOGLE_METADATA,
|
||||
client_kwargs={'scope': 'openid email profile'})
|
||||
return naam, client
|
||||
|
||||
|
||||
def gekozen_school():
|
||||
"""School die vastligt via de hostnaam, of anders de laatst gekozen school (cookie)."""
|
||||
return school_voor_host(request.host) or school_op_code(request.cookies.get(SCHOOL_COOKIE))
|
||||
|
||||
|
||||
def _nummer(school):
|
||||
"""Sorteersleutel: laagste officiële instellingsnummer eerst; scholen zonder nummer achteraan."""
|
||||
nr = ''.join(c for c in str(school.get('INSTELLINGSNUMMER') or '') if c.isdigit())
|
||||
return (0, int(nr), school.naam.lower()) if nr else (1, 0, school.naam.lower())
|
||||
|
||||
|
||||
def standaard_school(scholen=None):
|
||||
"""De school met het laagste instellingsnummer (voor wie nog nooit aangemeld was)."""
|
||||
scholen = alle_scholen() if scholen is None else list(scholen)
|
||||
return min(scholen, key=_nummer) if scholen else None
|
||||
|
||||
|
||||
def kies_school(gebruiker=None, toegang=None, ook=()):
|
||||
"""
|
||||
De school die na het aanmelden geladen wordt (het aanmeldscherm vraagt geen school meer):
|
||||
1) de laatst actieve school van de gebruiker,
|
||||
2) voor een nieuwe gebruiker: de school van zijn e-maildomein (+ eventueel de scholen in `ook`),
|
||||
3) de laatst gekozen school op dit toestel (cookie), 4) als laatste redmiddel: het laagste instellingsnummer.
|
||||
Telkens enkel scholen waarvoor toegang(school) True is (als `toegang` gegeven is).
|
||||
"""
|
||||
ok = toegang or (lambda s: True)
|
||||
domein = _volgens_domein((gebruiker or {}).get('Emailadres'))
|
||||
for s in [school_op_code((gebruiker or {}).get('Laatste_school')), *domein, *ook,
|
||||
school_op_code(request.cookies.get(SCHOOL_COOKIE))]:
|
||||
if s and ok(s):
|
||||
return s
|
||||
return standaard_school(s for s in alle_scholen() if ok(s))
|
||||
|
||||
|
||||
def _volgens_domein(email):
|
||||
"""Scholen met het e-maildomein (een domein kan door meerdere scholen gedeeld worden): de laatst gekozen
|
||||
school op dit toestel eerst, dan op instellingsnummer."""
|
||||
lijst = sorted(scholen_voor_email(email), key=_nummer)
|
||||
cookie = request.cookies.get(SCHOOL_COOKIE)
|
||||
return sorted(lijst, key=lambda s: s.code != cookie)
|
||||
|
||||
|
||||
def is_superbeheerder(email):
|
||||
return (email or '').lower() in Config.SUPERBEHEERDERS
|
||||
|
||||
|
||||
def _login_ok(school, user, naam, groep, superbeheerder=False, gebruiker=None):
|
||||
"""
|
||||
Zet de sessie. `gebruiker` is de rij uit de centrale tabel Gebruikers (of None voor een
|
||||
superbeheerder uit config.ini of een leerkracht zonder PIVOT-account).
|
||||
Administrators (Veiligheidsgroep 1) zijn superbeheerder: alle rechten in alle scholen.
|
||||
"""
|
||||
admin = bool(gebruiker and int(gebruiker['Veiligheidsgroep']) == ADMIN)
|
||||
session.clear()
|
||||
session["auth"] = True
|
||||
session["user"] = user
|
||||
session["naam"] = naam
|
||||
session["gebruiker_id"] = gebruiker['Gebruiker_ID'] if gebruiker else None
|
||||
session["admin"] = admin or bool(superbeheerder)
|
||||
session["superbeheerder"] = admin or bool(superbeheerder)
|
||||
if groep is None and gebruiker:
|
||||
groep = ADMIN if session["admin"] else GEBRUIKER
|
||||
session["security_group"] = int(groep) if groep is not None else None
|
||||
session["school"] = school.code if school else None
|
||||
if gebruiker:
|
||||
rechten.registreer_login(gebruiker['Gebruiker_ID'], request.remote_addr)
|
||||
if school:
|
||||
rechten.bewaar_laatste_school(gebruiker['Gebruiker_ID'], school.code)
|
||||
|
||||
|
||||
def _onthoud_school(response, school):
|
||||
if school:
|
||||
response.set_cookie(SCHOOL_COOKIE, school.code, max_age=365 * 24 * 3600, samesite='Lax',
|
||||
secure=request.is_secure, httponly=True)
|
||||
return response
|
||||
|
||||
|
||||
#----------------------------------------------
|
||||
# BRUTE FORCE BEVEILIGING
|
||||
#----------------------------------------------
|
||||
attempts = {}
|
||||
|
||||
def client_ip():
|
||||
return request.remote_addr
|
||||
|
||||
def is_locked():
|
||||
ip = client_ip()
|
||||
if ip in attempts and attempts[ip]['count'] >= 5:
|
||||
elapsed = time.time() - attempts[ip]['last_time']
|
||||
if elapsed < 60:
|
||||
return True, int(60 - elapsed)
|
||||
else:
|
||||
attempts.pop(ip)
|
||||
return False, 0
|
||||
|
||||
def record_failure():
|
||||
ip = client_ip()
|
||||
if ip not in attempts:
|
||||
attempts[ip] = {'count': 1, 'last_time': time.time()}
|
||||
else:
|
||||
attempts[ip]['count'] += 1
|
||||
attempts[ip]['last_time'] = time.time()
|
||||
|
||||
#----------------------------------------------
|
||||
# --- GOOGLE SSO ROUTES ---
|
||||
#----------------------------------------------
|
||||
|
||||
@auth_bp.route('/google-login')
|
||||
def google_login():
|
||||
"""Start het Google inlogproces. De school volgt uit de hostnaam, ?school=code of het e-maildomein."""
|
||||
vast = school_voor_host(request.host) or school_op_code(request.args.get('school'))
|
||||
# Zonder vaste school: de Google-client van de laatst gekozen school op dit toestel (of de standaardschool).
|
||||
# Welke school na het aanmelden geladen wordt, beslist de callback.
|
||||
school = vast or school_op_code(request.cookies.get(SCHOOL_COOKIE)) or standaard_school()
|
||||
naam, client = _google_client(school)
|
||||
if not client:
|
||||
flash("Inloggen met Google is (nog) niet ingesteld.", "error")
|
||||
return redirect(url_for('auth.login'))
|
||||
session['sso'] = {'client': naam, 'school': vast.code if vast else ''}
|
||||
extra = {'prompt': 'select_account'}
|
||||
if vast and len(vast.domeinen) == 1:
|
||||
extra['hd'] = school.domein # Google toont enkel accounts van dit domein
|
||||
redirect_uri = url_for('auth.callback', _external=True)
|
||||
return client.authorize_redirect(redirect_uri, **extra)
|
||||
|
||||
|
||||
@auth_bp.route('/callback')
|
||||
def callback():
|
||||
"""Verwerkt het antwoord van Google en bepaalt de school op basis van het e-maildomein."""
|
||||
sso = session.pop('sso', None) or {}
|
||||
try:
|
||||
client = oauth.create_client(sso.get('client') or 'google')
|
||||
token = client.authorize_access_token()
|
||||
user_info = token.get('userinfo') or {}
|
||||
except Exception as e:
|
||||
flash("Fout bij ophalen van Google gegevens.", "error")
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
email = (user_info.get('email') or '').lower()
|
||||
if not email or user_info.get('email_verified') is False:
|
||||
flash("Google gaf geen geverifieerd e-mailadres terug.", "error")
|
||||
return redirect(url_for('auth.login'))
|
||||
naam = user_info.get('name') or email
|
||||
super_ = is_superbeheerder(email)
|
||||
|
||||
# Geregistreerde PIVOT-gebruiker (centrale tabel Gebruikers)?
|
||||
try:
|
||||
gebruiker = rechten.zoek_gebruiker(email)
|
||||
except Exception:
|
||||
flash("De centrale PIVOT-database is niet bereikbaar.", "error")
|
||||
return redirect(url_for('auth.login'))
|
||||
if gebruiker and not gebruiker['Actief']:
|
||||
gebruiker = None
|
||||
|
||||
def heeft_toegang(s):
|
||||
return bool(gebruiker) and (super_ or rechten.mag_in_school(gebruiker, s))
|
||||
|
||||
gekozen = school_op_code(sso.get('school')) # enkel als de school vastligt (hostnaam)
|
||||
domeinscholen = _volgens_domein(email) # meerdere scholen kunnen hetzelfde domein hebben
|
||||
volgens_domein = next((s for s in domeinscholen if heeft_toegang(s)), None) or \
|
||||
(domeinscholen[0] if domeinscholen else None)
|
||||
if gekozen:
|
||||
school = gekozen
|
||||
elif gebruiker and heeft_toegang_ergens(gebruiker, super_):
|
||||
# Laatst actieve school → school van het e-maildomein → laatst gekozen op dit toestel → laagste nummer
|
||||
school = kies_school(gebruiker, heeft_toegang, ook=domeinscholen)
|
||||
elif super_:
|
||||
school = kies_school(None, None, ook=domeinscholen)
|
||||
else:
|
||||
school = volgens_domein
|
||||
|
||||
if gekozen and gekozen not in domeinscholen and not super_ and not heeft_toegang(gekozen):
|
||||
flash(f"{email} hoort niet bij {gekozen.naam}.", "error")
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
if school is None:
|
||||
if super_ or (gebruiker and int(gebruiker['Veiligheidsgroep']) == ADMIN):
|
||||
# Superbeheerder van wie het domein bij geen school hoort: meteen naar scholenbeheer
|
||||
_login_ok(None, email, naam, None, superbeheerder=True, gebruiker=gebruiker)
|
||||
flash("Ingelogd als superbeheerder. Kies of maak een school.", "success")
|
||||
return redirect(url_for('scholen.overzicht'))
|
||||
flash(f"Het domein van {email} hoort bij geen enkele school in PIVOT.", "error")
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
# 1) PIVOT-gebruiker met rechten in deze school
|
||||
if heeft_toegang(school):
|
||||
_login_ok(school, gebruiker['Gebruikersnaam'], naam, None, super_, gebruiker=gebruiker)
|
||||
flash(f"Succesvol ingelogd via Google: {gebruiker['Gebruikersnaam']}", "success")
|
||||
return _onthoud_school(redirect(url_for("main.dashboard")), school)
|
||||
|
||||
# 2) Superbeheerder van de scholengroep (config.ini) → beheerder in elke school
|
||||
if super_:
|
||||
_login_ok(school, email, naam, ADMIN, True)
|
||||
return _onthoud_school(redirect(url_for("main.dashboard")), school)
|
||||
|
||||
# 3) Personeelslid van de school (personeels-OU's) → beperkte toegang tot de personeelsmodule.
|
||||
# Delen meerdere scholen het domein, dan de eerste school waar hij personeelslid is.
|
||||
from services.personeel_service import is_personeelslid, LEERKRACHT_GROEP
|
||||
for kandidaat in [school] + [s for s in domeinscholen if s is not school and not gekozen]:
|
||||
with in_school(kandidaat):
|
||||
if is_personeelslid(email):
|
||||
_login_ok(kandidaat, email, naam, LEERKRACHT_GROEP)
|
||||
return _onthoud_school(redirect(url_for("personeel.mijn")), kandidaat)
|
||||
|
||||
flash(f"Toegang geweigerd: {email} is niet geregistreerd in PIVOT van {school.naam}.", "error")
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
def heeft_toegang_ergens(gebruiker, super_=False):
|
||||
return bool(gebruiker) and (super_ or any(rechten.mag_in_school(gebruiker, s) for s in alle_scholen()))
|
||||
|
||||
|
||||
#----------------------------------------------
|
||||
# --- HANDMATIGE LOGIN/LOGOUT ---
|
||||
#----------------------------------------------
|
||||
|
||||
def _login_pagina(school=None):
|
||||
vast = school_voor_host(request.host)
|
||||
return render_template("pin.html",
|
||||
scholen=alle_scholen(),
|
||||
gekozen=vast or school or gekozen_school(),
|
||||
school_vast=bool(vast),
|
||||
google_aan=bool(Config.SSO_CLIENT_ID) or any(
|
||||
s.heeft('GOOGLE_SSO_CLIENTID') for s in alle_scholen()))
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
locked, wait_time = is_locked()
|
||||
if locked:
|
||||
flash(f"Te veel pogingen. Probeer opnieuw over {wait_time} seconden.", "error")
|
||||
return _login_pagina()
|
||||
|
||||
if request.method == "POST":
|
||||
entered_username = request.form.get("username", "").strip()
|
||||
entered_password = request.form.get("password", "")
|
||||
school = school_voor_host(request.host) # ligt de school vast via de hostnaam?
|
||||
|
||||
if not entered_username or not entered_password:
|
||||
record_failure()
|
||||
flash("Vul zowel gebruikersnaam als wachtwoord in.", "error")
|
||||
return _login_pagina(school)
|
||||
|
||||
try:
|
||||
gebruiker = rechten.zoek_gebruiker(entered_username)
|
||||
except Exception:
|
||||
flash("De centrale PIVOT-database is niet bereikbaar.", "error")
|
||||
return _login_pagina(school)
|
||||
|
||||
if (gebruiker and gebruiker['Actief'] and gebruiker['Passwoordhash']
|
||||
and check_password_hash(gebruiker['Passwoordhash'], entered_password)):
|
||||
attempts.pop(client_ip(), None)
|
||||
if school and not rechten.mag_in_school(gebruiker, school):
|
||||
flash(f"Je hebt geen rechten in {school.naam}. Vraag een administrator om toegang.", "error")
|
||||
return _login_pagina(school)
|
||||
# Geen school gevraagd: laatst actieve school, anders de school van het e-maildomein
|
||||
school = school or kies_school(gebruiker, lambda s: rechten.mag_in_school(gebruiker, s))
|
||||
if not school:
|
||||
flash("Je hebt nog geen rechten in een school. Vraag een administrator om toegang.", "error")
|
||||
return _login_pagina()
|
||||
_login_ok(school, gebruiker['Gebruikersnaam'], gebruiker['Gebruikersnaam'], None, gebruiker=gebruiker)
|
||||
flash(f"Welkom terug, {gebruiker['Gebruikersnaam']}!", "success")
|
||||
return _onthoud_school(redirect(url_for("main.dashboard")), school)
|
||||
|
||||
record_failure()
|
||||
flash("Ongeldige inloggegevens.", "error")
|
||||
return _login_pagina(school)
|
||||
|
||||
return _login_pagina()
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
def logout():
|
||||
session.clear()
|
||||
flash("Je bent nu uitgelogd.", "success")
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
#----------------------------------------------
|
||||
# --- DECORATORS ---
|
||||
#----------------------------------------------
|
||||
|
||||
def login_required(view_func):
|
||||
@functools.wraps(view_func)
|
||||
def wrapped_view(*args, **kwargs):
|
||||
if not session.get("auth") or not session.get("school"):
|
||||
return redirect(url_for("auth.login"))
|
||||
return view_func(*args, **kwargs)
|
||||
return wrapped_view
|
||||
|
||||
# Rechten per school: zie rechten.py (recht_vereist, heeft_recht, admin_vereist)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Basisonderwijs › Bingel: leerlingenlijst uit Informat als Excel voor de import in Bingel."""
|
||||
import io
|
||||
from datetime import date
|
||||
|
||||
from flask import Blueprint, render_template, request, Response, redirect, url_for, flash
|
||||
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist
|
||||
from services import bingel_service as bs
|
||||
from tenancy import huidige_school
|
||||
|
||||
basisonderwijs_bp = Blueprint('basisonderwijs', __name__)
|
||||
KOLOMMEN = ['Klas', 'Voornaam', 'Familienaam', 'Wachtwoord', 'Klasnummer', 'Geslacht']
|
||||
|
||||
|
||||
@basisonderwijs_bp.route('/bingel', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('bingel')
|
||||
def bingel():
|
||||
rijen, voorbeeld, fout, waarschuwing = [], {}, None, None
|
||||
try:
|
||||
rijen, voorbeeld, waarschuwing = bs.leerlingen(vernieuw=bool(request.args.get('vernieuw')))
|
||||
except bs.BingelFout as e:
|
||||
fout = str(e)
|
||||
except Exception as e:
|
||||
fout = f"Leerlingen ophalen uit Informat mislukt: {e}"
|
||||
if request.args.get('vernieuw'):
|
||||
return redirect(url_for('basisonderwijs.bingel'))
|
||||
klassen = sorted({r['klas'] for r in rijen}, key=str.lower)
|
||||
return render_template('bingel.html', rijen=rijen, klassen=klassen, voorbeeld=voorbeeld, fout=fout, waarschuwing=waarschuwing,
|
||||
kolommen=KOLOMMEN, school_naam=huidige_school().naam,
|
||||
zonder_klas=sum(1 for r in rijen if not r['klas']),
|
||||
zonder_geslacht=sum(1 for r in rijen if r['geslacht'] not in ('M', 'V')))
|
||||
|
||||
|
||||
@basisonderwijs_bp.route('/bingel/export.xlsx', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('bingel')
|
||||
def bingel_export():
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font
|
||||
try:
|
||||
rijen, _, _ = bs.leerlingen()
|
||||
except Exception as e:
|
||||
flash(str(e), "error")
|
||||
return redirect(url_for('basisonderwijs.bingel'))
|
||||
gekozen = request.args.getlist('klas') # aangevinkte klassen (niets = alle)
|
||||
if gekozen:
|
||||
rijen = [r for r in rijen if r['klas'] in gekozen]
|
||||
klas = gekozen[0] if len(gekozen) == 1 else ''
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = 'Leerlingen'
|
||||
ws.append(KOLOMMEN)
|
||||
for c in ws[1]:
|
||||
c.font = Font(bold=True)
|
||||
for r in rijen:
|
||||
ws.append([r['klas'], r['voornaam'], r['naam'], '', r['klasnummer'], r['geslacht']])
|
||||
for kol, breedte in zip('ABCDEF', (12, 18, 22, 14, 11, 10)):
|
||||
ws.column_dimensions[kol].width = breedte
|
||||
ws.freeze_panes = 'A2'
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
deel = ''.join(c if c.isalnum() else '_' for c in klas) + '_' if klas else ''
|
||||
return Response(buf.getvalue(), mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
headers={'Content-Disposition': f"attachment; filename=Bingel_{huidige_school().code}_{deel}"
|
||||
f"{date.today():%Y%m%d}.xlsx"})
|
||||
@@ -0,0 +1,74 @@
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
|
||||
from database import central_query
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist
|
||||
|
||||
bugreport_bp = Blueprint('bugreport', __name__)
|
||||
|
||||
@bugreport_bp.route('/', methods=['GET'])
|
||||
@login_required
|
||||
def bugreport():
|
||||
"""Toont het bugreport formulier."""
|
||||
|
||||
if 'security_group' not in session:
|
||||
flash('Je moet eerst inloggen om een bug te rapporteren.', 'error')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return render_template('bugreport.html')
|
||||
|
||||
|
||||
@bugreport_bp.route('/submit', methods=['POST'])
|
||||
@login_required
|
||||
def bugreport_submit():
|
||||
"""Verwerkt het ingezonden bugreport en slaat het op in de database."""
|
||||
|
||||
|
||||
# Gegevens ophalen uit het HTML formulier
|
||||
tijdstip_str = request.form.get('tijdstip')
|
||||
omschrijving = request.form.get('omschrijving')
|
||||
foutcode = request.form.get('foutcode') # Kan leeg zijn (None of lege string)
|
||||
|
||||
# Automatische gegevens
|
||||
gebruiker_id = session["user"] # Of session.get('email'), afhankelijk van je login-systeem
|
||||
status = 'Nieuw'
|
||||
|
||||
# Validatie (html5 required is een back-up, server-side validatie is veiliger)
|
||||
if not tijdstip_str or not omschrijving:
|
||||
flash('Tijdstip en omschrijving zijn verplichte velden!', 'error')
|
||||
return redirect(url_for('bugreport.bugreport_page'))
|
||||
|
||||
try:
|
||||
# Converteren van datetime-local string (YYYY-MM-DDTHH:MM) naar MySQL formaat
|
||||
tijdstip = datetime.strptime(tijdstip_str, '%Y-%m-%dT%H:%M')
|
||||
except ValueError:
|
||||
flash('Ongeldig datum- of tijdformaat ontvangen.', 'error')
|
||||
return redirect(url_for('bugreport.bugreport_page'))
|
||||
|
||||
# SQL query om de bug op te slaan
|
||||
# Pas de tabelnaam ('bugs') en kolomnamen aan naar jouw exacte database-schema
|
||||
# Centrale database: Bugs (Bugstatus_ID 1 = Nieuw)
|
||||
from tenancy import huidige_school
|
||||
school = huidige_school(verplicht=False)
|
||||
query = """
|
||||
INSERT INTO Bugs (Timestamp, Omschrijving, Foutcode, Bugstatus_ID, Gebruiker_ID, Gebruikersnaam, School_ID)
|
||||
VALUES (%s, %s, %s, 1, %s, %s, %s)
|
||||
"""
|
||||
params = (tijdstip, omschrijving, foutcode or None, session.get('gebruiker_id'), gebruiker_id,
|
||||
school.id if school else None)
|
||||
|
||||
try:
|
||||
# Voer de query uit met commit=True om de data op te slaan
|
||||
central_query(query, params, commit=True)
|
||||
|
||||
# Succesmelding sturen en doorverwijzen naar het dashboard
|
||||
flash('Je bugreport is succesvol ingediend! Bedankt voor je feedback.', 'success')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
except Exception as e:
|
||||
# Log de fout eventueel in de console voor debugging
|
||||
print(f"Database error tijdens bugreport: {e}")
|
||||
|
||||
# Foutmelding tonen aan de gebruiker
|
||||
flash('Er ging iets mis bij het opslaan in de database. Probeer het later opnieuw.', 'error')
|
||||
return redirect(url_for('bugreport.bugreport_page'))
|
||||
@@ -0,0 +1,62 @@
|
||||
from flask import Blueprint, render_template, request, session, redirect, url_for, flash, jsonify
|
||||
from database import central_query
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist
|
||||
|
||||
|
||||
|
||||
# We noemen de blueprint 'devices'
|
||||
bugreport_overzicht_bp = Blueprint('bugreport_overzicht', __name__)
|
||||
|
||||
#-------------------------------------------------
|
||||
# --- ROUTE TOESTELLEN (Inventaris Toestellen) ---
|
||||
#-------------------------------------------------
|
||||
@bugreport_overzicht_bp.route("/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@recht_vereist('bugs_bekijken', 'bugs_beheren')
|
||||
def bugreport_overzicht():
|
||||
query = request.form.get("query", "") if request.method == "POST" else request.args.get("query", "")
|
||||
message = None
|
||||
|
||||
search_term = f"%{query}%"
|
||||
# Bugs staan in de centrale database (gelden voor PIVOT, niet voor één school)
|
||||
sql = """
|
||||
SELECT * FROM (
|
||||
SELECT b.Bug_ID, b.Gebruikersnaam AS Gebruiker, b.Timestamp AS Datum, b.Omschrijving, b.Foutcode,
|
||||
st.Omschrijving AS Status, sc.School_naam AS School
|
||||
FROM Bugs b
|
||||
JOIN Status st ON st.Bugstatus_ID = b.Bugstatus_ID
|
||||
LEFT JOIN Scholen sc ON sc.School_ID = b.School_ID
|
||||
) x
|
||||
WHERE CONCAT_WS(' ', Bug_ID, Gebruiker, Datum, Omschrijving, Foutcode, Status, School) LIKE %s
|
||||
ORDER BY Bug_ID DESC
|
||||
"""
|
||||
|
||||
results = central_query(sql, (search_term,))
|
||||
|
||||
|
||||
if query and not results:
|
||||
message = f"Geen resultaten gevonden voor '{query}'."
|
||||
elif not results:
|
||||
message = "Er zijn momenteel geen bugreports."
|
||||
|
||||
return render_template("bugreport_overzicht.html", results=results, query=query, message=message)
|
||||
|
||||
#---------------------------------
|
||||
# --- STATUS UPDATES ---
|
||||
#---------------------------------
|
||||
@bugreport_overzicht_bp.route("/update_bugreport", methods=["POST"])
|
||||
@login_required
|
||||
@recht_vereist('bugs_beheren')
|
||||
def bugreport_wijzig_status():
|
||||
Bug_ID = request.form.get("Bug_ID")
|
||||
new_status = request.form.get("new_status")
|
||||
query = request.form.get("query", "")
|
||||
|
||||
if new_status in ["Nieuw", "In behandeling", "Fixed"]:
|
||||
central_query("UPDATE Bugs SET Bugstatus_ID = (SELECT Bugstatus_ID FROM Status WHERE Omschrijving = %s) "
|
||||
"WHERE Bug_ID = %s", (new_status, Bug_ID), commit=True)
|
||||
flash(f"Status van {Bug_ID} gewijzigd naar {new_status}", "success")
|
||||
|
||||
return redirect(url_for("bugreport_overzicht.bugreport_overzicht", query=query))
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, jsonify, request
|
||||
from services.co2_service import CO2Scanner
|
||||
from database import execute_query
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist
|
||||
from tenancy import per_school
|
||||
|
||||
co2_sensor_bp = Blueprint('co2_sensor', __name__)
|
||||
scanner = per_school(CO2Scanner) # één scanner per school
|
||||
|
||||
#----------------------------------------------
|
||||
# Route voor het CO2 dashboard
|
||||
#----------------------------------------------
|
||||
|
||||
@co2_sensor_bp.route('/dashboard')
|
||||
@login_required
|
||||
@recht_vereist('co2')
|
||||
def dashboard():
|
||||
# haalt de meest recente record voor elke ruimte
|
||||
query = """
|
||||
SELECT lk.Naam AS lokaal, m.ppm, m.Timestamp AS timestamp
|
||||
FROM CO2_Metingen m
|
||||
JOIN Lokalen lk ON lk.Lokaal_ID = m.Lokaal_ID
|
||||
WHERE m.Meting_ID IN (SELECT MAX(Meting_ID) FROM CO2_Metingen GROUP BY Lokaal_ID)
|
||||
ORDER BY lk.Naam ASC
|
||||
"""
|
||||
sensors = execute_query(query)
|
||||
return render_template('CO2_sensor.html', sensors=sensors)
|
||||
|
||||
#---------------------------------------------
|
||||
# Routes en Logica
|
||||
#----------------------------------------------
|
||||
# Verwijderd record ouder dan 7 dagen uit DB
|
||||
def perform_cleanup():
|
||||
"""Verwijder data ouder dan 7 dagen uit de database."""
|
||||
query = "DELETE FROM CO2_Metingen WHERE Timestamp < NOW() - INTERVAL 7 DAY"
|
||||
try:
|
||||
execute_query(query, commit=True)
|
||||
print("Database opruiming voltooid.")
|
||||
except Exception as e:
|
||||
print(f"Fout bij database opruiming: {e}")
|
||||
|
||||
|
||||
# Toggleknop voor automatisch verwijderen
|
||||
@co2_sensor_bp.route('/toggle_auto_delete', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('co2')
|
||||
def toggle_auto_delete():
|
||||
"""Schakelt het automatisch verwijderen van oude data aan/uit."""
|
||||
data = request.get_json()
|
||||
is_active = data.get('active', False)
|
||||
|
||||
# Hier kun je een variabele in de scanner of een config opslaan
|
||||
# Voor nu simuleren we de status-bevestiging
|
||||
scanner.auto_delete = is_active
|
||||
|
||||
if is_active:
|
||||
perform_cleanup()
|
||||
|
||||
return jsonify({'active': scanner.auto_delete})
|
||||
|
||||
|
||||
@co2_sensor_bp.route('/get_cleanup_status')
|
||||
@login_required
|
||||
@recht_vereist('co2')
|
||||
def get_cleanup_status():
|
||||
"""Geeft de huidige status van de auto-delete instelling terug."""
|
||||
# Zorg dat 'auto_delete' bestaat in je CO2Scanner klasse of gebruik een globale
|
||||
status = getattr(scanner, 'auto_delete', False)
|
||||
return jsonify({'active': status})
|
||||
|
||||
|
||||
# Eenmalige scan uitvoeren
|
||||
@co2_sensor_bp.route('/scan')
|
||||
@login_required
|
||||
@recht_vereist('co2')
|
||||
def scan_co2():
|
||||
"""Trigger één handmatige scan-ronde."""
|
||||
scanner.perform_scan()
|
||||
flash("Handmatige scan voltooid.", "success")
|
||||
return redirect(url_for('co2_sensor.dashboard'))
|
||||
|
||||
# Start herhalende scan
|
||||
@co2_sensor_bp.route('/toggle_continuous_scan', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('co2')
|
||||
def toggle_continuous_scan():
|
||||
"""Schakelt de achtergrond-thread aan of uit via de website."""
|
||||
data = request.get_json()
|
||||
if data.get('active'):
|
||||
scanner.start()
|
||||
else:
|
||||
scanner.stop()
|
||||
return jsonify({'active': scanner.active})
|
||||
|
||||
@co2_sensor_bp.route('/get_scan_status')
|
||||
@login_required
|
||||
@recht_vereist('co2')
|
||||
def get_scan_status():
|
||||
"""Zorgt dat de switch in de HTML op de juiste stand staat bij laden."""
|
||||
return jsonify({'active': scanner.active})
|
||||
|
||||
|
||||
#-----------------------------------------------
|
||||
# Opent grafiekvenster met 50 waarden
|
||||
#-----------------------------------------------
|
||||
@co2_sensor_bp.route('/grafiek/<lokaal>')
|
||||
@login_required
|
||||
@recht_vereist('co2')
|
||||
def grafiek(lokaal):
|
||||
# Haal de laatste 50 metingen op (oud → nieuw)
|
||||
results = execute_query(
|
||||
"SELECT ppm, timestamp FROM ("
|
||||
" SELECT m.ppm, m.Timestamp AS timestamp FROM CO2_Metingen m "
|
||||
" JOIN Lokalen lk ON lk.Lokaal_ID = m.Lokaal_ID WHERE lk.Naam = %s "
|
||||
" ORDER BY m.Timestamp DESC LIMIT 50) x ORDER BY timestamp ASC",
|
||||
(lokaal,)
|
||||
)
|
||||
|
||||
# Split de data voor Chart.js
|
||||
# We gebruiken ASC zodat de grafiek van links (oud) naar rechts (nieuw) loopt
|
||||
labels = [row['timestamp'].strftime("%d-%m %H:%M:%S") if hasattr(row['timestamp'], 'strftime') else str(row['timestamp']) for row in results]
|
||||
values = [row['ppm'] for row in results]
|
||||
|
||||
return render_template('grafiek.html', lokaal=lokaal, labels=labels, data=values)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Gmail › Contacten: contactlijsten in de mailboxen en het adresboek van de organisatie (zichtbaar/verborgen)."""
|
||||
|
||||
from flask import Blueprint, render_template, request, session, jsonify
|
||||
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist, log
|
||||
from services import contacten_service as cs
|
||||
|
||||
contacten_bp = Blueprint('contacten', __name__)
|
||||
TABS = ('lijsten', 'adresboek')
|
||||
|
||||
|
||||
def _door():
|
||||
return session.get('naam') or session.get('user')
|
||||
|
||||
|
||||
@contacten_bp.route('/', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('gmail_contacten')
|
||||
def overzicht():
|
||||
tab = request.args.get('tab') if request.args.get('tab') in TABS else TABS[0]
|
||||
ctx = {'tab': tab, 'fout': None, 'status': cs.LijstSync().status}
|
||||
try:
|
||||
if tab == 'adresboek':
|
||||
ctx['ous'] = cs.ou_lijst()
|
||||
ctx['ou'] = request.args.get('ou') or ''
|
||||
ctx['met_sub'] = 'sub' not in request.args or '1' in request.args.getlist('sub')
|
||||
ctx['gebruikers'] = cs.gebruikers_in([ctx['ou']], ctx['met_sub']) if ctx['ou'] else []
|
||||
else:
|
||||
ctx['lijsten'] = cs.lijsten()
|
||||
lid = request.args.get('lijst', type=int)
|
||||
ctx['gekozen'] = cs.lijst(lid) if lid else None
|
||||
ctx['nieuw'] = bool(request.args.get('nieuw'))
|
||||
if ctx['gekozen'] or ctx['nieuw']:
|
||||
ctx['ous'] = cs.ou_lijst()
|
||||
except cs.ContactFout as e:
|
||||
ctx['fout'] = str(e)
|
||||
except Exception as e:
|
||||
ctx['fout'] = cs.vriendelijk(e)
|
||||
return render_template('contacten.html', **ctx)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# 1. Adresboek
|
||||
# ---------------------------------------------------------------------
|
||||
@contacten_bp.route('/adresboek', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('gmail_contacten')
|
||||
def adresboek():
|
||||
d = request.get_json(silent=True) or {}
|
||||
emails = [e for e in d.get('emails') or [] if isinstance(e, str)][:2000]
|
||||
if not emails:
|
||||
return jsonify(ok=False, fout="Niemand gekozen."), 400
|
||||
try:
|
||||
fouten = cs.zet_zichtbaar(emails, bool(d.get('zichtbaar')))
|
||||
except Exception as e:
|
||||
return jsonify(ok=False, fout=cs.vriendelijk(e)), 400
|
||||
log(f"Contacten: {len(emails)} gebruiker(s) {'zichtbaar' if d.get('zichtbaar') else 'verborgen'} in het adresboek")
|
||||
return jsonify(ok=not fouten, fouten=fouten, aantal=len(emails) - len(fouten))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# 2. Contactlijsten in de mailboxen
|
||||
# ---------------------------------------------------------------------
|
||||
@contacten_bp.route('/lijst/bewaren', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('gmail_contacten')
|
||||
def lijst_bewaren():
|
||||
d = request.get_json(silent=True) or {}
|
||||
lid = int(d['id']) if str(d.get('id') or '').isdigit() else None
|
||||
oud = cs.lijst(lid) if lid else None
|
||||
try:
|
||||
lid = cs.lijst_bewaren(lid, d.get('naam'), [o for o in d.get('ous') or [] if isinstance(o, str)],
|
||||
bool(d.get('met_sub')), [i for i in d.get('items') or [] if isinstance(i, dict)], _door())
|
||||
except cs.ContactFout as e:
|
||||
return jsonify(ok=False, fout=str(e)), 400
|
||||
log(f"Contacten: contactlijst {d.get('naam')} bewaard")
|
||||
gestart = False
|
||||
# Stond de lijst al in de mailboxen? Dan meteen bijwerken (ook bij wie er door een OU-wijziging uit valt)
|
||||
if oud and oud.get('Laatste_sync'):
|
||||
gestart = cs.LijstSync().start(lid, oude_naam=oud['Naam'], oude_ous=oud['ous'])
|
||||
return jsonify(ok=True, id=lid, gestart=gestart)
|
||||
|
||||
|
||||
@contacten_bp.route('/lijst/<int:lijst_id>/sync', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('gmail_contacten')
|
||||
def lijst_sync(lijst_id):
|
||||
if not cs.LijstSync().start(lijst_id):
|
||||
return jsonify(ok=False, fout="Er loopt al een opdracht."), 409
|
||||
log(f"Contacten: contactlijst {lijst_id} naar de mailboxen")
|
||||
return jsonify(ok=True)
|
||||
|
||||
|
||||
@contacten_bp.route('/lijst/<int:lijst_id>/verwijderen', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('gmail_contacten')
|
||||
def lijst_verwijderen(lijst_id):
|
||||
l = cs.lijst(lijst_id)
|
||||
if not l:
|
||||
return jsonify(ok=False, fout="Lijst niet gevonden."), 404
|
||||
log(f"Contacten: contactlijst {l['Naam']} verwijderd")
|
||||
if l.get('Laatste_sync'):
|
||||
if not cs.LijstSync().start(lijst_id, verwijderen=True):
|
||||
return jsonify(ok=False, fout="Er loopt al een opdracht."), 409
|
||||
return jsonify(ok=True, gestart=True)
|
||||
cs.lijst_verwijderen_db(lijst_id)
|
||||
return jsonify(ok=True, gestart=False)
|
||||
|
||||
|
||||
@contacten_bp.route('/status.json', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('gmail_contacten')
|
||||
def status():
|
||||
return jsonify(cs.LijstSync().status)
|
||||
@@ -0,0 +1,19 @@
|
||||
from flask import Blueprint, render_template, session
|
||||
from blueprints.auth import login_required
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
#---------------------------------------------
|
||||
# Functie open dahboard
|
||||
#---------------------------------------------
|
||||
@main_bp.route('/dashboard')
|
||||
@login_required
|
||||
def dashboard():
|
||||
# We halen de naam en de groep uit de sessie die in auth.py zijn opgeslagen
|
||||
user_name = session.get('user')
|
||||
user_group = session.get('security_group')
|
||||
|
||||
# We geven 'current_user' door zodat je HTML {% if current_user == 'admin' %} werkt
|
||||
return render_template('dashboard.html',
|
||||
current_user=user_name,
|
||||
security_group=user_group)
|
||||
@@ -0,0 +1,603 @@
|
||||
import sys
|
||||
from flask import Blueprint, render_template, request, session, redirect, url_for, flash, jsonify
|
||||
from database import execute_query
|
||||
from services import CIP_inleveren
|
||||
from services import CIP_uitdelen
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist
|
||||
from database import execute_query, execute_insert, get_db_connection
|
||||
from services import toestellen as tst
|
||||
from blueprints.google_admin import google_service
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
|
||||
# We noemen de blueprint 'devices'
|
||||
devices_bp = Blueprint('devices', __name__)
|
||||
|
||||
#-------------------------------------------------
|
||||
# --- ROUTE TOESTELLEN (Inventaris Toestellen) ---
|
||||
#-------------------------------------------------
|
||||
@devices_bp.route("/inventaris", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@recht_vereist('toestellen_beheren')
|
||||
def inventaris_toestellen():
|
||||
query = request.form.get("query", "") if request.method == "POST" else request.args.get("query", "")
|
||||
message = None
|
||||
|
||||
search_term = f"%{query}%"
|
||||
try:
|
||||
tst.ensure_geblokkeerd()
|
||||
geblokkeerd = "t.Geblokkeerd"
|
||||
except Exception as e: # bv. geen ALTER-rechten: pagina blijft werken
|
||||
sys.stderr.write(f"Kolom Toestellen.Geblokkeerd: {e}\n")
|
||||
geblokkeerd = "0"
|
||||
sql = f"""
|
||||
SELECT * FROM (
|
||||
SELECT t.Toestel_ID, t.Schooleigen_ID AS KAMXM_nummer, sp.Signpostnummer, t.Serienummer, t.Type,
|
||||
DATE_FORMAT(sp.Garantie, '%d-%m-%Y') AS Garantie, t.Status, {geblokkeerd} AS Geblokkeerd,
|
||||
{tst.LAATSTE_OPMERKING} AS Opmerking
|
||||
FROM Toestellen t
|
||||
LEFT JOIN Signpost_DB sp ON sp.Toestel_ID = t.Toestel_ID
|
||||
) x
|
||||
WHERE CONCAT_WS(' ', KAMXM_nummer, Signpostnummer, Serienummer, Type, Opmerking) LIKE %s
|
||||
ORDER BY KAMXM_nummer IS NULL, KAMXM_nummer
|
||||
"""
|
||||
|
||||
results = execute_query(sql, (search_term,))
|
||||
|
||||
|
||||
if query and not results:
|
||||
message = f"Geen resultaten gevonden voor '{query}'."
|
||||
elif not results:
|
||||
message = "Er zijn momenteel geen toestellen in de inventaris."
|
||||
|
||||
return render_template("toestellen.html", results=results, query=query, message=message)
|
||||
|
||||
@devices_bp.route("/google_import", methods=["POST"])
|
||||
@login_required
|
||||
@recht_vereist('toestellen_beheren')
|
||||
def google_import():
|
||||
"""Chromebooks uit Google Admin in de inventaris zetten: nieuwe serienummers toevoegen en een ontbrekend model
|
||||
aanvullen. Er wordt niets verwijderd of overschreven."""
|
||||
from config import Config
|
||||
# Leeg = alle Chromebooks van het domein; anders enkel deze OU's (met sub-OU's)
|
||||
ous = [o.strip() for o in str(Config.CHROMEBOOK_OUS or '').split(',') if o.strip()]
|
||||
try:
|
||||
toestellen = google_service.lijst_chromebooks(ous)
|
||||
except HttpError as e:
|
||||
fout = str(e)
|
||||
if 'INVALID_OU_ID' in fout or 'Invalid Input: OrgUnit' in fout or ('400' in fout and ous):
|
||||
flash(f"Een OU bestaat niet in Google: {', '.join(ous)}. Pas 'OU's van de Chromebooks' aan bij "
|
||||
"🔌 Koppelingen van deze school (volledig pad, bv. /ChromeBooks Medewerkers).", "error")
|
||||
else:
|
||||
flash(f"Chromebooks ophalen bij Google mislukt: {e}", "error")
|
||||
return redirect(url_for("devices.inventaris_toestellen"))
|
||||
except Exception as e:
|
||||
flash(f"Chromebooks ophalen bij Google mislukt: {e}", "error")
|
||||
return redirect(url_for("devices.inventaris_toestellen"))
|
||||
|
||||
try:
|
||||
tst.ensure_geblokkeerd()
|
||||
kol = "Geblokkeerd"
|
||||
except Exception:
|
||||
kol = "NULL AS Geblokkeerd"
|
||||
bestaand = {(r['Serienummer'] or '').strip().upper(): r for r in
|
||||
execute_query(f"SELECT Toestel_ID, Serienummer, Type, {kol} FROM Toestellen WHERE Serienummer IS NOT NULL")}
|
||||
nieuw = aangevuld = blok_bijgewerkt = 0
|
||||
fouten = []
|
||||
# Het veld Item-ID (Asset ID) uit Google wordt bewust NIET overgenomen: het wordt soms voor andere info gebruikt
|
||||
# (bv. een lokaal) en is dan niet uniek. Het toestelnummer van de school vul je in de inventaris of via Signpost.
|
||||
for d in toestellen:
|
||||
serie = d['serialNumber'].strip()
|
||||
model = (d.get('model') or '').strip()[:100] or None
|
||||
try:
|
||||
rij = bestaand.get(serie.upper())
|
||||
in_google = 1 if d.get('status') == 'DISABLED' else 0
|
||||
if not rij:
|
||||
nieuw_id = execute_insert("INSERT INTO Toestellen (Serienummer, Type, Status) VALUES (%s, %s, 'Beschikbaar')",
|
||||
(serie, model))
|
||||
if in_google and kol == "Geblokkeerd":
|
||||
tst.zet_geblokkeerd(nieuw_id, True)
|
||||
bestaand[serie.upper()] = {'Type': model}
|
||||
nieuw += 1
|
||||
continue
|
||||
if model and not rij['Type']:
|
||||
execute_query("UPDATE Toestellen SET Type = %s WHERE Toestel_ID = %s", (model, rij['Toestel_ID']),
|
||||
commit=True)
|
||||
aangevuld += 1
|
||||
# Blokkeerstatus overnemen uit Google (daar kan het ook rechtstreeks gewijzigd zijn)
|
||||
if rij.get('Geblokkeerd') is not None and int(rij['Geblokkeerd'] or 0) != in_google:
|
||||
tst.zet_geblokkeerd(rij['Toestel_ID'], in_google)
|
||||
blok_bijgewerkt += 1
|
||||
except Exception as e:
|
||||
fouten.append(f"{serie}: {e}")
|
||||
sys.stderr.write(f"Chromebook-import {serie}: {e}\n")
|
||||
bereik = f" uit {', '.join(ous)}" if ous else ""
|
||||
flash(f"{len(toestellen)} Chromebook(s) gevonden in Google{bereik}: {nieuw} nieuw in de inventaris, "
|
||||
f"{aangevuld} aangevuld, {len(toestellen) - nieuw - aangevuld - len(fouten)} stonden er al."
|
||||
+ (f" Blokkeerstatus bijgewerkt bij {blok_bijgewerkt} toestel(len)." if blok_bijgewerkt else ""), "success")
|
||||
if fouten:
|
||||
flash(f"{len(fouten)} toestel(len) konden niet verwerkt worden: " + "; ".join(fouten[:5])
|
||||
+ (" …" if len(fouten) > 5 else ""), "error")
|
||||
return redirect(url_for("devices.inventaris_toestellen"))
|
||||
|
||||
|
||||
@devices_bp.route("/google_info", methods=["GET"])
|
||||
@login_required
|
||||
@recht_vereist('toestellen_beheren')
|
||||
def google_info():
|
||||
"""JSON met gebruiksinfo uit Google Admin voor één toestel (voor de knop 'Google-info')."""
|
||||
try:
|
||||
info, fout = google_service.chromebook_info(request.args.get('serie', ''))
|
||||
except Exception as e:
|
||||
info, fout = None, f"Google gaf een fout: {e}"
|
||||
if fout:
|
||||
return jsonify(ok=False, fout=fout)
|
||||
# Naam van de leerling erbij als het e-mailadres in de leerlingenlijst staat
|
||||
emails = [g['email'] for g in info['gebruikers'] if g['email']]
|
||||
namen = {}
|
||||
if emails:
|
||||
try:
|
||||
# Gebruikersnaam staat er soms als volledig e-mailadres, soms enkel het deel vóór de @
|
||||
sleutels = list(dict.fromkeys([e.lower() for e in emails] + [e.split('@')[0].lower() for e in emails]))
|
||||
plek = ','.join(['%s'] * len(sleutels))
|
||||
for r in execute_query(f"SELECT Gebruikersnaam, Voornaam, Naam FROM Leerlingen WHERE Gebruikersnaam IN ({plek})",
|
||||
tuple(sleutels)):
|
||||
namen[(r['Gebruikersnaam'] or '').lower()] = f"{r.get('Voornaam') or ''} {r.get('Naam') or ''}".strip()
|
||||
except Exception:
|
||||
pass
|
||||
for g in info['gebruikers']:
|
||||
g['naam'] = namen.get(g['email'].lower()) or namen.get(g['email'].split('@')[0].lower(), '')
|
||||
return jsonify(ok=True, info=info)
|
||||
|
||||
|
||||
#-----------------------------------------------------
|
||||
# Functie achter bewerk toestel knop
|
||||
#-----------------------------------------------------
|
||||
@devices_bp.route("/bewerk_toestel", methods=["POST"])
|
||||
@login_required
|
||||
@recht_vereist('toestellen_beheren')
|
||||
def bewerk_toestel():
|
||||
"""Verwerkt de POST-gegevens om een toestelrecord bij te werken."""
|
||||
|
||||
# Gebruik de gegevens uit het formulier
|
||||
toestel_id = request.form.get("toestel_id")
|
||||
schooleigen_id = (request.form.get("schooleigen_id") or "").strip() or None
|
||||
serienummer = request.form.get("serienummer")
|
||||
signpostnummer = request.form.get("signpostnummer")
|
||||
type_toestel = request.form.get("type")
|
||||
garantie = request.form.get("garantie")
|
||||
opmerking = request.form.get("opmerking")
|
||||
query = request.form.get("query", "")
|
||||
|
||||
if not toestel_id:
|
||||
flash("Fout: Uniek nummer ontbreekt. Kan toestel niet bijwerken.", "error")
|
||||
return redirect(url_for("devices.inventaris_toestellen"))
|
||||
|
||||
try:
|
||||
tst.lees_datum(garantie) # eerst valideren, zodat er niets half bewaard wordt
|
||||
execute_query("UPDATE Toestellen SET Schooleigen_ID = %s, Serienummer = %s, Type = %s WHERE Toestel_ID = %s",
|
||||
(schooleigen_id, (serienummer or '').strip() or None, (type_toestel or '').strip() or None,
|
||||
toestel_id), commit=True)
|
||||
tst.bewaar_signpost(toestel_id, signpostnummer, garantie)
|
||||
tst.voeg_opmerking_toe(toestel_id, opmerking, session.get('user'))
|
||||
flash(f"Toestel {schooleigen_id or serienummer} succesvol bijgewerkt.", "success")
|
||||
|
||||
except ValueError as err:
|
||||
flash(str(err), "error")
|
||||
except Exception as err:
|
||||
flash(f"Databasefout bij het bewerken van het toestel: {err}", "error")
|
||||
|
||||
# BELANGRIJK: Gebruik de blueprint prefix 'devices.' in de redirect
|
||||
return redirect(url_for("devices.inventaris_toestellen", query=query))
|
||||
|
||||
|
||||
#-------------------------------------------------
|
||||
# --- ROUTE EIGENDOM (koppeling toestel-leerling) ---
|
||||
#-------------------------------------------------
|
||||
@devices_bp.route("/eigendom", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@recht_vereist('eigendom_bekijken')
|
||||
def inventaris_eigendom():
|
||||
query = (request.form.get("query") or request.args.get("query") or "").strip()
|
||||
|
||||
# Alle huidige leerlingen, met het toestel dat ze nu hebben (of zonder toestel). Vertrokken leerlingen enkel
|
||||
# als ze nog een toestel hebben. Een leerling met twee toestellen staat er twee keer in.
|
||||
# Met zoekterm: elk woord moet voorkomen in internnummer, naam, gebruikersnaam, SS-nummer, toestel of opmerkingen.
|
||||
try:
|
||||
from services.sync_service import zorg_vertrokken_kolom
|
||||
zorg_vertrokken_kolom()
|
||||
vertrokken = "l.Vertrokken_op IS NULL OR "
|
||||
except Exception:
|
||||
vertrokken = ""
|
||||
sql = f"""
|
||||
SELECT * FROM (
|
||||
SELECT e.Eigendom_ID, l.Internnummer AS internnummer, l.Voornaam, l.Naam, l.Gebruikersnaam, l.SS_nummer,
|
||||
t.Schooleigen_ID AS kamxm_nummer, t.Serienummer, e.Datum_toekenning,
|
||||
IFNULL(e.Status, 0) AS locked, (e.Eigendom_ID IS NOT NULL) AS heeft_toestel,
|
||||
(SELECT GROUP_CONCAT(o.Opmerking ORDER BY o.Timestamp SEPARATOR ' | ')
|
||||
FROM Opmerkingen o WHERE o.Toestel_ID = t.Toestel_ID) AS opmerkingen
|
||||
FROM Leerlingen l
|
||||
LEFT JOIN Eigendom e ON e.Internnummer = l.Internnummer AND e.Datum_inlevering IS NULL
|
||||
LEFT JOIN Toestellen t ON t.Toestel_ID = e.Toestel_ID
|
||||
WHERE {vertrokken}e.Eigendom_ID IS NOT NULL
|
||||
) x
|
||||
"""
|
||||
woorden = query.split()[:5]
|
||||
if woorden:
|
||||
sql += " WHERE " + " AND ".join(
|
||||
["LOWER(CONCAT_WS(' ', internnummer, Voornaam, Naam, Gebruikersnaam, SS_nummer, IFNULL(kamxm_nummer, ''), "
|
||||
"IFNULL(Serienummer, ''), IFNULL(opmerkingen, ''))) LIKE %s"] * len(woorden))
|
||||
sql += " ORDER BY heeft_toestel DESC, Datum_toekenning DESC, Naam, Voornaam, internnummer"
|
||||
results = execute_query(sql, tuple(f"%{w.lower()}%" for w in woorden))
|
||||
|
||||
return render_template("inventaris.html", results=results, query=query)
|
||||
|
||||
@devices_bp.route("/eigendom/historie/<internnummer>", methods=["GET"])
|
||||
@login_required
|
||||
@recht_vereist('eigendom_bekijken')
|
||||
def eigendom_historie(internnummer):
|
||||
"""Alle toestellen die een leerling ooit gehad heeft (ook ingeleverde), nieuwste eerst."""
|
||||
from datetime import date
|
||||
rows = execute_query("""
|
||||
SELECT e.Eigendom_ID, e.Datum_toekenning, e.Datum_inlevering, IFNULL(e.Status, 0) AS locked,
|
||||
t.Schooleigen_ID, t.Serienummer, t.Type,
|
||||
(SELECT GROUP_CONCAT(o.Opmerking ORDER BY o.Timestamp SEPARATOR ' | ')
|
||||
FROM Opmerkingen o WHERE o.Toestel_ID = t.Toestel_ID) AS opmerkingen
|
||||
FROM Eigendom e INNER JOIN Toestellen t ON t.Toestel_ID = e.Toestel_ID
|
||||
WHERE e.Internnummer = %s
|
||||
ORDER BY e.Datum_inlevering IS NOT NULL, e.Datum_toekenning DESC, e.Eigendom_ID DESC""", (internnummer,)) or []
|
||||
leerling = (execute_query("SELECT Voornaam, Naam, Gebruikersnaam FROM Leerlingen WHERE Internnummer = %s",
|
||||
(internnummer,)) or [{}])[0]
|
||||
|
||||
def d(x):
|
||||
return x.strftime('%d-%m-%Y') if hasattr(x, 'strftime') else (str(x) if x else '')
|
||||
|
||||
uit = []
|
||||
for r in rows:
|
||||
begin, einde = r['Datum_toekenning'], r['Datum_inlevering']
|
||||
dagen = None
|
||||
if hasattr(begin, 'toordinal'):
|
||||
dagen = ((einde if hasattr(einde, 'toordinal') else date.today()) - begin).days
|
||||
uit.append({'toestel': r['Schooleigen_ID'] or '', 'serienummer': r['Serienummer'] or '', 'type': r['Type'] or '',
|
||||
'van': d(begin), 'tot': d(einde), 'huidig': not einde, 'dagen': dagen,
|
||||
'geblokkeerd': int(r['locked'] or 0) == 1, 'opmerkingen': r['opmerkingen'] or ''})
|
||||
return jsonify(ok=True, internnummer=internnummer,
|
||||
naam=f"{leerling.get('Voornaam') or ''} {leerling.get('Naam') or ''}".strip(),
|
||||
gebruikersnaam=leerling.get('Gebruikersnaam') or '', toestellen=uit)
|
||||
|
||||
|
||||
@devices_bp.route("/inventaris/historie/<int:toestel_id>", methods=["GET"])
|
||||
@login_required
|
||||
@recht_vereist('toestellen_beheren', 'eigendom_bekijken')
|
||||
def toestel_historie(toestel_id):
|
||||
"""Alle leerlingen die dit toestel ooit gehad hebben, nieuwste eerst."""
|
||||
from datetime import date
|
||||
rows = execute_query("""
|
||||
SELECT e.Internnummer, e.Datum_toekenning, e.Datum_inlevering, IFNULL(e.Status, 0) AS locked,
|
||||
l.Voornaam, l.Naam, l.Gebruikersnaam
|
||||
FROM Eigendom e LEFT JOIN Leerlingen l ON l.Internnummer = e.Internnummer
|
||||
WHERE e.Toestel_ID = %s
|
||||
ORDER BY e.Datum_inlevering IS NOT NULL, e.Datum_toekenning DESC, e.Eigendom_ID DESC""", (toestel_id,)) or []
|
||||
|
||||
def d(x):
|
||||
return x.strftime('%d-%m-%Y') if hasattr(x, 'strftime') else (str(x) if x else '')
|
||||
|
||||
uit = []
|
||||
for r in rows:
|
||||
begin, einde = r['Datum_toekenning'], r['Datum_inlevering']
|
||||
dagen = ((einde if hasattr(einde, 'toordinal') else date.today()) - begin).days if hasattr(begin, 'toordinal') else None
|
||||
uit.append({'internnummer': r['Internnummer'], 'naam': f"{r['Voornaam'] or ''} {r['Naam'] or ''}".strip(),
|
||||
'gebruikersnaam': r['Gebruikersnaam'] or '', 'van': d(begin), 'tot': d(einde), 'huidig': not einde,
|
||||
'dagen': dagen, 'geblokkeerd': int(r['locked'] or 0) == 1})
|
||||
return jsonify(ok=True, eigenaars=uit)
|
||||
|
||||
|
||||
#-------------------------------------------------
|
||||
# --- ROUTE UITDELEN ---
|
||||
#-------------------------------------------------
|
||||
@devices_bp.route('/uitdelen', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('toestellen_uitdelen')
|
||||
def uitdelen_page():
|
||||
return render_template('uitdelen.html', message=None)
|
||||
|
||||
@devices_bp.route('/zoek_leerlingen', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('toestellen_uitdelen', 'toestellen_beheren')
|
||||
def zoek_leerlingen():
|
||||
"""Suggesties voor het veld 'Internnummer' (max. 15): op internnummer, naam, voornaam of gebruikersnaam.
|
||||
Geeft ook het toestel dat de leerling nu heeft."""
|
||||
q = (request.args.get('q') or '').strip()
|
||||
if not q:
|
||||
return jsonify([])
|
||||
woorden = q.split()[:4]
|
||||
# Elk woord moet voorkomen in internnummer, gebruikersnaam of naam (in eender welke volgorde)
|
||||
# LOWER(): zoeken zonder onderscheid tussen hoofd- en kleine letters, ongeacht de collation van CONCAT_WS
|
||||
voorwaarden = " AND ".join(["LOWER(CONCAT_WS(' ', l.Internnummer, l.SS_nummer, l.Gebruikersnaam, l.Voornaam, l.Naam)) LIKE %s"] * len(woorden))
|
||||
rows = execute_query(
|
||||
"SELECT l.Internnummer, l.Voornaam, l.Naam, l.Gebruikersnaam, "
|
||||
" GROUP_CONCAT(COALESCE(t.Schooleigen_ID, t.Serienummer) SEPARATOR ', ') AS Toestellen "
|
||||
"FROM Leerlingen l "
|
||||
"LEFT JOIN Eigendom e ON e.Internnummer = l.Internnummer AND e.Datum_inlevering IS NULL "
|
||||
"LEFT JOIN Toestellen t ON t.Toestel_ID = e.Toestel_ID "
|
||||
f"WHERE {voorwaarden} "
|
||||
"GROUP BY l.Internnummer, l.Voornaam, l.Naam, l.Gebruikersnaam "
|
||||
"ORDER BY (l.Internnummer LIKE %s) DESC, l.Naam, l.Voornaam LIMIT 15",
|
||||
tuple(f"%{w.lower()}%" for w in woorden) + (f"{q}%",))
|
||||
return jsonify([{'intern': r['Internnummer'], 'naam': f"{r['Voornaam'] or ''} {r['Naam'] or ''}".strip(),
|
||||
'gebruiker': r['Gebruikersnaam'] or '', 'toestellen': r['Toestellen'] or ''} for r in rows])
|
||||
|
||||
|
||||
@devices_bp.route('/zoek_toestellen', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('toestellen_uitdelen', 'toestellen_beheren')
|
||||
def zoek_toestellen():
|
||||
"""Suggesties voor het zoekveld 'Toestelnummer' (max. 15): op toestelnummer of serienummer."""
|
||||
q = (request.args.get('q') or '').strip()
|
||||
if len(q) < 1:
|
||||
return jsonify([])
|
||||
# Uitdelen: beschikbare toestellen eerst; inleveren: uitgeleende toestellen eerst
|
||||
voorkeur = {'uitgeleend': "e.Internnummer IS NULL, ", 'beschikbaar': "e.Internnummer IS NOT NULL, "}.get(
|
||||
request.args.get('voorkeur'), "")
|
||||
rows = execute_query(
|
||||
"SELECT t.Schooleigen_ID, t.Serienummer, t.Type, t.Status, e.Internnummer "
|
||||
"FROM Toestellen t LEFT JOIN Eigendom e ON e.Toestel_ID = t.Toestel_ID AND e.Datum_inlevering IS NULL "
|
||||
"WHERE t.Schooleigen_ID LIKE %s OR t.Serienummer LIKE %s "
|
||||
f"ORDER BY {voorkeur}t.Schooleigen_ID IS NULL, (t.Schooleigen_ID LIKE %s) DESC, t.Schooleigen_ID, t.Serienummer "
|
||||
"LIMIT 15",
|
||||
(f"%{q}%", f"%{q}%", f"{q}%"))
|
||||
return jsonify([{'nummer': r['Schooleigen_ID'] or '', 'serie': r['Serienummer'] or '', 'type': r['Type'] or '',
|
||||
'status': r['Status'] or '', 'bij': r['Internnummer'] or ''} for r in rows])
|
||||
|
||||
# Functie van knop Ingeven (Uitdelen)
|
||||
@devices_bp.route('/uitdelen_submit', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('toestellen_uitdelen')
|
||||
def uitdelen_submit():
|
||||
internnummer = request.form.get('internnummer')
|
||||
#TODO--------------------------------------------
|
||||
try:
|
||||
internnummer= int(internnummer)
|
||||
|
||||
except:
|
||||
internnummer = str(internnummer)
|
||||
#--------------------------------------------
|
||||
serienummer = request.form.get('serienummer')
|
||||
action_type = request.form.get('action')
|
||||
|
||||
success = CIP_uitdelen.uitdelen(internnummer, serienummer)
|
||||
|
||||
if success:
|
||||
flash(f"Toestel met nummer {serienummer} succesvol uitgedeeld!", "success")
|
||||
else:
|
||||
# Als het mislukt, tonen we de foutmelding die in de service is gezet
|
||||
flash(CIP_uitdelen.foutmelding, "error")
|
||||
|
||||
return redirect(url_for('devices.uitdelen_page'))
|
||||
|
||||
#-------------------------------------------------
|
||||
# --- ROUTE INLEVEREN ---
|
||||
#-------------------------------------------------
|
||||
@devices_bp.route('/inleveren', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('toestellen_uitdelen')
|
||||
def inleveren_page():
|
||||
return render_template('inleveren.html', message=None)
|
||||
|
||||
# Functie van knop Ingeven (Inleveren)
|
||||
@devices_bp.route('/inleveren_submit', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('toestellen_uitdelen')
|
||||
def inleveren_submit():
|
||||
internnummer = request.form.get('internnummer')
|
||||
serienummer = request.form.get('serienummer')
|
||||
tab2_schade_text = request.form.get('schade_type', "")
|
||||
action_type = request.form.get('action')
|
||||
try:
|
||||
internnummer= int(internnummer)
|
||||
|
||||
except:
|
||||
internnummer = str(internnummer)
|
||||
if action_type:
|
||||
CIP_inleveren.Inleveren(internnummer, serienummer, action_type, tab2_schade_text)
|
||||
|
||||
return render_template('inleveren.html', message=CIP_inleveren.foutmelding)
|
||||
|
||||
#---------------------------------
|
||||
# --- STATUS UPDATES ---
|
||||
#---------------------------------
|
||||
@devices_bp.route("/update_toestel_status", methods=["POST"])
|
||||
@login_required
|
||||
@recht_vereist('toestellen_beheren')
|
||||
def wijzig_status():
|
||||
toestel_id = request.form.get("toestel_id")
|
||||
new_status = request.form.get("new_status")
|
||||
query = request.form.get("query", "")
|
||||
|
||||
if toestel_id and new_status in ["Beschikbaar", "Uitgeleend", "Defect"]:
|
||||
execute_query("UPDATE Toestellen SET Status = %s WHERE Toestel_ID = %s", (new_status, toestel_id), commit=True)
|
||||
rij = execute_query("SELECT Schooleigen_ID, Serienummer FROM Toestellen WHERE Toestel_ID = %s", (toestel_id,))
|
||||
label = (rij[0]['Schooleigen_ID'] or rij[0]['Serienummer']) if rij else toestel_id
|
||||
flash(f"Status van {label} gewijzigd naar {new_status}", "success")
|
||||
|
||||
return redirect(url_for("devices.inventaris_toestellen", query=query))
|
||||
|
||||
#-------------------------------------------------
|
||||
# --- ROUTE BLOKKEREN / DEBLOKKEREN ---
|
||||
#-------------------------------------------------
|
||||
@devices_bp.route('/eigendom/bewerken', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('toestellen_uitdelen')
|
||||
def eigendom_bewerken():
|
||||
"""Een lopende toekenning aanpassen: andere leerling en/of ander toestel."""
|
||||
query = request.form.get('query', '')
|
||||
terug = redirect(url_for('devices.inventaris_eigendom', query=query or None))
|
||||
eigendom_id = request.form.get('eigendom_id', type=int)
|
||||
rows = execute_query("SELECT * FROM Eigendom WHERE Eigendom_ID = %s AND Datum_inlevering IS NULL", (eigendom_id,))
|
||||
if not rows:
|
||||
flash("Deze toekenning bestaat niet (meer) of is al ingeleverd.", "error")
|
||||
return terug
|
||||
oud = rows[0]
|
||||
|
||||
leerling = tst.zoek_leerling((request.form.get('internnummer') or '').strip())
|
||||
if not leerling:
|
||||
flash("Internnummer niet gevonden in de leerlingenlijst.", "error")
|
||||
return terug
|
||||
toestel = tst.zoek_toestel((request.form.get('toestelnummer') or '').strip())
|
||||
if not toestel:
|
||||
flash("Toestelnummer (of serienummer) niet gevonden in de toestellenlijst.", "error")
|
||||
return terug
|
||||
|
||||
nieuw_toestel = toestel['Toestel_ID'] != oud['Toestel_ID']
|
||||
if nieuw_toestel:
|
||||
bezet = execute_query("SELECT Internnummer FROM Eigendom WHERE Toestel_ID = %s AND Datum_inlevering IS NULL "
|
||||
"AND Eigendom_ID <> %s", (toestel['Toestel_ID'], eigendom_id))
|
||||
if bezet:
|
||||
flash(f"Toestel {toestel.get('Schooleigen_ID') or toestel.get('Serienummer')} is al uitgeleend aan "
|
||||
f"{bezet[0]['Internnummer']}. Lever het eerst in.", "error")
|
||||
return terug
|
||||
if not nieuw_toestel and str(leerling['Internnummer']) == str(oud['Internnummer']):
|
||||
flash("Er is niets gewijzigd.", "info")
|
||||
return terug
|
||||
|
||||
execute_query("UPDATE Eigendom SET Internnummer = %s, Toestel_ID = %s WHERE Eigendom_ID = %s",
|
||||
(leerling['Internnummer'], toestel['Toestel_ID'], eigendom_id), commit=True)
|
||||
if nieuw_toestel:
|
||||
# Het vorige toestel is weer vrij, het nieuwe staat op uitgeleend
|
||||
execute_query("UPDATE Toestellen SET Status = 'Beschikbaar' WHERE Toestel_ID = %s AND Status = 'Uitgeleend'",
|
||||
(oud['Toestel_ID'],), commit=True)
|
||||
execute_query("UPDATE Toestellen SET Status = 'Uitgeleend' WHERE Toestel_ID = %s", (toestel['Toestel_ID'],),
|
||||
commit=True)
|
||||
naam = f"{leerling.get('Voornaam') or ''} {leerling.get('Naam') or ''}".strip() or leerling['Internnummer']
|
||||
flash(f"Toekenning aangepast: {toestel.get('Schooleigen_ID') or toestel.get('Serienummer')} → {naam} "
|
||||
f"({leerling['Internnummer']}).", "success")
|
||||
return terug
|
||||
|
||||
|
||||
@devices_bp.route('/toggle_lock', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('toestel_blokkeren')
|
||||
def toggle_lock():
|
||||
# Haal de gegevens op uit het formulier en strip ze direct
|
||||
internnummer = request.form.get('internnummer', '').strip()
|
||||
query = request.form.get('query', '')
|
||||
|
||||
# 1. Haal de huidige leerling/toestel koppeling op
|
||||
rows = execute_query(
|
||||
"SELECT e.Eigendom_ID, e.Status, e.Toestel_ID, t.Schooleigen_ID, t.Serienummer FROM Eigendom e "
|
||||
"JOIN Toestellen t ON t.Toestel_ID = e.Toestel_ID "
|
||||
"WHERE e.Internnummer = %s AND e.Datum_inlevering IS NULL ORDER BY e.Datum_toekenning DESC",
|
||||
(internnummer,))
|
||||
|
||||
if not rows:
|
||||
flash("Geen gegevens gevonden in de Eigendom tabel.", "error")
|
||||
return redirect(url_for('devices.inventaris_eigendom', query=query))
|
||||
|
||||
current_status = int(rows[0]['Status']) if rows[0]['Status'] is not None else 0
|
||||
eigendom_id = rows[0]['Eigendom_ID']
|
||||
kamxm_nummer = str(rows[0]['Schooleigen_ID'] or '').strip()
|
||||
|
||||
# 2. Serienummer van het toestel
|
||||
serienummer = str(rows[0]['Serienummer']).strip() if rows[0]['Serienummer'] else None
|
||||
|
||||
if not serienummer:
|
||||
flash(f"Kan geen serienummer vinden voor toestel {kamxm_nummer}.", "error")
|
||||
return redirect(url_for('devices.inventaris_eigendom', query=query))
|
||||
|
||||
# 3. Vraag het Device ID op bij Google met een 'gecleand' serienummer
|
||||
# We gebruiken strip() om de \t (tab) en spaties te verwijderen
|
||||
clean_serial = serienummer.strip()
|
||||
device_id, error = google_service.get_device_id_by_serial(clean_serial)
|
||||
|
||||
if device_id:
|
||||
# Als status 0 is (unlocked) -> moet hij naar disable (True)
|
||||
should_lock = (current_status == 0)
|
||||
success, api_message = google_service.toggle_device_status(device_id, lock=should_lock)
|
||||
|
||||
if success:
|
||||
new_status = 1 if should_lock else 0
|
||||
execute_query("UPDATE Eigendom SET Status=%s WHERE Eigendom_ID=%s", (new_status, eigendom_id), commit=True)
|
||||
try:
|
||||
tst.zet_geblokkeerd(rows[0]['Toestel_ID'], should_lock)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Toestellen.Geblokkeerd bijwerken: {e}\n")
|
||||
flash(api_message, "success")
|
||||
else:
|
||||
flash(f"Google API Fout: {api_message}", "error")
|
||||
else:
|
||||
# Hier wordt de 'Invalid Input' nu voorkomen of getoond als het serienummer echt fout is
|
||||
flash(f"Google Zoekfout: {error}", "error")
|
||||
|
||||
return redirect(url_for('devices.inventaris_eigendom', query=query))
|
||||
|
||||
@devices_bp.route('/inventaris/blokkeren', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('toestel_blokkeren')
|
||||
def toestel_blokkeren():
|
||||
"""Blokkeert of deblokkeert één Chromebook in Google (knop in Alle toestellen).
|
||||
actie = 'blokkeren' of 'deblokkeren'. Met fetch (X-Requested-With) komt er JSON terug, anders een redirect."""
|
||||
from rechten import log
|
||||
ajax = request.headers.get('X-Requested-With') == 'fetch'
|
||||
query = request.form.get('query', '')
|
||||
actie = request.form.get('actie', '')
|
||||
|
||||
def klaar(ok, melding, geblokkeerd=None):
|
||||
if ajax:
|
||||
return jsonify(ok=ok, melding=melding, geblokkeerd=geblokkeerd)
|
||||
flash(melding, "success" if ok else "error")
|
||||
return redirect(url_for('devices.inventaris_toestellen', query=query))
|
||||
|
||||
if actie not in ('blokkeren', 'deblokkeren'):
|
||||
return klaar(False, "Onbekende actie.")
|
||||
blokkeren = actie == 'blokkeren'
|
||||
try:
|
||||
toestel_id = int(request.form.get('toestel_id', ''))
|
||||
except ValueError:
|
||||
return klaar(False, "Onbekend toestel.")
|
||||
rows = execute_query("SELECT Toestel_ID, Schooleigen_ID, Serienummer FROM Toestellen WHERE Toestel_ID = %s",
|
||||
(toestel_id,))
|
||||
if not rows:
|
||||
return klaar(False, "Toestel niet gevonden.")
|
||||
t = rows[0]
|
||||
label = t['Schooleigen_ID'] or t['Serienummer'] or str(toestel_id)
|
||||
if not (t['Serienummer'] or '').strip():
|
||||
return klaar(False, f"Toestel {label} heeft geen serienummer; vul het eerst in via ✏️ Bewerken.")
|
||||
try:
|
||||
toestel, fout = google_service.zoek_chromebook(t['Serienummer'])
|
||||
except Exception as e:
|
||||
toestel, fout = None, f"Google gaf een fout: {e}"
|
||||
if not toestel:
|
||||
return klaar(False, fout)
|
||||
status = toestel['status']
|
||||
if status == 'DEPROVISIONED':
|
||||
return klaar(False, f"Toestel {label} is uitgeschreven in Google Admin en kan niet (ge)blokkeerd worden.")
|
||||
gewenst = 'DISABLED' if blokkeren else 'ACTIVE'
|
||||
if status != gewenst:
|
||||
ok, melding = google_service.toggle_device_status(toestel['id'], lock=blokkeren)
|
||||
if not ok:
|
||||
return klaar(False, melding)
|
||||
try:
|
||||
tst.zet_geblokkeerd(toestel_id, blokkeren)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Toestellen.Geblokkeerd bijwerken: {e}\n")
|
||||
log(f"Chromebook {label} {'geblokkeerd' if blokkeren else 'gedeblokkeerd'}")
|
||||
if status == gewenst:
|
||||
return klaar(True, f"Toestel {label} was in Google al {'geblokkeerd' if blokkeren else 'actief'}.", blokkeren)
|
||||
return klaar(True, f"Toestel {label} is {'geblokkeerd' if blokkeren else 'gedeblokkeerd'}.", blokkeren)
|
||||
|
||||
|
||||
#-------------------------------------------------
|
||||
# --- HELPER gebruikt in Toggle lock functie ---
|
||||
#-------------------------------------------------
|
||||
def get_serienummer_from_kamxm(kamxm_nummer):
|
||||
if not kamxm_nummer:
|
||||
return None
|
||||
result = execute_query("SELECT Serienummer FROM Toestellen WHERE Schooleigen_ID=%s", (kamxm_nummer,))
|
||||
if result and result[0]['Serienummer']:
|
||||
# Alvast een basis strip()
|
||||
return str(result[0]['Serienummer']).strip()
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Leerlingfacturatie: facturen en creditnota's van de leerlingen van de actieve school (Informat Rekeningen API)."""
|
||||
import csv
|
||||
import io
|
||||
import time
|
||||
from datetime import date
|
||||
|
||||
from flask import Blueprint, render_template, request, Response, redirect, url_for, flash
|
||||
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist
|
||||
from services.facturatie_service import FacturatieService, FacturatieFout, schooljaren
|
||||
from tenancy import huidige_school
|
||||
|
||||
facturatie_bp = Blueprint('facturatie', __name__)
|
||||
|
||||
STATUS_LABELS = {'open': 'Open', 'gedeeltelijk': 'Gedeeltelijk betaald', 'betaald': 'Betaald', 'credit': 'Creditnota'}
|
||||
|
||||
|
||||
def _schooljaar():
|
||||
jaren = schooljaren()
|
||||
gekozen = request.args.get('schooljaar') or ''
|
||||
return gekozen if gekozen in jaren else jaren[0], jaren
|
||||
|
||||
|
||||
@facturatie_bp.route("/", methods=["GET"])
|
||||
@login_required
|
||||
@recht_vereist('leerlingfacturatie')
|
||||
def overzicht():
|
||||
school = huidige_school()
|
||||
schooljaar, jaren = _schooljaar()
|
||||
rijen, groepen, fout, opgehaald = [], [], None, None
|
||||
if not school.heeft('INFORMAT_ID', 'INFORMAT_SECRET'):
|
||||
fout = "Informat is nog niet ingesteld voor deze school (🔌 Koppelingen › Informat)."
|
||||
else:
|
||||
try:
|
||||
rijen, groepen, t = FacturatieService().facturen(schooljaar, vernieuw=bool(request.args.get('vernieuw')))
|
||||
opgehaald = time.strftime('%d-%m-%Y %H:%M', time.localtime(t))
|
||||
except FacturatieFout as e:
|
||||
fout = str(e)
|
||||
except Exception as e:
|
||||
fout = f"Facturen ophalen mislukt: {e}"
|
||||
return render_template("facturatie.html", rijen=rijen, groepen=groepen, fout=fout, opgehaald=opgehaald,
|
||||
schooljaar=schooljaar, jaren=jaren, school_naam=school.naam, labels=STATUS_LABELS,
|
||||
today=date.today())
|
||||
|
||||
|
||||
@facturatie_bp.route("/pdf/<toegangsgroep>/<invoice_id>", methods=["GET"])
|
||||
@login_required
|
||||
@recht_vereist('leerlingfacturatie')
|
||||
def pdf(toegangsgroep, invoice_id):
|
||||
try:
|
||||
inhoud = FacturatieService().pdf(toegangsgroep, invoice_id)
|
||||
except FacturatieFout as e:
|
||||
flash(str(e), "error")
|
||||
return redirect(url_for('facturatie.overzicht', schooljaar=request.args.get('schooljaar')))
|
||||
except Exception as e:
|
||||
flash(f"De PDF kon niet opgehaald worden: {e}", "error")
|
||||
return redirect(url_for('facturatie.overzicht', schooljaar=request.args.get('schooljaar')))
|
||||
naam = (request.args.get('naam') or 'factuur').replace('"', '')
|
||||
return Response(inhoud, mimetype='application/pdf',
|
||||
headers={'Content-Disposition': f'inline; filename="{naam}.pdf"'})
|
||||
|
||||
|
||||
@facturatie_bp.route("/export.csv", methods=["GET"])
|
||||
@login_required
|
||||
@recht_vereist('leerlingfacturatie')
|
||||
def export():
|
||||
"""CSV met dezelfde filters als de tabel: ?schooljaar=&groep=&status=&zoek="""
|
||||
schooljaar, _ = _schooljaar()
|
||||
try:
|
||||
rijen, _, _ = FacturatieService().facturen(schooljaar)
|
||||
except Exception as e:
|
||||
flash(str(e), "error")
|
||||
return redirect(url_for('facturatie.overzicht', schooljaar=schooljaar))
|
||||
groep, status = request.args.get('groep') or '', request.args.get('status') or ''
|
||||
zoek = (request.args.get('zoek') or '').strip().lower()
|
||||
if groep:
|
||||
rijen = [r for r in rijen if str(r['groep_id']) == groep]
|
||||
if status == 'onbetaald':
|
||||
rijen = [r for r in rijen if r['status'] in ('open', 'gedeeltelijk')]
|
||||
elif status:
|
||||
rijen = [r for r in rijen if r['status'] == status]
|
||||
if zoek:
|
||||
rijen = [r for r in rijen if zoek in ' '.join(str(r[k]) for k in ('naam', 'voornaam', 'stamnummer', 'klas', 'groep', 'ogm')).lower()]
|
||||
bedrag = lambda x: f"{x:.2f}".replace('.', ',')
|
||||
datum = lambda d: d.strftime('%d/%m/%Y') if d else ''
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf, delimiter=';')
|
||||
w.writerow(['Naam', 'Voornaam', 'Stamnummer', 'Klas', 'Factuurgroep', 'Soort', 'Datum', 'Vervaldag', 'Totaal', 'Betaald',
|
||||
'Open', 'Status', 'Gestructureerde mededeling'])
|
||||
for r in rijen:
|
||||
w.writerow([r['naam'], r['voornaam'], r['stamnummer'], r['klas'], r['groep'], 'Creditnota' if r['type'] == 'CREDIT_NOTE' else 'Factuur',
|
||||
datum(r['datum']), datum(r['vervaldag']), bedrag(r['totaal'] * r['teken']), bedrag(r['betaald'] * r['teken']), bedrag(r['open'] * r['teken']),
|
||||
STATUS_LABELS.get(r['status'], r['status']), r['ogm']])
|
||||
return Response('' + buf.getvalue(), mimetype='text/csv; charset=utf-8', headers={
|
||||
'Content-Disposition': f"attachment; filename=Facturen_{huidige_school().code}_{schooljaar}_{date.today():%Y%m%d}.csv"})
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Google › Gedeelde drives: overzicht, leden en rechten beheren."""
|
||||
import time
|
||||
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist, log
|
||||
from services import gedeelde_drives_service as gd
|
||||
|
||||
gedeelde_drives_bp = Blueprint('gedeelde_drives', __name__)
|
||||
|
||||
|
||||
@gedeelde_drives_bp.route('/', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('gedeelde_drives')
|
||||
def overzicht():
|
||||
drives, fout, opgehaald, gekozen, leden = [], None, None, None, []
|
||||
did = request.args.get('drive') or ''
|
||||
vervang, lid, lid_van = None, (request.args.get('lid') or '').strip().lower(), None
|
||||
try:
|
||||
if lid:
|
||||
lid_van = gd.drives_van(lid)
|
||||
elif request.args.get('oud'):
|
||||
oud, nieuw = request.args.get('oud').strip().lower(), (request.args.get('nieuw') or '').strip().lower()
|
||||
vervang = {'oud': oud, 'nieuw': nieuw, 'drives': gd.drives_van(oud)}
|
||||
elif did:
|
||||
gekozen, leden = gd.drive(did)
|
||||
else:
|
||||
drives, t = gd.overzicht(vernieuw=bool(request.args.get('vernieuw')))
|
||||
opgehaald = time.strftime('%d-%m-%Y %H:%M', time.localtime(t))
|
||||
if request.args.get('vernieuw'):
|
||||
return redirect(url_for('gedeelde_drives.overzicht'))
|
||||
except gd.DriveFout as e:
|
||||
fout = str(e)
|
||||
except Exception as e:
|
||||
fout = gd.vriendelijk(e)
|
||||
return render_template('gedeelde_drives.html', drives=drives, fout=fout, opgehaald=opgehaald, gekozen=gekozen,
|
||||
leden=leden, rollen=gd.ROLLEN, vervang=vervang, resultaat=None, lid=lid, lid_van=lid_van)
|
||||
|
||||
|
||||
@gedeelde_drives_bp.route('/vervangen', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('gedeelde_drives')
|
||||
def vervangen():
|
||||
oud, nieuw = request.form.get('oud'), request.form.get('nieuw')
|
||||
try:
|
||||
res = gd.vervang(oud, nieuw, request.form.getlist('drive'), request.form.get('verwijderen') == '1',
|
||||
request.form.get('melding') == '1')
|
||||
except gd.DriveFout as e:
|
||||
flash(str(e), "error")
|
||||
return redirect(url_for('gedeelde_drives.overzicht', oud=oud, nieuw=nieuw))
|
||||
ok = sum(1 for r in res if r['ok'])
|
||||
log(f"Gedeelde drives: {oud} vervangen door {nieuw} op {ok} drive(s)")
|
||||
return render_template('gedeelde_drives.html', drives=[], fout=None, opgehaald=None, gekozen=None, leden=[],
|
||||
rollen=gd.ROLLEN, vervang={'oud': oud, 'nieuw': nieuw, 'drives': []}, resultaat=res,
|
||||
lid='', lid_van=None)
|
||||
|
||||
|
||||
def _terug():
|
||||
return redirect(url_for('gedeelde_drives.overzicht', drive=request.form.get('drive')))
|
||||
|
||||
|
||||
@gedeelde_drives_bp.route('/lid/toevoegen', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('gedeelde_drives')
|
||||
def lid_toevoegen():
|
||||
did, email, rol = request.form.get('drive'), request.form.get('email'), request.form.get('rol')
|
||||
try:
|
||||
gd.lid_toevoegen(did, email, rol, request.form.get('melding') == '1')
|
||||
log(f"Gedeelde drive {request.form.get('naam')}: {email} toegevoegd als {gd.ROL_NAAM.get(rol)}")
|
||||
flash(f"{email} toegevoegd als {gd.ROL_NAAM.get(rol)}.", "success")
|
||||
except gd.DriveFout as e:
|
||||
flash(str(e), "error")
|
||||
return _terug()
|
||||
|
||||
|
||||
@gedeelde_drives_bp.route('/lid/wijzigen', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('gedeelde_drives')
|
||||
def lid_wijzigen():
|
||||
rol = request.form.get('rol')
|
||||
try:
|
||||
gd.lid_wijzigen(request.form.get('drive'), request.form.get('perm'), rol)
|
||||
log(f"Gedeelde drive {request.form.get('naam')}: {request.form.get('email')} → {gd.ROL_NAAM.get(rol)}")
|
||||
flash(f"{request.form.get('email')} is nu {gd.ROL_NAAM.get(rol)}.", "success")
|
||||
except gd.DriveFout as e:
|
||||
flash(str(e), "error")
|
||||
return _terug()
|
||||
|
||||
|
||||
@gedeelde_drives_bp.route('/lid/verwijderen', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('gedeelde_drives')
|
||||
def lid_verwijderen():
|
||||
try:
|
||||
gd.lid_verwijderen(request.form.get('drive'), request.form.get('perm'))
|
||||
log(f"Gedeelde drive {request.form.get('naam')}: {request.form.get('email')} verwijderd")
|
||||
flash(f"{request.form.get('email')} verwijderd uit de drive.", "success")
|
||||
except gd.DriveFout as e:
|
||||
flash(str(e), "error")
|
||||
return _terug()
|
||||
|
||||
|
||||
@gedeelde_drives_bp.route('/nieuw', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('gedeelde_drives')
|
||||
def nieuw():
|
||||
naam = request.form.get('naam')
|
||||
try:
|
||||
did = gd.drive_maken(naam, (request.form.get('beheerder') or '').strip() or None)
|
||||
log(f"Gedeelde drive {naam} aangemaakt")
|
||||
flash(f"Gedeelde drive '{naam}' aangemaakt.", "success")
|
||||
return redirect(url_for('gedeelde_drives.overzicht', drive=did))
|
||||
except gd.DriveFout as e:
|
||||
flash(str(e), "error")
|
||||
return redirect(url_for('gedeelde_drives.overzicht'))
|
||||
@@ -0,0 +1,156 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
|
||||
from tenancy import huidige_school, per_school
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist
|
||||
from services.gmail_delegation_service import GmailDelegationService, DelegationError, MAX_DELEGATES
|
||||
from services.gmail_delegatie_overzicht import DelegatieOverzicht
|
||||
|
||||
gmail_delegatie_bp = Blueprint('gmail_delegatie', __name__)
|
||||
delegation_service = per_school(GmailDelegationService)
|
||||
|
||||
|
||||
_table_ready = set() # scholen waarvoor de tabellen al gecontroleerd zijn
|
||||
|
||||
|
||||
def _ensure_table():
|
||||
if huidige_school().code not in _table_ready:
|
||||
try:
|
||||
delegation_service.ensure_table()
|
||||
_table_ready.add(huidige_school().code)
|
||||
except Exception as e:
|
||||
flash(f"Kon tabel gmail_delegatie_log niet aanmaken: {e}", "error")
|
||||
|
||||
|
||||
def _safe_log(actie, mailbox, gedelegeerde):
|
||||
try:
|
||||
delegation_service.log(session.get('user'), actie, mailbox, gedelegeerde)
|
||||
except Exception as e:
|
||||
flash(f"Wijziging is uitgevoerd, maar kon niet gelogd worden: {e}", "warning")
|
||||
|
||||
|
||||
#---------------------------------------------
|
||||
# Overzicht: mailbox zoeken en gedelegeerden tonen
|
||||
#---------------------------------------------
|
||||
@gmail_delegatie_bp.route('/', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('gmail_delegatie')
|
||||
def overzicht():
|
||||
_ensure_table()
|
||||
zoek = (request.args.get('zoek') or '').strip()
|
||||
mailbox_param = (request.args.get('mailbox') or '').strip()
|
||||
|
||||
zoekresultaten, mailbox, delegates, fout = [], None, [], None
|
||||
|
||||
# Zoekterm zonder @ = zoeken op naam; met @ = direct die mailbox openen
|
||||
if zoek and '@' not in zoek:
|
||||
zoekresultaten = delegation_service.search_users(zoek)
|
||||
if not zoekresultaten:
|
||||
fout = f"Geen gebruikers gevonden voor '{zoek}'."
|
||||
elif zoek and not mailbox_param:
|
||||
mailbox_param = zoek
|
||||
|
||||
if mailbox_param:
|
||||
try:
|
||||
mailbox = delegation_service.resolve_user(mailbox_param)
|
||||
delegates = delegation_service.list_delegates(mailbox['email'])
|
||||
DelegatieOverzicht().bewaar_mailbox(mailbox['email'], mailbox['naam'], delegates) # overzicht actueel houden
|
||||
except DelegationError as e:
|
||||
fout = str(e)
|
||||
|
||||
try:
|
||||
geschiedenis = delegation_service.history(mailbox['email'] if mailbox else None)
|
||||
except Exception:
|
||||
geschiedenis = []
|
||||
|
||||
# Alle lopende machtigingen (achtergrondtaak, vanzelf vernieuwd als ze ouder zijn dan een dag)
|
||||
ov = DelegatieOverzicht()
|
||||
machtigingen, laatst, ov_fout = [], None, None
|
||||
try:
|
||||
ov.ensure_table()
|
||||
if ov.verouderd():
|
||||
ov.sync_op_achtergrond()
|
||||
machtigingen, laatst = ov.alle(), ov.laatst_opgehaald()
|
||||
except Exception as e:
|
||||
ov_fout = f"Het overzicht kon niet gelezen worden: {e}"
|
||||
|
||||
return render_template('gmail_delegatie.html', zoek=zoek, zoekresultaten=zoekresultaten,
|
||||
mailbox=mailbox, delegates=delegates, fout=fout,
|
||||
geschiedenis=geschiedenis, max_delegates=MAX_DELEGATES,
|
||||
machtigingen=machtigingen, laatst=laatst, ov_fout=ov_fout, ov_status=ov.status)
|
||||
|
||||
|
||||
@gmail_delegatie_bp.route('/overzicht/vernieuwen', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('gmail_delegatie')
|
||||
def overzicht_vernieuwen():
|
||||
if DelegatieOverzicht().sync_op_achtergrond():
|
||||
flash("Alle mailboxen worden nagekeken. Dit kan enkele minuten duren; de tabel vernieuwt vanzelf.", "info")
|
||||
else:
|
||||
flash("Er loopt al een ophaling.", "warning")
|
||||
return redirect(url_for('gmail_delegatie.overzicht'))
|
||||
|
||||
|
||||
@gmail_delegatie_bp.route('/overzicht/status.json', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('gmail_delegatie')
|
||||
def overzicht_status():
|
||||
s = DelegatieOverzicht().status
|
||||
return jsonify(bezig=s['bezig'], voortgang=s['voortgang'], totaal=s['totaal'], fout=s['fout'], resultaat=s['resultaat'])
|
||||
|
||||
|
||||
def _ververs_mailbox(mailbox):
|
||||
"""Na een wijziging: de rijen van deze mailbox in het overzicht opnieuw opvragen."""
|
||||
try:
|
||||
m = delegation_service.resolve_user(mailbox)
|
||||
DelegatieOverzicht().bewaar_mailbox(m['email'], m['naam'], delegation_service.list_delegates(m['email']))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
#---------------------------------------------
|
||||
# Gedelegeerde toevoegen
|
||||
#---------------------------------------------
|
||||
@gmail_delegatie_bp.route('/toevoegen', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('gmail_delegatie')
|
||||
def toevoegen():
|
||||
_ensure_table()
|
||||
mailbox_in = request.form.get('mailbox', '')
|
||||
delegate_in = request.form.get('delegate', '')
|
||||
try:
|
||||
mailbox = delegation_service.resolve_user(mailbox_in)
|
||||
delegate = delegation_service.resolve_user(delegate_in)
|
||||
if delegate['geschorst']:
|
||||
raise DelegationError(f"{delegate['email']} is geschorst en kan geen toegang krijgen.")
|
||||
if len(delegation_service.list_delegates(mailbox['email'])) >= MAX_DELEGATES:
|
||||
raise DelegationError(f"{mailbox['email']} heeft al het maximum van {MAX_DELEGATES} gedelegeerden.")
|
||||
delegation_service.add_delegate(mailbox['email'], delegate['email'])
|
||||
_safe_log('toegevoegd', mailbox['email'], delegate['email'])
|
||||
_ververs_mailbox(mailbox['email'])
|
||||
extra = f" (alias {delegate_in.strip()} omgezet naar hoofdadres)" if delegate['is_alias'] else ""
|
||||
flash(f"{delegate['email']} heeft nu toegang tot {mailbox['email']}{extra}. "
|
||||
"Het kan tot een minuut duren voor de mailbox zichtbaar is.", "success")
|
||||
return redirect(url_for('gmail_delegatie.overzicht', mailbox=mailbox['email']))
|
||||
except DelegationError as e:
|
||||
flash(str(e), "error")
|
||||
return redirect(url_for('gmail_delegatie.overzicht', mailbox=mailbox_in.strip()))
|
||||
|
||||
|
||||
#---------------------------------------------
|
||||
# Gedelegeerde verwijderen
|
||||
#---------------------------------------------
|
||||
@gmail_delegatie_bp.route('/verwijderen', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('gmail_delegatie')
|
||||
def verwijderen():
|
||||
_ensure_table()
|
||||
mailbox = (request.form.get('mailbox') or '').strip().lower()
|
||||
delegate = (request.form.get('delegate') or '').strip().lower()
|
||||
try:
|
||||
delegation_service.remove_delegate(mailbox, delegate)
|
||||
_safe_log('verwijderd', mailbox, delegate)
|
||||
_ververs_mailbox(mailbox)
|
||||
flash(f"Toegang van {delegate} tot {mailbox} is ingetrokken.", "success")
|
||||
except DelegationError as e:
|
||||
flash(str(e), "error")
|
||||
return redirect(url_for('gmail_delegatie.overzicht', mailbox=mailbox))
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Google › E-mails met een label verplaatsen of kopiëren naar een andere mailbox."""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
|
||||
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist, log
|
||||
from services import gmail_label_service as gl
|
||||
from services.gmail_delegation_service import GmailDelegationService, DelegationError
|
||||
|
||||
gmail_labels_bp = Blueprint('gmail_labels', __name__)
|
||||
|
||||
|
||||
def _mailbox(waarde):
|
||||
"""E-mailadres → {'email', 'naam', ...} via Google (hoofdadres, ook als er een alias ingegeven werd)."""
|
||||
return GmailDelegationService().resolve_user(waarde)
|
||||
|
||||
|
||||
@gmail_labels_bp.route('/', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('gmail_labels')
|
||||
def overzicht():
|
||||
bron_in = (request.args.get('bron') or '').strip()
|
||||
doel_in = (request.args.get('doel') or '').strip()
|
||||
label_id = (request.args.get('label') or '').strip()
|
||||
bron = doel = None
|
||||
labels, fout, gekozen = [], None, None
|
||||
if bron_in:
|
||||
try:
|
||||
bron = _mailbox(bron_in)
|
||||
labels = gl.labels(bron['email'])
|
||||
gekozen = next((l for l in labels if l['id'] == label_id), None)
|
||||
except (DelegationError, gl.LabelFout) as e:
|
||||
fout = str(e)
|
||||
except Exception as e:
|
||||
fout = gl.vriendelijk(e)
|
||||
if doel_in and not fout:
|
||||
try:
|
||||
doel = _mailbox(doel_in)
|
||||
if bron and doel['email'] == bron['email']:
|
||||
fout, doel = "De doelmailbox moet een andere mailbox zijn dan de bronmailbox.", None
|
||||
except DelegationError as e:
|
||||
fout = str(e)
|
||||
try:
|
||||
geschiedenis = gl.geschiedenis()
|
||||
except Exception:
|
||||
geschiedenis = []
|
||||
return render_template('gmail_labels.html', bron_in=bron_in, doel_in=doel_in, bron=bron, doel=doel,
|
||||
labels=labels, gekozen=gekozen, fout=fout, status=gl.LabelVerplaatser().status,
|
||||
geschiedenis=geschiedenis, scope=gl.SCOPE)
|
||||
|
||||
|
||||
@gmail_labels_bp.route('/start', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('gmail_labels')
|
||||
def start():
|
||||
f = request.form
|
||||
terug = dict(bron=f.get('bron'), doel=f.get('doel'), label=f.get('label'))
|
||||
modus = 'verplaatsen' if f.get('modus') == 'verplaatsen' else 'kopieren'
|
||||
try:
|
||||
bron, doel = _mailbox(f.get('bron')), _mailbox(f.get('doel'))
|
||||
if bron['email'] == doel['email']:
|
||||
raise DelegationError("De doelmailbox moet een andere mailbox zijn dan de bronmailbox.")
|
||||
label = next((l for l in gl.labels(bron['email']) if l['id'] == f.get('label')), None)
|
||||
if not label:
|
||||
raise gl.LabelFout("Dat label bestaat niet (meer) in de bronmailbox.")
|
||||
except (DelegationError, gl.LabelFout) as e:
|
||||
flash(str(e), "error")
|
||||
return redirect(url_for('gmail_labels.overzicht', **terug))
|
||||
door = session.get('naam') or session.get('user')
|
||||
if gl.LabelVerplaatser().start(bron['email'], label['id'], label['naam'], doel['email'], modus, door):
|
||||
log(f"Gmail: label '{label['naam']}' {modus} van {bron['email']} naar {doel['email']}")
|
||||
flash(f"Gestart: de berichten met label '{label['naam']}' worden {'verplaatst' if modus == 'verplaatsen' else 'gekopieerd'} "
|
||||
f"naar {doel['email']}. Je mag deze pagina sluiten; het gaat verder op de achtergrond.", "info")
|
||||
else:
|
||||
flash("Er loopt al een opdracht. Wacht tot die klaar is.", "warning")
|
||||
return redirect(url_for('gmail_labels.overzicht', **terug))
|
||||
|
||||
|
||||
@gmail_labels_bp.route('/status.json', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('gmail_labels')
|
||||
def status():
|
||||
s = gl.LabelVerplaatser().status
|
||||
return jsonify({k: s.get(k) for k in ('bezig', 'voortgang', 'totaal', 'gelukt', 'overgeslagen', 'fouten', 'fout',
|
||||
'resultaat', 'opdracht', 'foutvoorbeelden')})
|
||||
@@ -0,0 +1,112 @@
|
||||
from flask import Blueprint, request, flash, redirect, url_for, render_template, make_response, jsonify
|
||||
from services.google_service import GoogleAdminService
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist
|
||||
import rechten
|
||||
from config import Config
|
||||
from tenancy import per_school
|
||||
from datetime import datetime
|
||||
import io
|
||||
from xhtml2pdf import pisa
|
||||
|
||||
google_bp = Blueprint('google', __name__)
|
||||
google_service = per_school(GoogleAdminService)
|
||||
|
||||
#-------------------------------------------------
|
||||
# Passwoord reset route
|
||||
#-------------------------------------------------
|
||||
@google_bp.route('/reset_pw', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('google_wachtwoord')
|
||||
def reset_pw():
|
||||
email = request.form.get('email')
|
||||
nieuw_wachtwoord = Config.STANDAARD_WACHTWOORD
|
||||
|
||||
if not email:
|
||||
flash("Geen e-mailadres opgegeven.", "error")
|
||||
return redirect(url_for('google.password_reset'))
|
||||
|
||||
success, message = google_service.change_password(email, nieuw_wachtwoord)
|
||||
|
||||
if success:
|
||||
rechten.log(actie=f"PW Reset voor {email}") # tabel Logs (centrale database)
|
||||
flash(f"Wachtwoord voor {email} is gereset naar {nieuw_wachtwoord}", "success")
|
||||
else:
|
||||
flash(f"Google API Fout: {message}", "error")
|
||||
|
||||
return redirect(url_for('google.password_reset'))
|
||||
|
||||
#--------------------------------------------------
|
||||
# Route voor pagina google gebruikers
|
||||
#--------------------------------------------------
|
||||
|
||||
@google_bp.route('/google_account_beheer', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@recht_vereist('google_wachtwoord', 'google_accounts')
|
||||
def password_reset():
|
||||
results = []
|
||||
query = ""
|
||||
if request.method == 'POST':
|
||||
query = request.form.get('query')
|
||||
results = google_service.search_users(query)
|
||||
return render_template('google_beheer.html', results=results, query=query)
|
||||
|
||||
|
||||
# Logica voor apply password
|
||||
@google_bp.route('/apply_password', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('google_wachtwoord')
|
||||
def apply_password():
|
||||
email = request.form.get('email')
|
||||
new_password = request.form.get('new_password')
|
||||
|
||||
success, message = google_service.change_password(email, new_password)
|
||||
if success:
|
||||
return "OK", 200
|
||||
else:
|
||||
return message, 500
|
||||
|
||||
# Logica voor google account aanmaken
|
||||
@google_bp.route('/aanmaken_google_account', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('google_accounts')
|
||||
def aanmaken_google_account():
|
||||
first_name = request.form.get('first_name')
|
||||
last_name = request.form.get('last_name')
|
||||
password = request.form.get('password')
|
||||
account_type = request.form.get('account_type')
|
||||
|
||||
|
||||
# Gebruik de config vanuit de service
|
||||
target_ou = Config.OU_NIEUWE_LEERKRACHTEN if account_type == "Leerkracht" else f"/{google_service.conf.OU_NEW_STUDENTS}"
|
||||
|
||||
template_html = "pdf_template_leerkracht.html" if account_type == "Leerkracht" else "pdf_template_leerling.html"
|
||||
|
||||
success, result = google_service.create_user(first_name, last_name, password, target_ou)
|
||||
|
||||
if success:
|
||||
context = {
|
||||
'naam': f"{first_name} {last_name}",
|
||||
'email': result,
|
||||
'wachtwoord': password,
|
||||
'datum': datetime.now().strftime("%d/%m/%Y")
|
||||
}
|
||||
html_string = render_template(template_html, **context)
|
||||
pdf_buffer = io.BytesIO()
|
||||
|
||||
# Gebruik pisa om de HTML om te zetten naar PDF
|
||||
pisa_status = pisa.CreatePDF(io.StringIO(html_string), dest=pdf_buffer)
|
||||
|
||||
if pisa_status.err:
|
||||
flash("Fout bij het genereren van PDF", "error")
|
||||
return redirect(url_for('google.password_reset'))
|
||||
|
||||
pdf_buffer.seek(0)
|
||||
response = make_response(pdf_buffer.read())
|
||||
response.headers['Content-Type'] = 'application/pdf'
|
||||
response.headers['Content-Disposition'] = f'inline; filename=account_{first_name}.pdf'
|
||||
return response
|
||||
else:
|
||||
flash(f"Google Fout: {result}", "error")
|
||||
return redirect(url_for('google.password_reset'))
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, Response
|
||||
from tenancy import huidige_school, per_school
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist
|
||||
from services.google_license_service import GoogleLicenseService, SKU_NAMES
|
||||
|
||||
google_licenties_bp = Blueprint('google_licenties', __name__)
|
||||
license_service = per_school(GoogleLicenseService)
|
||||
|
||||
# Tabel aanmaken bij de eerste request (niet bij import, zodat de app ook start zonder DB)
|
||||
_table_ready = set() # scholen waarvoor de tabellen al gecontroleerd zijn
|
||||
|
||||
|
||||
def _ensure_table():
|
||||
if huidige_school().code not in _table_ready:
|
||||
try:
|
||||
license_service.ensure_table()
|
||||
_table_ready.add(huidige_school().code)
|
||||
except Exception as e:
|
||||
flash(f"Kon tabel google_licenties niet aanmaken: {e}", "error")
|
||||
|
||||
|
||||
#---------------------------------------------
|
||||
# Overzichtspagina
|
||||
#---------------------------------------------
|
||||
@google_licenties_bp.route('/', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('google_licenties')
|
||||
def overzicht():
|
||||
_ensure_table()
|
||||
data, fout = license_service.get_overview(force=request.args.get('vernieuw') == '1')
|
||||
if request.args.get('vernieuw') == '1':
|
||||
return redirect(url_for('google_licenties.overzicht'))
|
||||
|
||||
opgehaald = None
|
||||
if data:
|
||||
opgehaald = datetime.fromtimestamp(data['opgehaald']).strftime('%d-%m-%Y %H:%M')
|
||||
return render_template('google_licenties.html', data=data, fout=fout,
|
||||
opgehaald=opgehaald, sku_names=SKU_NAMES)
|
||||
|
||||
|
||||
#---------------------------------------------
|
||||
# Aangekochte aantallen opslaan
|
||||
#---------------------------------------------
|
||||
@google_licenties_bp.route('/aangekocht', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('google_licenties')
|
||||
def aangekocht_opslaan():
|
||||
_ensure_table()
|
||||
gewijzigd = 0
|
||||
for key, waarde in request.form.items():
|
||||
if not key.startswith('aangekocht_'):
|
||||
continue
|
||||
sku_id = key.replace('aangekocht_', '', 1)
|
||||
if sku_id not in SKU_NAMES:
|
||||
continue
|
||||
waarde = (waarde or '').strip()
|
||||
if waarde == '':
|
||||
continue
|
||||
if not waarde.isdigit():
|
||||
flash(f"Ongeldig aantal voor {SKU_NAMES[sku_id]}: '{waarde}'", "error")
|
||||
continue
|
||||
license_service.set_purchased(sku_id, int(waarde), session.get('user'))
|
||||
gewijzigd += 1
|
||||
|
||||
license_service.clear_cache()
|
||||
if gewijzigd:
|
||||
flash("Aangekochte aantallen opgeslagen.", "success")
|
||||
return redirect(url_for('google_licenties.overzicht'))
|
||||
|
||||
|
||||
#---------------------------------------------
|
||||
# Export van alle toegewezen licenties
|
||||
#---------------------------------------------
|
||||
@google_licenties_bp.route('/export.csv', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('google_licenties')
|
||||
def export_csv():
|
||||
data, fout = license_service.get_overview()
|
||||
if fout or not data:
|
||||
flash(fout or "Geen gegevens om te exporteren.", "error")
|
||||
return redirect(url_for('google_licenties.overzicht'))
|
||||
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf, delimiter=';')
|
||||
w.writerow(['E-mail', 'Naam', 'Organisatie-eenheid', 'Licentie', 'SKU', 'Geschorst', 'Gearchiveerd', 'Laatste login'])
|
||||
for u in data['users']:
|
||||
w.writerow([u['email'], u['naam'], u['ou'], u['sku_naam'], u['sku_id'],
|
||||
'ja' if u['geschorst'] else 'nee', 'ja' if u['gearchiveerd'] else 'nee', u['laatste_login']])
|
||||
bestandsnaam = f"google_licenties_{datetime.now():%Y%m%d}.csv"
|
||||
return Response('' + buf.getvalue(), mimetype='text/csv',
|
||||
headers={'Content-Disposition': f'attachment; filename={bestandsnaam}'})
|
||||
@@ -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))
|
||||
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
📘 Handleidingen: stappenplannen voor het beheer (bv. Google Admin).
|
||||
|
||||
- Lezen: iedereen met een PIVOT-account (handleidingen met Zichtbaar = 'admins' enkel administrators).
|
||||
Leerkrachten die enkel met Google inloggen (zonder PIVOT-account) zien ze niet.
|
||||
- Aanmaken, bewerken, verwijderen, vorige versie terugzetten: enkel administrators.
|
||||
|
||||
Handleidingen staan in de centrale database: ze gelden voor alle scholen.
|
||||
Elke opslag bewaart de vorige tekst in Handleidingen_versies.
|
||||
"""
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, abort
|
||||
|
||||
import rechten
|
||||
from blueprints.auth import login_required
|
||||
from database import central_query, central_insert
|
||||
from rechten import admin_vereist, is_admin
|
||||
from services.html_schoon import schoon
|
||||
|
||||
handleidingen_bp = Blueprint('handleidingen', __name__)
|
||||
|
||||
ZICHTBAAR = {'iedereen': 'Iedereen met een PIVOT-account', 'admins': 'Enkel administrators'}
|
||||
MAX_VERSIES = 30
|
||||
|
||||
|
||||
def mag_lezen():
|
||||
return is_admin() or bool(session.get('gebruiker_id'))
|
||||
|
||||
|
||||
def _zichtbaar_filter():
|
||||
return ("Zichtbaar IN ('iedereen', 'admins')" if is_admin() else "Zichtbaar = 'iedereen'")
|
||||
|
||||
|
||||
def lijst():
|
||||
"""Zichtbare handleidingen voor de ingelogde gebruiker (ook gebruikt door het dashboard)."""
|
||||
if not mag_lezen():
|
||||
return []
|
||||
try:
|
||||
return central_query(f"SELECT Handleiding_ID, Slug, Titel, Categorie, Samenvatting, Zichtbaar, Gewijzigd_op, "
|
||||
f"Gewijzigd_door FROM Handleidingen WHERE {_zichtbaar_filter()} "
|
||||
f"ORDER BY Volgorde, Titel")
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _maak_slug(titel, behalve=None):
|
||||
s = unicodedata.normalize('NFKD', titel).encode('ascii', 'ignore').decode().lower()
|
||||
s = re.sub(r'[^a-z0-9]+', '-', s).strip('-')[:80] or 'handleiding'
|
||||
if s == 'nieuw': # botst met de route /handleidingen/nieuw
|
||||
s = 'nieuw-1'
|
||||
kandidaat, n = s, 2
|
||||
while central_query("SELECT 1 FROM Handleidingen WHERE Slug = %s AND Handleiding_ID <> %s",
|
||||
(kandidaat, behalve or 0)):
|
||||
kandidaat, n = f"{s}-{n}", n + 1
|
||||
return kandidaat
|
||||
|
||||
|
||||
def _haal(slug):
|
||||
rows = central_query(f"SELECT * FROM Handleidingen WHERE Slug = %s AND {_zichtbaar_filter()}", (slug,))
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def _inhoudstafel(html):
|
||||
"""[(id, tekst, niveau)] van de h2/h3-koppen; koppen zonder id krijgen er een."""
|
||||
gezien, toc = set(), []
|
||||
|
||||
def vervang(m):
|
||||
tag, attrs, tekst = m.group(1), m.group(2) or '', m.group(3)
|
||||
platte = re.sub(r'<[^>]+>', '', tekst).strip()
|
||||
idm = re.search(r'id="([^"]+)"', attrs)
|
||||
hid = idm.group(1) if idm else (re.sub(r'[^a-z0-9]+', '-', platte.lower()).strip('-') or 'kop')
|
||||
basis, n = hid, 2
|
||||
while hid in gezien:
|
||||
hid, n = f"{basis}-{n}", n + 1
|
||||
gezien.add(hid)
|
||||
toc.append((hid, platte, int(tag[1])))
|
||||
attrs = re.sub(r'\s*id="[^"]*"', '', attrs)
|
||||
return f'<{tag} id="{hid}"{attrs}>{tekst}</{tag}>'
|
||||
|
||||
html = re.sub(r'<(h[23])((?:\s[^>]*)?)>(.*?)</\1>', vervang, schoon(html or ''), flags=re.S)
|
||||
return html, toc
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lezen
|
||||
# ------------------------------------------------------------------
|
||||
@handleidingen_bp.route('/', methods=['GET'])
|
||||
@login_required
|
||||
def overzicht():
|
||||
if not mag_lezen():
|
||||
flash("Handleidingen zijn enkel beschikbaar voor PIVOT-gebruikers.", "error")
|
||||
return redirect(url_for('main.dashboard'))
|
||||
q = (request.args.get('q') or '').strip()
|
||||
items = lijst()
|
||||
if q:
|
||||
treffers = {r['Handleiding_ID'] for r in central_query(
|
||||
f"SELECT Handleiding_ID FROM Handleidingen WHERE {_zichtbaar_filter()} AND "
|
||||
"(Titel LIKE %s OR Samenvatting LIKE %s OR Inhoud LIKE %s)", (f"%{q}%",) * 3)}
|
||||
items = [h for h in items if h['Handleiding_ID'] in treffers]
|
||||
per_cat = {}
|
||||
for h in items:
|
||||
per_cat.setdefault(h['Categorie'] or 'Algemeen', []).append(h)
|
||||
return render_template('handleidingen.html', per_cat=per_cat, q=q, ZICHTBAAR=ZICHTBAAR)
|
||||
|
||||
|
||||
@handleidingen_bp.route('/<slug>', methods=['GET'])
|
||||
@login_required
|
||||
def bekijk(slug):
|
||||
if not mag_lezen():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
h = _haal(slug)
|
||||
if not h:
|
||||
flash("Deze handleiding bestaat niet (meer).", "error")
|
||||
return redirect(url_for('handleidingen.overzicht'))
|
||||
inhoud, toc = _inhoudstafel(h['Inhoud'])
|
||||
return render_template('handleiding.html', h=h, inhoud=inhoud, toc=toc, ZICHTBAAR=ZICHTBAAR)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bewerken (enkel administrators)
|
||||
# ------------------------------------------------------------------
|
||||
def _formulier():
|
||||
titel = (request.form.get('titel') or '').strip()[:200]
|
||||
return {
|
||||
'Titel': titel,
|
||||
'Categorie': (request.form.get('categorie') or '').strip()[:100] or None,
|
||||
'Samenvatting': (request.form.get('samenvatting') or '').strip()[:300] or None,
|
||||
'Inhoud': schoon(request.form.get('inhoud') or ''),
|
||||
'Zichtbaar': request.form.get('zichtbaar') if request.form.get('zichtbaar') in ZICHTBAAR else 'iedereen',
|
||||
'Volgorde': request.form.get('volgorde', type=int) or 0,
|
||||
}
|
||||
|
||||
|
||||
def _te_groot(e):
|
||||
t = str(e)
|
||||
if 'max_allowed_packet' in t or 'too large' in t.lower() or '1153' in t:
|
||||
return (": de handleiding is te groot voor de database (te veel of te grote afbeeldingen). Verdeel ze over "
|
||||
"meerdere handleidingen, of verhoog max_allowed_packet in MySQL.")
|
||||
return f": {t}"
|
||||
|
||||
|
||||
def _categorieen():
|
||||
return [r['Categorie'] for r in central_query(
|
||||
"SELECT DISTINCT Categorie FROM Handleidingen WHERE Categorie IS NOT NULL AND Zichtbaar <> 'weg' ORDER BY Categorie")]
|
||||
|
||||
|
||||
@handleidingen_bp.route('/nieuw', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_vereist
|
||||
def nieuw():
|
||||
if request.method == 'POST':
|
||||
f = _formulier()
|
||||
if not f['Titel']:
|
||||
flash("Geef de handleiding een titel.", "error")
|
||||
return render_template('handleiding_bewerken.html', h=f, nieuw=True, categorieen=_categorieen(),
|
||||
ZICHTBAAR=ZICHTBAAR, versies=[])
|
||||
slug = _maak_slug(f['Titel'])
|
||||
try:
|
||||
central_insert("INSERT INTO Handleidingen (Slug, Titel, Categorie, Samenvatting, Inhoud, Zichtbaar, "
|
||||
"Volgorde, Gewijzigd_op, Gewijzigd_door) VALUES (%s, %s, %s, %s, %s, %s, %s, NOW(), %s)",
|
||||
(slug, f['Titel'], f['Categorie'], f['Samenvatting'], f['Inhoud'], f['Zichtbaar'],
|
||||
f['Volgorde'], session.get('user')))
|
||||
except Exception as e:
|
||||
flash(f"Opslaan mislukt{_te_groot(e)}", "error")
|
||||
return render_template('handleiding_bewerken.html', h=f, nieuw=True, categorieen=_categorieen(),
|
||||
ZICHTBAAR=ZICHTBAAR, versies=[])
|
||||
rechten.log(actie=f"Handleiding '{f['Titel']}' aangemaakt")
|
||||
flash("Handleiding aangemaakt.", "success")
|
||||
return redirect(url_for('handleidingen.bekijk', slug=slug))
|
||||
leeg = {'Titel': '', 'Categorie': request.args.get('categorie') or '', 'Samenvatting': '', 'Inhoud': '',
|
||||
'Zichtbaar': 'iedereen', 'Volgorde': 100}
|
||||
return render_template('handleiding_bewerken.html', h=leeg, nieuw=True, categorieen=_categorieen(),
|
||||
ZICHTBAAR=ZICHTBAAR, versies=[])
|
||||
|
||||
|
||||
@handleidingen_bp.route('/<slug>/bewerken', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_vereist
|
||||
def bewerken(slug):
|
||||
h = _haal(slug)
|
||||
if not h:
|
||||
abort(404)
|
||||
if request.method == 'POST':
|
||||
f = _formulier()
|
||||
if not f['Titel']:
|
||||
flash("De titel mag niet leeg zijn.", "error")
|
||||
return redirect(url_for('handleidingen.bewerken', slug=slug))
|
||||
if f['Titel'] != h['Titel'] or f['Inhoud'] != (h['Inhoud'] or ''):
|
||||
central_query("INSERT INTO Handleidingen_versies (Handleiding_ID, Titel, Inhoud, Opgeslagen_op, Door) "
|
||||
"VALUES (%s, %s, %s, %s, %s)",
|
||||
(h['Handleiding_ID'], h['Titel'], h['Inhoud'], h['Gewijzigd_op'], h['Gewijzigd_door']),
|
||||
commit=True)
|
||||
_versies_opruimen(h['Handleiding_ID'])
|
||||
try:
|
||||
central_query("UPDATE Handleidingen SET Titel = %s, Categorie = %s, Samenvatting = %s, Inhoud = %s, "
|
||||
"Zichtbaar = %s, Volgorde = %s, Gewijzigd_op = NOW(), Gewijzigd_door = %s "
|
||||
"WHERE Handleiding_ID = %s",
|
||||
(f['Titel'], f['Categorie'], f['Samenvatting'], f['Inhoud'], f['Zichtbaar'], f['Volgorde'],
|
||||
session.get('user'), h['Handleiding_ID']), commit=True)
|
||||
except Exception as e:
|
||||
flash(f"Opslaan mislukt{_te_groot(e)}", "error")
|
||||
return redirect(url_for('handleidingen.bewerken', slug=slug))
|
||||
rechten.log(actie=f"Handleiding '{f['Titel']}' bewerkt")
|
||||
flash("Handleiding opgeslagen.", "success")
|
||||
if request.form.get('verder'):
|
||||
return redirect(url_for('handleidingen.bewerken', slug=slug))
|
||||
return redirect(url_for('handleidingen.bekijk', slug=slug))
|
||||
versies = central_query("SELECT Versie_ID, Titel, Opgeslagen_op, Door FROM Handleidingen_versies "
|
||||
"WHERE Handleiding_ID = %s ORDER BY Opgeslagen_op DESC, Versie_ID DESC",
|
||||
(h['Handleiding_ID'],))
|
||||
h['Inhoud'] = schoon(h['Inhoud'] or '')
|
||||
return render_template('handleiding_bewerken.html', h=h, nieuw=False, categorieen=_categorieen(),
|
||||
ZICHTBAAR=ZICHTBAAR, versies=versies)
|
||||
|
||||
|
||||
def _versies_opruimen(hid):
|
||||
oud = central_query("SELECT Versie_ID FROM Handleidingen_versies WHERE Handleiding_ID = %s "
|
||||
"ORDER BY Opgeslagen_op DESC, Versie_ID DESC", (hid,))[MAX_VERSIES:]
|
||||
for r in oud:
|
||||
central_query("DELETE FROM Handleidingen_versies WHERE Versie_ID = %s", (r['Versie_ID'],), commit=True)
|
||||
|
||||
|
||||
@handleidingen_bp.route('/<slug>/versie/<int:vid>', methods=['GET'])
|
||||
@login_required
|
||||
@admin_vereist
|
||||
def versie(slug, vid):
|
||||
h = _haal(slug)
|
||||
rows = central_query("SELECT * FROM Handleidingen_versies WHERE Versie_ID = %s AND Handleiding_ID = %s",
|
||||
(vid, h['Handleiding_ID'] if h else 0))
|
||||
if not h or not rows:
|
||||
abort(404)
|
||||
v = rows[0]
|
||||
inhoud, toc = _inhoudstafel(v['Inhoud'])
|
||||
return render_template('handleiding.html', h=h, inhoud=inhoud, toc=toc, versie=v, ZICHTBAAR=ZICHTBAAR)
|
||||
|
||||
|
||||
@handleidingen_bp.route('/<slug>/versie/<int:vid>/terugzetten', methods=['POST'])
|
||||
@login_required
|
||||
@admin_vereist
|
||||
def terugzetten(slug, vid):
|
||||
h = _haal(slug)
|
||||
rows = central_query("SELECT * FROM Handleidingen_versies WHERE Versie_ID = %s AND Handleiding_ID = %s",
|
||||
(vid, h['Handleiding_ID'] if h else 0))
|
||||
if not h or not rows:
|
||||
abort(404)
|
||||
v = rows[0]
|
||||
central_query("INSERT INTO Handleidingen_versies (Handleiding_ID, Titel, Inhoud, Opgeslagen_op, Door) "
|
||||
"VALUES (%s, %s, %s, %s, %s)",
|
||||
(h['Handleiding_ID'], h['Titel'], h['Inhoud'], h['Gewijzigd_op'], h['Gewijzigd_door']), commit=True)
|
||||
central_query("UPDATE Handleidingen SET Titel = %s, Inhoud = %s, Gewijzigd_op = NOW(), Gewijzigd_door = %s "
|
||||
"WHERE Handleiding_ID = %s", (v['Titel'], v['Inhoud'], session.get('user'), h['Handleiding_ID']),
|
||||
commit=True)
|
||||
_versies_opruimen(h['Handleiding_ID'])
|
||||
rechten.log(actie=f"Handleiding '{v['Titel']}': versie van {v['Opgeslagen_op']:%d-%m-%Y %H:%M} teruggezet")
|
||||
flash(f"Versie van {v['Opgeslagen_op']:%d-%m-%Y %H:%M} teruggezet. De vorige tekst staat bij de versies.", "success")
|
||||
return redirect(url_for('handleidingen.bekijk', slug=slug))
|
||||
|
||||
|
||||
@handleidingen_bp.route('/<slug>/verwijderen', methods=['POST'])
|
||||
@login_required
|
||||
@admin_vereist
|
||||
def verwijderen(slug):
|
||||
h = _haal(slug)
|
||||
if not h:
|
||||
abort(404)
|
||||
# Verbergen i.p.v. wissen: zo komt een meegeleverde handleiding niet terug bij het opstarten
|
||||
central_query("UPDATE Handleidingen SET Zichtbaar = 'weg', Gewijzigd_op = NOW(), Gewijzigd_door = %s "
|
||||
"WHERE Handleiding_ID = %s", (session.get('user'), h['Handleiding_ID']), commit=True)
|
||||
rechten.log(actie=f"Handleiding '{h['Titel']}' verwijderd")
|
||||
flash(f"Handleiding '{h['Titel']}' verwijderd.", "success")
|
||||
return redirect(url_for('handleidingen.overzicht'))
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Gmail › Handtekeningen: sjabloon, logo, OU's, functie/telefoon (Google) en toepassen in Gmail."""
|
||||
import time
|
||||
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify, Response, abort
|
||||
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist, log
|
||||
from services import handtekening_service as hs
|
||||
from tenancy import huidige_school, school_op_code, in_school
|
||||
|
||||
handtekeningen_bp = Blueprint('handtekeningen', __name__)
|
||||
handtekening_logo_bp = Blueprint('handtekening_logo', __name__) # publiek (Gmail haalt het logo op)
|
||||
|
||||
CACHE_SECONDEN = 5 * 60
|
||||
|
||||
|
||||
def _door():
|
||||
return session.get('naam') or session.get('user')
|
||||
|
||||
|
||||
def _gebruikers(inst, vernieuw=False):
|
||||
school = huidige_school()
|
||||
sleutel = ('handtekening_gebruikers', tuple(inst['ous']), bool(inst.get('Met_sub_ous')))
|
||||
bewaard = school.cache.get('handtekening_gebruikers')
|
||||
if bewaard and not vernieuw and bewaard[0] == sleutel and time.time() - bewaard[1] < CACHE_SECONDEN:
|
||||
return bewaard[2]
|
||||
lijst = hs.gebruikers(inst['ous'], bool(inst.get('Met_sub_ous')))
|
||||
school.cache['handtekening_gebruikers'] = (sleutel, time.time(), lijst)
|
||||
return lijst
|
||||
|
||||
|
||||
@handtekeningen_bp.route('/', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('gmail_handtekeningen')
|
||||
def overzicht():
|
||||
inst = hs.instellingen()
|
||||
fout_ou = fout_gebruikers = None
|
||||
try:
|
||||
ous = hs.ou_lijst()
|
||||
except hs.HandtekeningFout as e:
|
||||
ous, fout_ou = inst['ous'], str(e)
|
||||
gebruikers = []
|
||||
if inst['ous']:
|
||||
try:
|
||||
gebruikers = _gebruikers(inst, vernieuw=bool(request.args.get('vernieuw')))
|
||||
except Exception as e:
|
||||
fout_gebruikers = str(e)
|
||||
if request.args.get('vernieuw'):
|
||||
return redirect(url_for('handtekeningen.overzicht'))
|
||||
logboek = hs.logboek()
|
||||
return render_template('handtekeningen.html', inst=inst, ous=ous, fout_ou=fout_ou, gebruikers=gebruikers,
|
||||
fout_gebruikers=fout_gebruikers, logboek=logboek, velden=hs.VELDEN,
|
||||
standaard_sjabloon=hs.STANDAARD_SJABLOON, max_tekens=hs.MAX_TEKENS,
|
||||
publiek_voorstel=request.host_url.rstrip('/'), status=hs.Toepasser().status,
|
||||
school_gegevens=hs.gegevens({}))
|
||||
|
||||
|
||||
@handtekeningen_bp.route('/sjabloon', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('gmail_handtekeningen')
|
||||
def sjabloon():
|
||||
hs.ensure_tables()
|
||||
try:
|
||||
hs.bewaar(request.form.get('sjabloon'), request.form.getlist('ou'), request.form.get('met_sub') == '1',
|
||||
request.form.get('logo_url'), request.form.get('logo_breedte') or 120,
|
||||
request.form.get('publiek_adres'), _door())
|
||||
bestand = request.files.get('logo')
|
||||
if bestand and bestand.filename:
|
||||
hs.bewaar_logo(bestand.read(), bestand.mimetype)
|
||||
elif request.form.get('logo_wissen') == '1':
|
||||
hs.verwijder_logo()
|
||||
huidige_school().cache.pop('handtekening_gebruikers', None)
|
||||
log("Gmail-handtekening: sjabloon en instellingen bewaard")
|
||||
flash("Handtekening en instellingen bewaard. Klik op ▶ Toepassen om ze in Gmail te zetten.", "success")
|
||||
except hs.HandtekeningFout as e:
|
||||
flash(str(e), "error")
|
||||
except Exception as e:
|
||||
flash(f"Bewaren mislukt: {e}", "error")
|
||||
return redirect(url_for('handtekeningen.overzicht'))
|
||||
|
||||
|
||||
@handtekeningen_bp.route('/voorbeeld', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('gmail_handtekeningen')
|
||||
def voorbeeld():
|
||||
"""Live voorbeeld met het (nog niet bewaarde) sjabloon uit de editor en de gegevens van één gebruiker."""
|
||||
d = request.get_json(silent=True) or {}
|
||||
inst = hs.instellingen()
|
||||
if d.get('logo_url') is not None:
|
||||
inst['Logo_url'] = (d.get('logo_url') or '').strip() or None
|
||||
if d.get('publiek_adres') is not None:
|
||||
inst['Publiek_adres'] = (d.get('publiek_adres') or '').strip().rstrip('/') or None
|
||||
try:
|
||||
inst['Logo_breedte'] = int(d.get('logo_breedte') or inst.get('Logo_breedte') or 120)
|
||||
except ValueError:
|
||||
pass
|
||||
inst['logo_link'] = hs.logo_link(inst)
|
||||
# In het voorbeeld het geüploade logo via de eigen (ingelogde) sessie tonen, ook als het publiek adres nog niet klopt
|
||||
if not inst.get('Logo_url') and inst.get('heeft_logo') and inst.get('Logo_sleutel'):
|
||||
inst['logo_link'] = url_for('handtekening_logo.logo', code=huidige_school().code, sleutel=inst['Logo_sleutel'],
|
||||
ext=hs.LOGO_TYPES.get(inst.get('Logo_type'), 'png'))
|
||||
g = d.get('gebruiker') or {}
|
||||
tekst = hs.vul_in(d.get('sjabloon') if d.get('sjabloon') is not None else inst.get('Sjabloon'), hs.gegevens(g), inst)
|
||||
return jsonify(ok=True, html=tekst, tekens=len(tekst))
|
||||
|
||||
|
||||
@handtekeningen_bp.route('/gebruiker', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('gmail_handtekeningen')
|
||||
def gebruiker():
|
||||
"""Functie en telefoon van één gebruiker in Google bewaren."""
|
||||
d = request.get_json(silent=True) or {}
|
||||
email = (d.get('email') or '').strip().lower()
|
||||
functie, telefoon = (d.get('functie') or '').strip()[:100], (d.get('telefoon') or '').strip()[:40]
|
||||
if not email:
|
||||
return jsonify(ok=False, fout="Geen gebruiker."), 400
|
||||
try:
|
||||
hs.bewaar_in_google(email, functie, telefoon)
|
||||
except hs.HandtekeningFout as e:
|
||||
return jsonify(ok=False, fout=str(e)), 400
|
||||
bewaard = huidige_school().cache.get('handtekening_gebruikers')
|
||||
if bewaard:
|
||||
for u in bewaard[2]:
|
||||
if u['email'] == email:
|
||||
u.update(functie=functie, telefoon=telefoon)
|
||||
log(f"Gmail-handtekening: functie/telefoon van {email} aangepast in Google")
|
||||
return jsonify(ok=True)
|
||||
|
||||
|
||||
@handtekeningen_bp.route('/toepassen', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('gmail_handtekeningen')
|
||||
def toepassen():
|
||||
d = request.get_json(silent=True) or {}
|
||||
emails = None if d.get('alle') else [e for e in (d.get('emails') or []) if isinstance(e, str)]
|
||||
if emails is not None and not emails:
|
||||
return jsonify(ok=False, fout="Niemand geselecteerd."), 400
|
||||
if not hs.Toepasser().start(emails, _door()):
|
||||
return jsonify(ok=False, fout="Er loopt al een opdracht."), 409
|
||||
log(f"Gmail-handtekening toepassen ({'iedereen' if emails is None else len(emails)})")
|
||||
return jsonify(ok=True)
|
||||
|
||||
|
||||
@handtekeningen_bp.route('/status.json', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('gmail_handtekeningen')
|
||||
def status():
|
||||
s = dict(hs.Toepasser().status)
|
||||
if not s['bezig']:
|
||||
s['logboek'] = {k: {'ok': bool(v['Ok']), 'melding': v['Melding'] or '',
|
||||
'op': v['Toegepast'].strftime('%d-%m-%Y %H:%M')} for k, v in hs.logboek().items()}
|
||||
return jsonify(s)
|
||||
|
||||
|
||||
@handtekeningen_bp.route('/nachtelijk', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('gmail_handtekeningen')
|
||||
def nachtelijk():
|
||||
aan = request.form.get('aan') == '1'
|
||||
hs.zet_nacht(aan, _door())
|
||||
log(f"Gmail-handtekening: nachtelijk opnieuw toepassen {'aan' if aan else 'uit'}")
|
||||
flash("Elke nacht wordt de handtekening opnieuw gezet bij wie ze gewijzigd heeft." if aan
|
||||
else "Het nachtelijk opnieuw toepassen staat uit.", "success")
|
||||
return redirect(url_for('handtekeningen.overzicht'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Publiek: het logo (Gmail haalt het op via een link; geen aanmelding)
|
||||
# ---------------------------------------------------------------------
|
||||
@handtekening_logo_bp.route('/handtekening-logo/<code>/<sleutel>.<ext>', methods=['GET'])
|
||||
def logo(code, sleutel, ext):
|
||||
school = school_op_code(code)
|
||||
if not school or not sleutel.isalnum():
|
||||
abort(404)
|
||||
with in_school(school):
|
||||
data, soort = hs.logo_bestand(sleutel)
|
||||
if not data:
|
||||
abort(404)
|
||||
return Response(bytes(data), mimetype=soort, headers={'Cache-Control': 'public, max-age=86400'})
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Internaat: lijst van alle internen van de actieve school (Informat Internaat WEB API)."""
|
||||
import csv
|
||||
import io
|
||||
import time
|
||||
from datetime import date
|
||||
|
||||
from flask import Blueprint, render_template, request, Response, redirect, url_for, flash
|
||||
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist
|
||||
from services.facturatie_service import schooljaren
|
||||
from services.internaat_service import InternaatService, InternaatFout
|
||||
from tenancy import huidige_school
|
||||
|
||||
internaat_bp = Blueprint('internaat', __name__)
|
||||
|
||||
|
||||
def _keuzes():
|
||||
jaren = schooljaren()
|
||||
schooljaar = request.args.get('schooljaar') if request.args.get('schooljaar') in jaren else jaren[0]
|
||||
try:
|
||||
peildatum = date.fromisoformat(request.args.get('peildatum') or '')
|
||||
except ValueError:
|
||||
peildatum = date.today()
|
||||
return schooljaar, jaren, peildatum
|
||||
|
||||
|
||||
@internaat_bp.route("/internen", methods=["GET"])
|
||||
@login_required
|
||||
@recht_vereist('internen')
|
||||
def internen():
|
||||
school = huidige_school()
|
||||
schooljaar, jaren, peildatum = _keuzes()
|
||||
rijen, fout, opgehaald = [], None, None
|
||||
if not school.heeft('INFORMAT_ID', 'INFORMAT_SECRET'):
|
||||
fout = "Informat is nog niet ingesteld voor deze school (🔌 Koppelingen › Informat)."
|
||||
else:
|
||||
try:
|
||||
rijen, t = InternaatService().internen(schooljaar, peildatum, vernieuw=bool(request.args.get('vernieuw')))
|
||||
opgehaald = time.strftime('%d-%m-%Y %H:%M', time.localtime(t))
|
||||
except InternaatFout as e:
|
||||
fout = str(e)
|
||||
except Exception as e:
|
||||
fout = f"Internen ophalen mislukt: {e}"
|
||||
internaten = sorted({r['internaat'] for r in rijen})
|
||||
return render_template("internen.html", rijen=rijen, fout=fout, opgehaald=opgehaald, schooljaar=schooljaar,
|
||||
jaren=jaren, peildatum=peildatum, internaten=internaten, school_naam=school.naam)
|
||||
|
||||
|
||||
@internaat_bp.route("/internen/smartschool.json", methods=["POST"])
|
||||
@login_required
|
||||
@recht_vereist('internen')
|
||||
def internen_smartschool():
|
||||
"""Welke internen bestaan als gebruiker in Smartschool? {pid: gebruikersnaam | null}"""
|
||||
from flask import jsonify
|
||||
from services.internaat_service import smartschool_status
|
||||
personen = (request.get_json(silent=True) or {}).get('personen') or []
|
||||
try:
|
||||
return jsonify(ok=True, status=smartschool_status(personen[:2000]))
|
||||
except InternaatFout as e:
|
||||
return jsonify(ok=False, fout=str(e))
|
||||
except Exception as e:
|
||||
return jsonify(ok=False, fout=f"Smartschool kon niet bevraagd worden: {e}")
|
||||
|
||||
|
||||
@internaat_bp.route("/internen/export.csv", methods=["GET"])
|
||||
@login_required
|
||||
@recht_vereist('internen')
|
||||
def export():
|
||||
schooljaar, _, peildatum = _keuzes()
|
||||
try:
|
||||
rijen, _ = InternaatService().internen(schooljaar, peildatum)
|
||||
except Exception as e:
|
||||
flash(str(e), "error")
|
||||
return redirect(url_for('internaat.internen', schooljaar=schooljaar))
|
||||
internaat = request.args.get('internaat') or ''
|
||||
zoek = (request.args.get('zoek') or '').strip().lower()
|
||||
if internaat:
|
||||
rijen = [r for r in rijen if r['internaat'] == internaat]
|
||||
if zoek:
|
||||
rijen = [r for r in rijen if zoek in ' '.join(str(r[k]) for k in ('naam', 'voornaam', 'stamnummer', 'kamer', 'vestiging', 'gemeente')).lower()]
|
||||
datum = lambda d: d.strftime('%d/%m/%Y') if d else ''
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf, delimiter=';')
|
||||
w.writerow(['Naam', 'Voornaam', 'Geboortedatum', 'Geslacht', 'Stamnummer', 'Internaat', 'Vestiging', 'Kamer',
|
||||
'Ingeschreven van', 'Ingeschreven tot', 'Financiering', 'Woonplaats', 'Contact 1', 'Gsm 1', 'E-mail 1',
|
||||
'Contact 2', 'Gsm 2', 'E-mail 2', 'Smartschool'])
|
||||
ss_cache = huidige_school().cache.get('ss_internen') or {}
|
||||
def ss(r):
|
||||
hit = ss_cache.get((str(r['id']), r['voornaam'], r['naam']))
|
||||
return '' if not hit else (hit[1] or 'niet gevonden')
|
||||
for r in rijen:
|
||||
o = (r['ouders'] + [{}, {}])[:2]
|
||||
w.writerow([r['naam'], r['voornaam'], datum(r['geboortedatum']), r['geslacht'], r['stamnummer'], r['internaat'],
|
||||
r['vestiging'], r['kamer'], datum(r['begin']), datum(r['einde']), r['financiering'], r['gemeente']]
|
||||
+ sum(([f"{x.get('naam', '')} ({x['type']})" if x.get('type') else x.get('naam', ''), x.get('gsm', ''),
|
||||
x.get('email', '')] for x in o), []) + [ss(r)])
|
||||
return Response('' + buf.getvalue(), mimetype='text/csv; charset=utf-8', headers={
|
||||
'Content-Disposition': f"attachment; filename=Internen_{huidige_school().code}_{peildatum:%Y%m%d}.csv"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Aanwezigheden (register per week: ochtend en avond)
|
||||
# ---------------------------------------------------------------------
|
||||
from datetime import timedelta
|
||||
from flask import jsonify, session
|
||||
from services import aanwezigheid_service as aw
|
||||
|
||||
|
||||
def _week():
|
||||
try:
|
||||
d = date.fromisoformat(request.args.get('week') or '')
|
||||
except ValueError:
|
||||
d = date.today()
|
||||
return aw.maandag_van(d)
|
||||
|
||||
|
||||
def _internen_van_week(maandag):
|
||||
"""Internen die in de week ingeschreven zijn (peildatum: vandaag als die in de week valt, anders woensdag)."""
|
||||
vandaag = date.today()
|
||||
peildatum = vandaag if maandag <= vandaag <= maandag + timedelta(days=6) else maandag + timedelta(days=2)
|
||||
rijen, _ = InternaatService().internen(aw.schooljaar_van(peildatum), peildatum)
|
||||
meerdere = len({r['internaat'] for r in rijen}) > 1
|
||||
for r in rijen:
|
||||
r['groep'] = aw.groep_van(r, meerdere)
|
||||
r['pid'] = str(r['id'])
|
||||
return rijen
|
||||
|
||||
|
||||
@internaat_bp.route("/aanwezigheden", methods=["GET"])
|
||||
@login_required
|
||||
@recht_vereist('internaat_aanwezigheid')
|
||||
def aanwezigheden():
|
||||
school = huidige_school()
|
||||
maandag = _week()
|
||||
rijen, fout, data = [], None, {}
|
||||
try:
|
||||
rijen = _internen_van_week(maandag)
|
||||
data = aw.week(maandag)
|
||||
except InternaatFout as e:
|
||||
fout = str(e)
|
||||
except Exception as e:
|
||||
fout = f"Internen ophalen mislukt: {e}"
|
||||
groepen = {}
|
||||
for r in sorted(rijen, key=lambda r: (r['groep'].lower(), r['naam'].lower(), r['voornaam'].lower())):
|
||||
groepen.setdefault(r['groep'], []).append(r)
|
||||
dagen = [maandag + timedelta(days=i) for i in range(7)]
|
||||
return render_template("aanwezigheden.html", groepen=groepen, data=data, dagen=dagen, maandag=maandag,
|
||||
fout=fout, school_naam=school.naam, vandaag=date.today(), dagnamen=aw.DAGEN,
|
||||
dagnamen_voluit=aw.DAGEN_VOLUIT, aantal=len(rijen),
|
||||
vorige=maandag - timedelta(days=7), volgende=maandag + timedelta(days=7),
|
||||
weeknr=maandag.isocalendar()[1])
|
||||
|
||||
|
||||
@internaat_bp.route("/aanwezigheden/zet", methods=["POST"])
|
||||
@login_required
|
||||
@recht_vereist('internaat_aanwezigheid')
|
||||
def aanwezigheid_zet():
|
||||
d = request.get_json(silent=True) or {}
|
||||
try:
|
||||
datum = date.fromisoformat(str(d.get('datum') or ''))
|
||||
aw.zet(datum, str(d.get('persoon') or '')[:64], d.get('moment'), d.get('status'), d.get('naam'),
|
||||
session.get('naam') or session.get('user'))
|
||||
except ValueError as e:
|
||||
return jsonify(ok=False, fout=str(e) or 'Ongeldige gegevens.'), 400
|
||||
except Exception as e:
|
||||
return jsonify(ok=False, fout=f"Bewaren mislukt: {e}"), 500
|
||||
return jsonify(ok=True)
|
||||
|
||||
|
||||
@internaat_bp.route("/aanwezigheden/export.xlsx", methods=["GET"])
|
||||
@login_required
|
||||
@recht_vereist('internaat_aanwezigheid')
|
||||
def aanwezigheden_export():
|
||||
"""Excel van de week, met dezelfde filters als op het scherm (?groep=&zoek=)."""
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
maandag = _week()
|
||||
try:
|
||||
rijen = _internen_van_week(maandag)
|
||||
data = aw.week(maandag)
|
||||
except Exception as e:
|
||||
flash(str(e), "error")
|
||||
return redirect(url_for('internaat.aanwezigheden', week=maandag.isoformat()))
|
||||
groep, zoek = request.args.get('groep') or '', (request.args.get('zoek') or '').strip().lower()
|
||||
rijen = [r for r in rijen if (not groep or r['groep'] == groep)
|
||||
and (not zoek or zoek in f"{r['naam']} {r['voornaam']}".lower())]
|
||||
rijen.sort(key=lambda r: (r['groep'].lower(), r['naam'].lower(), r['voornaam'].lower()))
|
||||
labels = {'aanwezig': 'Aanwezig', 'afwezig': 'Afwezig'}
|
||||
kleur = {'aanwezig': PatternFill('solid', fgColor='D7EDDF'), 'afwezig': PatternFill('solid', fgColor='F6D5CF')}
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = f"Week {maandag.isocalendar()[1]}"
|
||||
kop = ['Naam', 'Voornaam', 'Groep', 'Kamer']
|
||||
for i, dag in enumerate(aw.DAGEN_VOLUIT):
|
||||
d = maandag + timedelta(days=i)
|
||||
kop += [f"{dag} {d:%d/%m} ochtend", f"{dag} {d:%d/%m} avond"]
|
||||
ws.append(kop)
|
||||
for c in ws[1]:
|
||||
c.font = Font(bold=True)
|
||||
c.alignment = Alignment(wrap_text=True, vertical='top')
|
||||
vorige_groep = None
|
||||
for r in rijen:
|
||||
if r['groep'] != vorige_groep:
|
||||
ws.append([f"Groep: {r['groep']}"])
|
||||
ws.cell(ws.max_row, 1).font = Font(bold=True)
|
||||
vorige_groep = r['groep']
|
||||
rij = [r['naam'], r['voornaam'], r['groep'], r['kamer']]
|
||||
for i in range(7):
|
||||
for m in aw.MOMENTEN:
|
||||
rij.append(labels.get(data.get(f"{r['pid']}:{i}:{m}"), ''))
|
||||
ws.append(rij)
|
||||
for c in ws[ws.max_row][4:]:
|
||||
for k, f in kleur.items():
|
||||
if c.value == labels[k]:
|
||||
c.fill = f
|
||||
ws.column_dimensions['A'].width = 22
|
||||
ws.column_dimensions['B'].width = 16
|
||||
ws.column_dimensions['C'].width = 16
|
||||
ws.column_dimensions['D'].width = 22
|
||||
from openpyxl.utils import get_column_letter
|
||||
for i in range(5, 5 + 14):
|
||||
ws.column_dimensions[get_column_letter(i)].width = 13
|
||||
ws.freeze_panes = 'C2'
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return Response(buf.getvalue(), mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
headers={'Content-Disposition': f"attachment; filename=Aanwezigheden_{huidige_school().code}_"
|
||||
f"{maandag:%Y}-W{maandag.isocalendar()[1]:02d}.xlsx"})
|
||||
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
Beheer › IP-blokkades. Geldt voor alle scholen.
|
||||
|
||||
- Administrators zien en beheren alles (blokkeren, altijd toestaan, schakelaar, testen).
|
||||
- Een gewone gebruiker met het recht 'ip_whitelist' ziet enkel de toegestane adressen van de actieve school,
|
||||
kan een adres toevoegen (school = actieve school, opmerking = zijn naam) en enkel zijn eigen regels verwijderen.
|
||||
"""
|
||||
import functools
|
||||
|
||||
from flask import Blueprint, render_template, request, flash, redirect, url_for, session
|
||||
|
||||
from blueprints.auth import login_required
|
||||
from database import central_query
|
||||
from rechten import admin_vereist, recht_vereist, is_admin, log
|
||||
from services import ipblokkade as ipb
|
||||
from tenancy import alle_scholen, school_op_code, huidige_school
|
||||
|
||||
ipblokkade_bp = Blueprint('ipblokkade', __name__)
|
||||
|
||||
|
||||
def _schoolnaam(code):
|
||||
s = school_op_code(code, ook_inactief=True)
|
||||
return s.naam if s else code
|
||||
|
||||
|
||||
def admin_of_whitelist(view):
|
||||
"""Administrators altijd (ook zonder gekozen school); anderen met het recht 'ip_whitelist' in de actieve school."""
|
||||
beschermd = recht_vereist('ip_whitelist')(view)
|
||||
|
||||
@functools.wraps(view)
|
||||
def wrapped(*a, **kw):
|
||||
return view(*a, **kw) if session.get('auth') and is_admin() else beschermd(*a, **kw)
|
||||
return wrapped
|
||||
|
||||
|
||||
# Gewone gebruikers mogen hoogstens zo'n reeks toestaan (een ruimere reeks laat te veel adressen altijd binnen)
|
||||
MAX_PREFIX_GEBRUIKER = {4: 24, 6: 64}
|
||||
|
||||
|
||||
def _eigen(r):
|
||||
"""Mag de ingelogde (gewone) gebruiker deze regel verwijderen? Enkel als hij hem zelf toevoegde."""
|
||||
gid = session.get('gebruiker_id')
|
||||
return bool(gid) and r.get('Gebruiker_ID') == gid and r.get('Soort') == 'toestaan'
|
||||
|
||||
|
||||
def _gekozen_scholen():
|
||||
"""De aangevinkte scholen uit het formulier (enkel bestaande schoolcodes)."""
|
||||
return ipb.scholen_waarde(c for c in request.form.getlist('scholen') if school_op_code(c, ook_inactief=True))
|
||||
|
||||
|
||||
@ipblokkade_bp.route("/", methods=["GET"])
|
||||
@login_required
|
||||
@admin_of_whitelist
|
||||
def overzicht():
|
||||
ipb.ensure_table()
|
||||
rijen = central_query("SELECT * FROM IP_blokkades ORDER BY Soort, Reeks") or []
|
||||
if not is_admin():
|
||||
return _overzicht_gebruiker(rijen)
|
||||
import ipaddress
|
||||
for r in rijen: # sorteersleutel: numeriek op adres (zodat 9.x vóór 85.x komt), dan op grootte van de reeks
|
||||
try:
|
||||
net = ipaddress.ip_network(r['Reeks'], strict=False)
|
||||
r['Sorteer'] = f"{net.version}-{int(net.network_address):040d}-{net.prefixlen:03d}"
|
||||
except ValueError:
|
||||
r['Sorteer'] = r['Reeks']
|
||||
r['Scholen_codes'] = ipb.scholen_codes(r.get('Scholen'))
|
||||
r['Scholen_namen'] = sorted((_schoolnaam(c) for c in r['Scholen_codes']), key=str.lower)
|
||||
test_ip = (request.args.get('test') or '').strip()
|
||||
test = None
|
||||
if test_ip:
|
||||
try:
|
||||
lijst = ipb.regels(vers=True)
|
||||
geweigerd, rid = ipb.beoordeel(test_ip, lijst)
|
||||
regel = next((r for r in rijen if r['ID'] == rid), None)
|
||||
test = {'ip': test_ip, 'geweigerd': geweigerd, 'regel': regel}
|
||||
except Exception as e:
|
||||
flash(f"Test mislukt: {e}", "error")
|
||||
modus = ipb.instelling(ipb.MODUS_SLEUTEL)
|
||||
teller = ipb.instelling(ipb.TELLER_SLEUTEL)
|
||||
scholen = sorted(alle_scholen(ook_inactief=True), key=lambda s: s.naam.lower())
|
||||
return render_template("ipblokkade.html", rijen=rijen, mijn_ip=request.remote_addr, test=test, scholen=scholen,
|
||||
blokkeren=[r for r in rijen if r['Soort'] == 'blokkeren'],
|
||||
toestaan=[r for r in rijen if r['Soort'] == 'toestaan'],
|
||||
enkel_toegestaan=bool(modus and modus['Waarde'] == '1'), modus=modus,
|
||||
lokaal=ipb.is_lokaal(request.remote_addr),
|
||||
modus_geweigerd=int(teller['Waarde']) if teller and str(teller['Waarde']).isdigit() else 0)
|
||||
|
||||
|
||||
def _overzicht_gebruiker(rijen):
|
||||
"""Gewone gebruiker: enkel de toegestane adressen van de actieve school."""
|
||||
school = huidige_school()
|
||||
eigen_school = [r for r in rijen if r['Soort'] == 'toestaan' and school.code in ipb.scholen_codes(r.get('Scholen'))]
|
||||
import ipaddress
|
||||
for r in eigen_school:
|
||||
try:
|
||||
net = ipaddress.ip_network(r['Reeks'], strict=False)
|
||||
r['Sorteer'] = f"{net.version}-{int(net.network_address):040d}-{net.prefixlen:03d}"
|
||||
except ValueError:
|
||||
r['Sorteer'] = r['Reeks']
|
||||
r['Eigen'] = _eigen(r)
|
||||
return render_template("ipblokkade_school.html", rijen=eigen_school, school_naam=school.naam,
|
||||
mijn_ip=request.remote_addr, lokaal=ipb.is_lokaal(request.remote_addr),
|
||||
naam=session.get('naam') or session.get('user'))
|
||||
|
||||
|
||||
@ipblokkade_bp.route("/modus", methods=["POST"])
|
||||
@login_required
|
||||
@admin_vereist
|
||||
def modus():
|
||||
"""Schakelaar: enkel nog adressen uit "Altijd toestaan" binnenlaten."""
|
||||
ipb.ensure_table()
|
||||
aan = request.form.get('aan') == '1'
|
||||
if aan:
|
||||
mijn = request.remote_addr
|
||||
if ipb.is_lokaal(mijn):
|
||||
flash(f"PIVOT ziet jouw adres als {mijn}. Dan draait PIVOT achter een reverse proxy (of test je lokaal) en "
|
||||
"lijkt elke bezoeker van dat adres te komen: de witte lijst zou dan niets tegenhouden of iedereen "
|
||||
"buitensluiten. Zet eerst in config.ini › [algemeen] proxy_aantal = 1 en herstart PIVOT; bij "
|
||||
"'Jouw IP-adres' moet je echte adres verschijnen.", "error")
|
||||
return redirect(url_for('ipblokkade.overzicht'))
|
||||
lijst = ipb.regels(vers=True)
|
||||
if not any(sz == 'toestaan' for _, sz, _ in lijst):
|
||||
flash("Voeg eerst minstens één adres of reeks toe bij 'Altijd toestaan'.", "error")
|
||||
return redirect(url_for('ipblokkade.overzicht'))
|
||||
if ipb.beoordeel(mijn, lijst, enkel_toe=True)[0]:
|
||||
flash(f"Jouw eigen adres ({mijn}) staat niet bij 'Altijd toestaan': dan sluit je jezelf buiten. "
|
||||
"Voeg het eerst toe.", "error")
|
||||
return redirect(url_for('ipblokkade.overzicht'))
|
||||
ipb.zet_enkel_toegestaan(aan, session.get('user'))
|
||||
flash("Enkel nog adressen uit 'Altijd toestaan' worden binnengelaten." if aan
|
||||
else "Iedereen wordt weer binnengelaten, behalve de geblokkeerde adressen.", "success" if not aan else "warning")
|
||||
return redirect(url_for('ipblokkade.overzicht'))
|
||||
|
||||
|
||||
@ipblokkade_bp.route("/toevoegen", methods=["POST"])
|
||||
@login_required
|
||||
@admin_of_whitelist
|
||||
def toevoegen():
|
||||
if not is_admin():
|
||||
return _toevoegen_gebruiker()
|
||||
soort = 'toestaan' if request.form.get('soort') == 'toestaan' else 'blokkeren'
|
||||
try:
|
||||
net = ipb.normaliseer(request.form.get('reeks'))
|
||||
except ipb.OngeldigeReeks as e:
|
||||
flash(str(e), "error")
|
||||
return redirect(url_for('ipblokkade.overzicht'))
|
||||
ipb.ensure_table()
|
||||
if soort == 'blokkeren':
|
||||
# Niet jezelf buitensluiten: weigeren als je eigen adres erin valt (tenzij het uitdrukkelijk toegestaan is)
|
||||
lijst = ipb.regels(vers=True) + [(0, 'blokkeren', net)]
|
||||
if ipb.beoordeel(request.remote_addr, lijst)[0]:
|
||||
flash(f"Deze reeks bevat je eigen IP-adres ({request.remote_addr}): dan sluit je jezelf buiten. "
|
||||
"Voeg eerst je eigen adres toe als uitzondering (toestaan), of kies een kleinere reeks.", "error")
|
||||
return redirect(url_for('ipblokkade.overzicht'))
|
||||
bestaat = central_query("SELECT ID FROM IP_blokkades WHERE Reeks = %s AND Soort = %s", (str(net), soort))
|
||||
if bestaat:
|
||||
flash(f"{net} staat al in de lijst.", "warning")
|
||||
return redirect(url_for('ipblokkade.overzicht'))
|
||||
# Bij "Altijd toestaan" kan je aanduiden bij welke school/scholen het adres hoort (enkel ter info in de tabel)
|
||||
scholen = _gekozen_scholen() if soort == 'toestaan' else None
|
||||
central_query("INSERT INTO IP_blokkades (Reeks, Soort, Opmerking, Scholen, Aangemaakt, Door, Gebruiker_ID) "
|
||||
"VALUES (%s, %s, %s, %s, NOW(), %s, %s)",
|
||||
(str(net), soort, (request.form.get('opmerking') or '').strip()[:255] or None,
|
||||
scholen, session.get('user'), session.get('gebruiker_id')),
|
||||
commit=True)
|
||||
ipb.vergeet_cache()
|
||||
aantal = net.num_addresses
|
||||
getal = f"{aantal:,}".replace(',', '.')
|
||||
flash(f"{net} toegevoegd ({'geblokkeerd' if soort == 'blokkeren' else 'altijd toegestaan'}"
|
||||
f"{', ' + ', '.join(_schoolnaam(c) for c in ipb.scholen_codes(scholen)) if scholen else ''}, {getal} adres{'sen' if aantal != 1 else ''}).", "success")
|
||||
return redirect(url_for('ipblokkade.overzicht'))
|
||||
|
||||
|
||||
def _toevoegen_gebruiker():
|
||||
"""Gewone gebruiker: enkel 'Altijd toestaan', school = actieve school, opmerking = zijn naam."""
|
||||
terug = redirect(url_for('ipblokkade.overzicht'))
|
||||
try:
|
||||
net = ipb.normaliseer(request.form.get('reeks'))
|
||||
except ipb.OngeldigeReeks as e:
|
||||
flash(str(e), "error")
|
||||
return terug
|
||||
grens = MAX_PREFIX_GEBRUIKER[net.version]
|
||||
if net.prefixlen < grens:
|
||||
flash(f"{net} is een te grote reeks. Je kan één adres toevoegen of hoogstens een /{grens}-reeks "
|
||||
f"(bv. {'85.12.3.' if net.version == 4 else 'een /64'}). Vraag een administrator voor een grotere reeks.", "error")
|
||||
return terug
|
||||
ipb.ensure_table()
|
||||
school = huidige_school()
|
||||
naam = (session.get('naam') or session.get('user') or '').strip()[:255] or None
|
||||
bestaat = central_query("SELECT ID, Scholen FROM IP_blokkades WHERE Reeks = %s AND Soort = 'toestaan'", (str(net),))
|
||||
if bestaat:
|
||||
codes = ipb.scholen_codes(bestaat[0]['Scholen'])
|
||||
if school.code in codes:
|
||||
flash(f"{net} staat al in de lijst van {school.naam}.", "warning")
|
||||
else:
|
||||
# Al toegestaan voor een andere school: deze school erbij zetten (de regel blijft van wie hem maakte)
|
||||
central_query("UPDATE IP_blokkades SET Scholen = %s WHERE ID = %s",
|
||||
(ipb.scholen_waarde(codes + [school.code]), bestaat[0]['ID']), commit=True)
|
||||
flash(f"{net} was al toegestaan voor een andere school en staat nu ook bij {school.naam}.", "success")
|
||||
return terug
|
||||
central_query("INSERT INTO IP_blokkades (Reeks, Soort, Opmerking, Scholen, Aangemaakt, Door, Gebruiker_ID) "
|
||||
"VALUES (%s, 'toestaan', %s, %s, NOW(), %s, %s)",
|
||||
(str(net), naam, school.code, session.get('user'), session.get('gebruiker_id')), commit=True)
|
||||
ipb.vergeet_cache()
|
||||
log(f"IP toegestaan voor {school.code}: {net}")
|
||||
flash(f"{net} toegevoegd aan de toegestane adressen van {school.naam}.", "success")
|
||||
return terug
|
||||
|
||||
|
||||
@ipblokkade_bp.route("/leegmaken", methods=["POST"])
|
||||
@login_required
|
||||
@admin_vereist
|
||||
def leegmaken():
|
||||
"""Verwijdert alle 'blokkeren'-regels. 'Altijd toestaan' en de schakelaar blijven ongemoeid."""
|
||||
ipb.ensure_table()
|
||||
rij = central_query("SELECT COUNT(*) AS n FROM IP_blokkades WHERE Soort = 'blokkeren'")
|
||||
aantal = int(rij[0]['n']) if rij else 0
|
||||
if not aantal:
|
||||
flash("De bloklijst is al leeg.", "warning")
|
||||
return redirect(url_for('ipblokkade.overzicht'))
|
||||
central_query("DELETE FROM IP_blokkades WHERE Soort = 'blokkeren'", commit=True)
|
||||
ipb.vergeet_cache()
|
||||
try:
|
||||
from rechten import log
|
||||
log(f"Bloklijst IP-blokkades leeggemaakt ({aantal} regels)")
|
||||
except Exception:
|
||||
pass
|
||||
flash(f"Bloklijst leeggemaakt: {aantal} regel{'s' if aantal != 1 else ''} verwijderd.", "success")
|
||||
return redirect(url_for('ipblokkade.overzicht'))
|
||||
|
||||
|
||||
@ipblokkade_bp.route("/<int:regel_id>/bewerken", methods=["POST"])
|
||||
@login_required
|
||||
@admin_vereist
|
||||
def bewerken(regel_id):
|
||||
"""Reeks, scholen en opmerking van een bestaande regel aanpassen (de soort blijft dezelfde)."""
|
||||
terug = redirect(url_for('ipblokkade.overzicht'))
|
||||
ipb.ensure_table()
|
||||
rij = central_query("SELECT ID, Soort FROM IP_blokkades WHERE ID = %s", (regel_id,))
|
||||
if not rij:
|
||||
flash("Deze regel bestaat niet meer.", "error")
|
||||
return terug
|
||||
soort = rij[0]['Soort']
|
||||
try:
|
||||
net = ipb.normaliseer(request.form.get('reeks'))
|
||||
except ipb.OngeldigeReeks as e:
|
||||
flash(str(e), "error")
|
||||
return terug
|
||||
if central_query("SELECT ID FROM IP_blokkades WHERE Reeks = %s AND Soort = %s AND ID <> %s", (str(net), soort, regel_id)):
|
||||
flash(f"{net} staat al in de lijst.", "warning")
|
||||
return terug
|
||||
# Niet jezelf buitensluiten: met de aangepaste regel moet je eigen adres nog binnen mogen
|
||||
mijn = request.remote_addr
|
||||
nieuw = [r for r in ipb.regels(vers=True) if r[0] != regel_id] + [(regel_id, soort, net)]
|
||||
if ipb.beoordeel(mijn, nieuw, enkel_toe=ipb.enkel_toegestaan())[0] and not ipb.beoordeel(mijn, ipb.regels())[0]:
|
||||
flash(f"Met deze aanpassing zou je eigen IP-adres ({mijn}) geweigerd worden: dan sluit je jezelf buiten.", "error")
|
||||
return terug
|
||||
central_query("UPDATE IP_blokkades SET Reeks = %s, Opmerking = %s, Scholen = %s WHERE ID = %s",
|
||||
(str(net), (request.form.get('opmerking') or '').strip()[:255] or None,
|
||||
_gekozen_scholen() if soort == 'toestaan' else None, regel_id), commit=True)
|
||||
ipb.vergeet_cache()
|
||||
flash(f"{net} bijgewerkt.", "success")
|
||||
return terug
|
||||
|
||||
|
||||
@ipblokkade_bp.route("/<int:regel_id>/verwijderen", methods=["POST"])
|
||||
@login_required
|
||||
@admin_of_whitelist
|
||||
def verwijderen(regel_id):
|
||||
if not is_admin():
|
||||
rij = central_query("SELECT ID, Soort, Gebruiker_ID FROM IP_blokkades WHERE ID = %s", (regel_id,))
|
||||
if not rij or not _eigen(rij[0]):
|
||||
flash("Je kan enkel adressen verwijderen die je zelf toegevoegd hebt.", "error")
|
||||
return redirect(url_for('ipblokkade.overzicht'))
|
||||
# Staat de witte-lijstmodus aan, dan mag je de regel die jou zelf binnenlaat niet verwijderen
|
||||
if ipb.enkel_toegestaan(vers=True):
|
||||
rest = [r for r in ipb.regels(vers=True) if r[0] != regel_id]
|
||||
if ipb.beoordeel(request.remote_addr, rest, enkel_toe=True)[0]:
|
||||
flash("Deze regel laat jou zelf binnen terwijl enkel 'Altijd toestaan' geldt: verwijderen zou je "
|
||||
"buitensluiten. Zet eerst de schakelaar uit of voeg je adres apart toe.", "error")
|
||||
return redirect(url_for('ipblokkade.overzicht'))
|
||||
central_query("DELETE FROM IP_blokkades WHERE ID = %s", (regel_id,), commit=True)
|
||||
ipb.vergeet_cache()
|
||||
flash("Regel verwijderd.", "success")
|
||||
return redirect(url_for('ipblokkade.overzicht'))
|
||||
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Scholengroep: leerlingenaantallen van alle scholen in PIVOT.
|
||||
|
||||
Elke school wordt geteld met haar EIGEN Informat-koppeling (instellingsnummer,
|
||||
client-ID en secret uit het scholenregister). Het resultaat wordt bewaard in de
|
||||
centrale database, zodat elke school het overzicht van de scholengroep ziet.
|
||||
|
||||
- Huidig aantal: peildatum vandaag + 1 maand (kolommen in Scholen).
|
||||
- Telling op peildatum: een gekozen datum (bv. 1 februari), bewaard in Leerlingenaantal_tellingen,
|
||||
zodat je ze later opnieuw kan bekijken en exporteren.
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
|
||||
from flask import Blueprint, render_template, request, flash, redirect, url_for, session, Response
|
||||
from blueprints.auth import login_required
|
||||
from database import central_query
|
||||
from rechten import recht_vereist
|
||||
from services.sync_service import SyncService
|
||||
from tenancy import alle_scholen, school_op_code, in_school, herlaad, ONDERWIJSNIVEAUS
|
||||
|
||||
leerlingenaantal_bp = Blueprint('leerlingenaantal', __name__)
|
||||
|
||||
INFORMAT_VELDEN = ('INFORMAT_INST_NUMBER', 'INFORMAT_ID', 'INFORMAT_SECRET')
|
||||
|
||||
# Teksten en routes voor het gedeelde sjabloon leerlingenaantal.html (ook gebruikt door de personeelsaantallen)
|
||||
WEERGAVE = dict(ep='leerlingenaantal', pagina='leerlingenaantal_page', titel='Leerlingenaantallen',
|
||||
eenheid='Leerlingen', kort='lln.')
|
||||
|
||||
|
||||
def _datum(waarde):
|
||||
try:
|
||||
return datetime.strptime((waarde or '').strip(), '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _fmt(dt, leeg=''):
|
||||
if not dt:
|
||||
return leeg
|
||||
return dt.strftime('%d-%m-%Y %H:%M') if hasattr(dt, 'strftime') else str(dt)
|
||||
|
||||
|
||||
def _detail(tekst):
|
||||
try:
|
||||
return json.loads(tekst) if tekst else []
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
|
||||
NIVEAUS = list(ONDERWIJSNIVEAUS) + [('', 'Onderwijsniveau niet ingesteld')]
|
||||
NIVEAU_LABEL = dict(NIVEAUS)
|
||||
|
||||
|
||||
def _niveau(school):
|
||||
n = school.waarden.get('ONDERWIJSNIVEAU') or ''
|
||||
return n if n in NIVEAU_LABEL else ''
|
||||
|
||||
|
||||
def _som(rijen, veld):
|
||||
return sum(r[veld] for r in rijen if isinstance(r[veld], int))
|
||||
|
||||
|
||||
def _groepen(rows, veld):
|
||||
"""Rijen per onderwijsniveau (basis, secundair, niet ingesteld) met subtotaal; lege groepen vallen weg."""
|
||||
uit = []
|
||||
for code, label in NIVEAUS:
|
||||
rijen = [r for r in rows if r['Niveau'] == code]
|
||||
if rijen:
|
||||
uit.append({'code': code, 'label': label, 'rijen': rijen, 'subtotaal': _som(rijen, veld)})
|
||||
return uit
|
||||
|
||||
|
||||
def _huidig(query=''):
|
||||
rows = []
|
||||
for s in alle_scholen():
|
||||
if query and query.lower() not in s.naam.lower():
|
||||
continue
|
||||
rows.append({
|
||||
'Code': s.code,
|
||||
'Naam_school': s.naam,
|
||||
'Niveau': _niveau(s),
|
||||
'Leerlingenaantal': s.leerlingenaantal if s.leerlingenaantal is not None else '–',
|
||||
'Huidig': s.leerlingenaantal if s.leerlingenaantal is not None else '–',
|
||||
'Laatste_synchronisatie': s.leerlingenaantal_sync,
|
||||
'Informat': s.heeft(*INFORMAT_VELDEN),
|
||||
'Campussen': s.leerlingenaantal_detail or [], # [{'naam', 'nummer', 'aantal'}] bij meerdere campussen
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _telling(peildatum, query=''):
|
||||
"""Alle actieve scholen met hun aantal op de peildatum (None = niet geteld)."""
|
||||
per = {r['School_ID']: r for r in central_query(
|
||||
"SELECT School_ID, Aantal, Detail, Schooljaar, Opgevraagd_op, Door FROM Leerlingenaantal_tellingen "
|
||||
"WHERE Peildatum = %s", (peildatum,))}
|
||||
rows = []
|
||||
for s in alle_scholen():
|
||||
if query and query.lower() not in s.naam.lower():
|
||||
continue
|
||||
t = per.get(s.id) or {}
|
||||
rows.append({'Code': s.code, 'Naam_school': s.naam, 'Niveau': _niveau(s), 'Aantal': t.get('Aantal'),
|
||||
'Schooljaar': t.get('Schooljaar'), 'Opgevraagd_op': t.get('Opgevraagd_op'),
|
||||
'Door': t.get('Door'), 'Informat': s.heeft(*INFORMAT_VELDEN),
|
||||
'Campussen': _detail(t.get('Detail'))})
|
||||
return rows
|
||||
|
||||
|
||||
def _peildata():
|
||||
return central_query("SELECT Peildatum, COUNT(*) AS Scholen, SUM(Aantal) AS Totaal, MAX(Opgevraagd_op) AS Laatst "
|
||||
"FROM Leerlingenaantal_tellingen GROUP BY Peildatum ORDER BY Peildatum DESC")
|
||||
|
||||
|
||||
@leerlingenaantal_bp.route("/leerlingenaantal", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@recht_vereist('leerlingenaantal')
|
||||
def leerlingenaantal_page():
|
||||
query = (request.values.get("query") or "").strip()
|
||||
herlaad(force=True)
|
||||
results = _huidig(query)
|
||||
totaal = _som(results, 'Leerlingenaantal')
|
||||
|
||||
peildatum = _datum(request.args.get('peildatum'))
|
||||
telling = _telling(peildatum, query) if peildatum else None
|
||||
telling_totaal = _som(telling, 'Aantal') if telling else 0
|
||||
return render_template("leerlingenaantal.html", results=results, query=query, totaal=totaal,
|
||||
groepen=_groepen(results, 'Leerlingenaantal'),
|
||||
telling_groepen=_groepen(telling, 'Aantal') if telling else [],
|
||||
peildatum=peildatum, telling=telling, telling_totaal=telling_totaal,
|
||||
peildata=_peildata(), vandaag=date.today(), **WEERGAVE)
|
||||
|
||||
|
||||
@leerlingenaantal_bp.route("/sync-aanvragen", methods=["POST"])
|
||||
@login_required
|
||||
@recht_vereist('leerlingenaantal')
|
||||
def sync_aanvragen():
|
||||
codes = [s.code for s in alle_scholen()] if request.form.get("alle") else [request.form.get("school_code")]
|
||||
for code in codes:
|
||||
school = school_op_code(code)
|
||||
if not school:
|
||||
continue
|
||||
if not school.heeft(*INFORMAT_VELDEN):
|
||||
flash(f"{school.naam}: Informat is nog niet ingesteld.", "error")
|
||||
continue
|
||||
with in_school(school):
|
||||
success, message = SyncService().sync_student_count()
|
||||
flash(f"{school.naam}: {message}", "success" if success else "error")
|
||||
return redirect(url_for('leerlingenaantal.leerlingenaantal_page'))
|
||||
|
||||
|
||||
@leerlingenaantal_bp.route("/leerlingenaantal/peildatum", methods=["POST"])
|
||||
@login_required
|
||||
@recht_vereist('leerlingenaantal')
|
||||
def tel_peildatum():
|
||||
"""Telt de leerlingen op de gekozen datum, voor alle scholen of één school."""
|
||||
peildatum = _datum(request.form.get('peildatum'))
|
||||
if not peildatum:
|
||||
flash("Kies een geldige datum.", "error")
|
||||
return redirect(url_for('leerlingenaantal.leerlingenaantal_page'))
|
||||
code = request.form.get('school_code')
|
||||
scholen = [school_op_code(code)] if code else alle_scholen()
|
||||
door = session.get('user')
|
||||
gelukt = 0
|
||||
for school in scholen:
|
||||
if not school:
|
||||
continue
|
||||
if not school.heeft(*INFORMAT_VELDEN):
|
||||
if code:
|
||||
flash(f"{school.naam}: Informat is nog niet ingesteld.", "error")
|
||||
continue
|
||||
with in_school(school):
|
||||
ok, res = SyncService().tel_op_peildatum(peildatum, door)
|
||||
if ok:
|
||||
gelukt += 1
|
||||
else:
|
||||
flash(f"{school.naam}: {res}", "error")
|
||||
if gelukt:
|
||||
flash(f"Leerlingen geteld op {peildatum:%d-%m-%Y} voor {gelukt} school/scholen.", "success")
|
||||
return redirect(url_for('leerlingenaantal.leerlingenaantal_page', peildatum=peildatum.isoformat()))
|
||||
|
||||
|
||||
@leerlingenaantal_bp.route("/leerlingenaantal/peildatum/verwijderen", methods=["POST"])
|
||||
@login_required
|
||||
@recht_vereist('leerlingenaantal')
|
||||
def verwijder_peildatum():
|
||||
peildatum = _datum(request.form.get('peildatum'))
|
||||
if peildatum:
|
||||
central_query("DELETE FROM Leerlingenaantal_tellingen WHERE Peildatum = %s", (peildatum,), commit=True)
|
||||
flash(f"Telling van {peildatum:%d-%m-%Y} verwijderd.", "success")
|
||||
return redirect(url_for('leerlingenaantal.leerlingenaantal_page'))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CSV
|
||||
# ------------------------------------------------------------------
|
||||
def _csv(naam, kop, rijen):
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf, delimiter=';') # ; zodat Excel (Belgische instellingen) de kolommen meteen splitst
|
||||
w.writerow(kop)
|
||||
w.writerows(rijen)
|
||||
return Response('' + buf.getvalue(), mimetype='text/csv',
|
||||
headers={'Content-Disposition': f'attachment; filename={naam}'})
|
||||
|
||||
|
||||
@leerlingenaantal_bp.route("/leerlingenaantal/export.csv", methods=["GET"])
|
||||
@login_required
|
||||
@recht_vereist('leerlingenaantal')
|
||||
def export_csv():
|
||||
"""?peildatum=YYYY-MM-DD → die telling; ?alle=1 → alle peildata naast elkaar; anders het huidige aantal."""
|
||||
query = (request.args.get('query') or '').strip()
|
||||
|
||||
if request.args.get('alle'):
|
||||
data = central_query("SELECT School_ID, Peildatum, Aantal, Detail FROM Leerlingenaantal_tellingen ORDER BY Peildatum")
|
||||
datums = sorted({r['Peildatum'] for r in data})
|
||||
per = {(r['School_ID'], r['Peildatum']): r['Aantal'] for r in data}
|
||||
# campus-aantallen: {(school_id, campusnaam, datum): aantal}
|
||||
campus, campusnamen = {}, {}
|
||||
for r in data:
|
||||
for d in _detail(r['Detail']):
|
||||
campus[(r['School_ID'], d['naam'], r['Peildatum'])] = d['aantal']
|
||||
campusnamen.setdefault(r['School_ID'], [])
|
||||
if d['naam'] not in campusnamen[r['School_ID']]:
|
||||
campusnamen[r['School_ID']].append(d['naam'])
|
||||
scholen = [s for s in alle_scholen() if not query or query.lower() in s.naam.lower()]
|
||||
rijen = []
|
||||
for code, label in NIVEAUS:
|
||||
groep = [s for s in scholen if _niveau(s) == code]
|
||||
if not groep:
|
||||
continue
|
||||
for s in groep:
|
||||
rijen.append([label, s.naam, ''] + [per.get((s.id, d), '') for d in datums])
|
||||
for naam in campusnamen.get(s.id, []):
|
||||
rijen.append([label, s.naam, naam] + [campus.get((s.id, naam, d), '') for d in datums])
|
||||
if code:
|
||||
rijen.append([f"Subtotaal {label}", '', ''] + [sum(per.get((s.id, d)) or 0 for s in groep) for d in datums])
|
||||
rijen.append(['Totaal', '', ''] + [sum(per.get((s.id, d)) or 0 for s in scholen) for d in datums])
|
||||
return _csv(f"leerlingenaantallen_alle_peildata_{date.today():%Y%m%d}.csv",
|
||||
['Onderwijsniveau', 'School', 'Campus'] + [f"{d:%d-%m-%Y}" for d in datums], rijen)
|
||||
|
||||
peildatum = _datum(request.args.get('peildatum'))
|
||||
if peildatum:
|
||||
rows = _telling(peildatum, query)
|
||||
rijen = []
|
||||
for g in _groepen(rows, 'Aantal'):
|
||||
for r in g['rijen']:
|
||||
rijen.append([g['label'], r['Naam_school'], '', '' if r['Aantal'] is None else r['Aantal'],
|
||||
r['Schooljaar'] or '', _fmt(r['Opgevraagd_op'], 'niet geteld'), r['Door'] or ''])
|
||||
for c in r['Campussen']:
|
||||
rijen.append([g['label'], r['Naam_school'], c['naam'], c['aantal'], r['Schooljaar'] or '', '', ''])
|
||||
if g['code']:
|
||||
rijen.append([f"Subtotaal {g['label']}", '', '', g['subtotaal'], '', '', ''])
|
||||
rijen.append(['Totaal', '', '', _som(rows, 'Aantal'), '', '', ''])
|
||||
return _csv(f"leerlingenaantallen_{peildatum:%Y%m%d}.csv",
|
||||
['Onderwijsniveau', 'School', 'Campus', f"Leerlingen op {peildatum:%d-%m-%Y}", 'Schooljaar',
|
||||
'Opgevraagd op', 'Door'], rijen)
|
||||
|
||||
rows = _huidig(query)
|
||||
rijen = []
|
||||
for g in _groepen(rows, 'Leerlingenaantal'):
|
||||
for r in g['rijen']:
|
||||
rijen.append([g['label'], r['Naam_school'], '',
|
||||
r['Leerlingenaantal'] if isinstance(r['Leerlingenaantal'], int) else '',
|
||||
_fmt(r['Laatste_synchronisatie'], 'nog nooit')])
|
||||
for c in r['Campussen']:
|
||||
rijen.append([g['label'], r['Naam_school'], c['naam'], c['aantal'], ''])
|
||||
if g['code']:
|
||||
rijen.append([f"Subtotaal {g['label']}", '', '', g['subtotaal'], ''])
|
||||
rijen.append(['Totaal', '', '', _som(rows, 'Leerlingenaantal'), ''])
|
||||
return _csv(f"leerlingenaantallen_{date.today():%Y%m%d}.csv",
|
||||
['Onderwijsniveau', 'School', 'Campus', 'Leerlingen', 'Laatste synchronisatie'], rijen)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
from flask import Blueprint, render_template, request, session, redirect, url_for, flash
|
||||
from services.google_service import GoogleAdminService
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist
|
||||
from tenancy import per_school
|
||||
|
||||
account_verplaatsen_bp = Blueprint('accounts_verplaatsen', __name__)
|
||||
google = per_school(GoogleAdminService)
|
||||
|
||||
@account_verplaatsen_bp.route('/beheer/verplaatsen', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@recht_vereist('nieuw_schooljaar')
|
||||
def accounts_verplaatsen():
|
||||
if request.method == 'POST':
|
||||
huidige_ou = request.form.get('huidige_ou')
|
||||
nieuwe_ou = request.form.get('nieuwe_ou')
|
||||
move_type = request.form.get('move_type') # 'current' of 'all'
|
||||
|
||||
# Als move_type 'current' is, zou je hier je database/Informat lijst ophalen
|
||||
enrolled_list = []
|
||||
if move_type == 'current':
|
||||
# Voorbeeld: enrolled_list = db.get_active_students_emails()
|
||||
pass
|
||||
|
||||
only_enrolled = (move_type == 'current')
|
||||
|
||||
success, message = google.move_users_ou(
|
||||
huidige_ou,
|
||||
nieuwe_ou,
|
||||
only_enrolled=only_enrolled,
|
||||
enrolled_emails=enrolled_list
|
||||
)
|
||||
|
||||
flash(message)
|
||||
return redirect(url_for('accounts_verplaatsen.accounts_verplaatsen'))
|
||||
|
||||
# Voor de GET: vul de dropdowns
|
||||
org_units = google.list_org_units()
|
||||
return render_template('nieuw_schooljaar_accounts.html', ous=org_units)
|
||||
|
||||
@account_verplaatsen_bp.route('/beheer/archiveren-inactief', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('nieuw_schooljaar')
|
||||
def archiveren_inactief():
|
||||
source_ou = request.form.get('bron_ou')
|
||||
days_inactive = int(request.form.get('inactief_periode'))
|
||||
|
||||
success, message = google.archive_inactive_users(source_ou, days_inactive)
|
||||
|
||||
flash(message, "success" if success else "danger")
|
||||
return redirect(url_for('accounts_verplaatsen.accounts_verplaatsen'))
|
||||
@@ -0,0 +1,39 @@
|
||||
from flask import Blueprint, render_template, request, flash, redirect, url_for
|
||||
from services.google_service import GoogleAdminService
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist
|
||||
|
||||
# We noemen de blueprint 'toestellen_verplaatsen'
|
||||
toestellen_verplaatsen_bp = Blueprint('toestellen_verplaatsen', __name__)
|
||||
|
||||
@toestellen_verplaatsen_bp.route('/toestellen', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@recht_vereist('nieuw_schooljaar')
|
||||
def move_toestellen():
|
||||
google_service = GoogleAdminService()
|
||||
org_units = google_service.list_org_units()
|
||||
|
||||
# Standaard is de verplaatsen tab actief
|
||||
return render_template('nieuw_schooljaar_toestellen.html',
|
||||
org_units=org_units,
|
||||
active_tab='verplaatsen')
|
||||
|
||||
@toestellen_verplaatsen_bp.route('/blokkeren', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('nieuw_schooljaar')
|
||||
def blokkeer_toestellen():
|
||||
google_service = GoogleAdminService()
|
||||
selected_ou = request.form.get('ou_select_blokkeer')
|
||||
actie = request.form.get('actie')
|
||||
|
||||
success, message = google_service.toggle_ou_devices_status(selected_ou, block=(actie == 'blokkeer'))
|
||||
|
||||
flash(message, "success" if success else "danger")
|
||||
|
||||
# Haal opnieuw de OU's op voor de template
|
||||
org_units = google_service.list_org_units()
|
||||
|
||||
# Cruciaal: we geven 'blokkeren' mee als actieve tab
|
||||
return render_template('nieuw_schooljaar_toestellen.html',
|
||||
org_units=org_units,
|
||||
active_tab='blokkeren')
|
||||
@@ -0,0 +1,318 @@
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, abort
|
||||
from tenancy import huidige_school, per_school
|
||||
from blueprints.auth import login_required
|
||||
from services import personeel_service as P
|
||||
|
||||
personeel_bp = Blueprint('personeel', __name__)
|
||||
|
||||
_tables_ready = set() # scholen waarvoor de tabellen al gecontroleerd zijn
|
||||
|
||||
|
||||
def _ensure_tables():
|
||||
if huidige_school().code not in _tables_ready:
|
||||
try:
|
||||
P.ensure_tables()
|
||||
_tables_ready.add(huidige_school().code)
|
||||
except Exception as e:
|
||||
flash(f"Kon de personeelstabellen niet aanmaken: {e}", "error")
|
||||
|
||||
|
||||
def _me():
|
||||
return (session.get('user') or '').lower(), session.get('naam') or session.get('user')
|
||||
|
||||
|
||||
def rol_vereist(rol):
|
||||
def deco(f):
|
||||
@wraps(f)
|
||||
def wrapped(*a, **kw):
|
||||
if rol not in P.rollen(session):
|
||||
flash("Je hebt geen toegang tot deze pagina.", "error")
|
||||
return redirect(url_for('personeel.mijn'))
|
||||
return f(*a, **kw)
|
||||
return wrapped
|
||||
return deco
|
||||
|
||||
|
||||
@personeel_bp.context_processor
|
||||
def _ctx():
|
||||
r = P.rollen(session) if session.get('auth') else set()
|
||||
return {'p_rollen': r, 'SOORTEN': P.SOORTEN, 'STATUS': P.STATUS, 'DAGDELEN': P.DAGDELEN,
|
||||
'p_te_beoordelen': P.aantal_te_beoordelen() if 'directie' in r else 0}
|
||||
|
||||
|
||||
def _link(aid):
|
||||
return url_for('personeel.detail', aid=aid, _external=True)
|
||||
|
||||
|
||||
def _mag_zien(item):
|
||||
me, _ = _me()
|
||||
r = P.rollen(session)
|
||||
return (item['Email'] == me or 'secretariaat' in r
|
||||
or ('directie' in r and P.SOORTEN[item['Soort']]['goedkeuring']))
|
||||
|
||||
|
||||
def _laad(aid):
|
||||
item = P.get(aid)
|
||||
if not item:
|
||||
abort(404)
|
||||
if not _mag_zien(item):
|
||||
flash("Je hebt geen toegang tot deze aanvraag.", "error")
|
||||
return None
|
||||
return item
|
||||
|
||||
|
||||
#---------------------------------------------
|
||||
# Mijn meldingen en aanvragen
|
||||
#---------------------------------------------
|
||||
@personeel_bp.route('/', methods=['GET'])
|
||||
@login_required
|
||||
def mijn():
|
||||
_ensure_tables()
|
||||
me, _ = _me()
|
||||
try:
|
||||
items = P.mijn(me)
|
||||
except Exception as e:
|
||||
items = []
|
||||
flash(f"Kon je aanvragen niet ophalen: {e}", "error")
|
||||
return render_template('personeel_mijn.html', items=items, vandaag=date.today())
|
||||
|
||||
|
||||
#---------------------------------------------
|
||||
# Nieuwe melding / aanvraag
|
||||
#---------------------------------------------
|
||||
@personeel_bp.route('/nieuw', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def nieuw():
|
||||
_ensure_tables()
|
||||
soort = request.values.get('soort', 'afwezigheid')
|
||||
if soort not in P.SOORTEN:
|
||||
soort = 'afwezigheid'
|
||||
me, mijn_naam = _me()
|
||||
is_secr = 'secretariaat' in P.rollen(session)
|
||||
form = request.form
|
||||
|
||||
if request.method == 'POST':
|
||||
fouten = []
|
||||
try:
|
||||
van = datetime.strptime(form.get('van', ''), '%Y-%m-%d').date()
|
||||
tot = datetime.strptime(form.get('tot') or form.get('van', ''), '%Y-%m-%d').date()
|
||||
if tot < van:
|
||||
fouten.append("De einddatum ligt voor de begindatum.")
|
||||
except ValueError:
|
||||
van = tot = None
|
||||
fouten.append("Kies een geldige datum.")
|
||||
|
||||
titel = (form.get('titel') or '').strip()
|
||||
if soort == 'afwezigheid' and not titel:
|
||||
titel = 'Afwezig'
|
||||
if not titel:
|
||||
fouten.append("Geef een titel op.")
|
||||
|
||||
dagdeel = form.get('dagdeel', 'dag')
|
||||
if dagdeel not in P.DAGDELEN:
|
||||
dagdeel = 'dag'
|
||||
lesuren = (form.get('lesuren') or '').strip()
|
||||
if dagdeel == 'uren' and not lesuren:
|
||||
fouten.append("Vul in welke lesuren het gaat (bv. 3-5).")
|
||||
|
||||
klassen = form.getlist('klassen')
|
||||
extra = [k.strip() for k in (form.get('klassen_extra') or '').split(',') if k.strip()]
|
||||
klassen = list(dict.fromkeys(klassen + extra))
|
||||
if soort == 'uitstap' and not klassen:
|
||||
fouten.append("Kies welke klassen mee op uitstap gaan.")
|
||||
|
||||
kost = None
|
||||
if (form.get('kost') or '').strip():
|
||||
try:
|
||||
kost = Decimal(form['kost'].replace(',', '.').replace('€', '').strip())
|
||||
except InvalidOperation:
|
||||
fouten.append("De kost is geen geldig bedrag.")
|
||||
|
||||
# Secretariaat kan melden namens een leerkracht
|
||||
email, naam = me, mijn_naam
|
||||
namens = (form.get('namens') or '').strip().lower()
|
||||
if namens and namens != me:
|
||||
if not is_secr:
|
||||
fouten.append("Je kan enkel voor jezelf een melding maken.")
|
||||
else:
|
||||
email, naam = namens, namens
|
||||
try:
|
||||
from services.google_service import GoogleAdminService
|
||||
u = GoogleAdminService().get_user_info(namens)
|
||||
if not u:
|
||||
fouten.append(f"{namens} bestaat niet in Google Workspace.")
|
||||
else:
|
||||
email = u['primaryEmail'].lower()
|
||||
naam = (u.get('name') or {}).get('fullName') or email
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if fouten:
|
||||
for f in fouten:
|
||||
flash(f, "error")
|
||||
else:
|
||||
data = dict(soort=soort, email=email, naam=naam, titel=titel[:255],
|
||||
omschrijving=(form.get('omschrijving') or '').strip() or None,
|
||||
locatie=(form.get('locatie') or '').strip()[:255] or None,
|
||||
van=van, tot=tot, dagdeel=dagdeel, lesuren=lesuren[:50] or None,
|
||||
klassen=klassen, begeleiders=(form.get('begeleiders') or '').strip() or None, kost=kost)
|
||||
aid = P.aanmaken(data, me, mijn_naam)
|
||||
P.mail_nieuw(P.get(aid), _link(aid))
|
||||
if P.SOORTEN[soort]['goedkeuring']:
|
||||
flash("Je aanvraag is ingediend. De directie krijgt een melding; je ontvangt een mail zodra er een beslissing is.", "success")
|
||||
else:
|
||||
flash("De afwezigheid is gemeld aan het secretariaat.", "success")
|
||||
return redirect(url_for('personeel.detail', aid=aid))
|
||||
|
||||
return render_template('personeel_nieuw.html', soort=soort, form=form, klassen=P.klassen(),
|
||||
is_secr=is_secr, vandaag=date.today())
|
||||
|
||||
|
||||
#---------------------------------------------
|
||||
# Detail + acties
|
||||
#---------------------------------------------
|
||||
@personeel_bp.route('/<int:aid>', methods=['GET'])
|
||||
@login_required
|
||||
def detail(aid):
|
||||
_ensure_tables()
|
||||
item = _laad(aid)
|
||||
if item is None:
|
||||
return redirect(url_for('personeel.mijn'))
|
||||
me, _ = _me()
|
||||
return render_template('personeel_detail.html', item=item, reacties=P.reacties(aid),
|
||||
is_eigenaar=item['Email'] == me, vandaag=date.today())
|
||||
|
||||
|
||||
@personeel_bp.route('/<int:aid>/beslissing', methods=['POST'])
|
||||
@login_required
|
||||
@rol_vereist('directie')
|
||||
def beslissing(aid):
|
||||
item = _laad(aid)
|
||||
if item is None:
|
||||
return redirect(url_for('personeel.mijn'))
|
||||
actie = request.form.get('actie')
|
||||
tekst = (request.form.get('tekst') or '').strip()
|
||||
if actie not in ('goedgekeurd', 'geweigerd', 'vraag'):
|
||||
abort(400)
|
||||
if not P.SOORTEN[item['Soort']]['goedkeuring'] or item['Status'] not in ('ingediend', 'vraag'):
|
||||
flash("Over deze aanvraag kan geen beslissing (meer) genomen worden.", "error")
|
||||
return redirect(url_for('personeel.detail', aid=aid))
|
||||
if actie in ('geweigerd', 'vraag') and not tekst:
|
||||
flash("Geef wat uitleg mee bij een weigering of vraag.", "error")
|
||||
return redirect(url_for('personeel.detail', aid=aid))
|
||||
me, naam = _me()
|
||||
P.wijzig_status(aid, actie, me, naam, actie, tekst or None)
|
||||
P.mail_beslissing(P.get(aid), actie, tekst, _link(aid))
|
||||
flash({'goedgekeurd': "Aanvraag goedgekeurd.", 'geweigerd': "Aanvraag geweigerd.",
|
||||
'vraag': "Je vraag is naar de leerkracht gestuurd."}[actie], "success")
|
||||
nxt = request.form.get('volgende')
|
||||
return redirect(url_for('personeel.beoordelen') if nxt else url_for('personeel.detail', aid=aid))
|
||||
|
||||
|
||||
@personeel_bp.route('/<int:aid>/antwoord', methods=['POST'])
|
||||
@login_required
|
||||
def antwoord(aid):
|
||||
item = _laad(aid)
|
||||
if item is None:
|
||||
return redirect(url_for('personeel.mijn'))
|
||||
me, naam = _me()
|
||||
tekst = (request.form.get('tekst') or '').strip()
|
||||
if item['Email'] != me or item['Status'] != 'vraag' or not tekst:
|
||||
flash("Antwoorden kan enkel op je eigen aanvraag met een openstaande vraag.", "error")
|
||||
return redirect(url_for('personeel.detail', aid=aid))
|
||||
P.wijzig_status(aid, 'ingediend', me, naam, 'antwoord', tekst)
|
||||
P.mail_antwoord(P.get(aid), tekst, _link(aid))
|
||||
flash("Je antwoord is verstuurd; de aanvraag wacht opnieuw op de directie.", "success")
|
||||
return redirect(url_for('personeel.detail', aid=aid))
|
||||
|
||||
|
||||
@personeel_bp.route('/<int:aid>/commentaar', methods=['POST'])
|
||||
@login_required
|
||||
def commentaar(aid):
|
||||
item = _laad(aid)
|
||||
if item is None:
|
||||
return redirect(url_for('personeel.mijn'))
|
||||
tekst = (request.form.get('tekst') or '').strip()
|
||||
if tekst:
|
||||
me, naam = _me()
|
||||
P.commentaar(aid, me, naam, tekst)
|
||||
return redirect(url_for('personeel.detail', aid=aid))
|
||||
|
||||
|
||||
@personeel_bp.route('/<int:aid>/annuleren', methods=['POST'])
|
||||
@login_required
|
||||
def annuleren(aid):
|
||||
item = _laad(aid)
|
||||
if item is None:
|
||||
return redirect(url_for('personeel.mijn'))
|
||||
me, naam = _me()
|
||||
if not (item['Email'] == me or 'secretariaat' in P.rollen(session)):
|
||||
flash("Je kan deze aanvraag niet annuleren.", "error")
|
||||
elif item['Status'] in ('geannuleerd', 'geweigerd'):
|
||||
flash("Deze aanvraag is al afgesloten.", "error")
|
||||
else:
|
||||
P.mail_geannuleerd(item, _link(aid))
|
||||
P.wijzig_status(aid, 'geannuleerd', me, naam, 'geannuleerd', (request.form.get('tekst') or '').strip() or None)
|
||||
flash("Geannuleerd.", "success")
|
||||
return redirect(url_for('personeel.detail', aid=aid))
|
||||
|
||||
|
||||
@personeel_bp.route('/<int:aid>/vervanging', methods=['POST'])
|
||||
@login_required
|
||||
@rol_vereist('secretariaat')
|
||||
def vervanging(aid):
|
||||
item = _laad(aid)
|
||||
if item is None:
|
||||
return redirect(url_for('personeel.mijn'))
|
||||
me, naam = _me()
|
||||
P.vervanging(aid, request.form.get('ok') == '1', request.form.get('info'), me, naam)
|
||||
terug = request.form.get('terug')
|
||||
return redirect(terug if terug and terug.startswith('/') else url_for('personeel.detail', aid=aid))
|
||||
|
||||
|
||||
#---------------------------------------------
|
||||
# Directie: te beoordelen
|
||||
#---------------------------------------------
|
||||
@personeel_bp.route('/beoordelen', methods=['GET'])
|
||||
@login_required
|
||||
@rol_vereist('directie')
|
||||
def beoordelen():
|
||||
_ensure_tables()
|
||||
return render_template('personeel_beoordelen.html', open_items=P.te_beoordelen(), recent=P.recent_beslist())
|
||||
|
||||
|
||||
#---------------------------------------------
|
||||
# Secretariaat: dagoverzicht
|
||||
#---------------------------------------------
|
||||
@personeel_bp.route('/dagoverzicht', methods=['GET'])
|
||||
@login_required
|
||||
@rol_vereist('secretariaat')
|
||||
def dagoverzicht():
|
||||
_ensure_tables()
|
||||
try:
|
||||
dag = datetime.strptime(request.args.get('datum', ''), '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
dag = date.today()
|
||||
weergave = request.args.get('weergave', 'dag')
|
||||
if weergave == 'week':
|
||||
van = dag - timedelta(days=dag.weekday())
|
||||
tot = van + timedelta(days=4)
|
||||
stap = timedelta(days=7)
|
||||
else:
|
||||
weergave = 'dag'
|
||||
if dag.weekday() >= 5: # weekend → volgende maandag
|
||||
dag = dag + timedelta(days=7 - dag.weekday())
|
||||
van = tot = dag
|
||||
stap = timedelta(days=1)
|
||||
vorige, volgende = van - stap, van + stap
|
||||
if weergave == 'dag':
|
||||
while vorige.weekday() >= 5:
|
||||
vorige -= timedelta(days=1)
|
||||
while volgende.weekday() >= 5:
|
||||
volgende += timedelta(days=1)
|
||||
return render_template('personeel_dagoverzicht.html', dagen=P.dagoverzicht(van, tot), weergave=weergave,
|
||||
dag=dag, van=van, tot=tot, vorige=vorige, volgende=volgende, vandaag=date.today())
|
||||
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Personeel › Personeelsaantallen: hetzelfde overzicht als de leerlingenaantallen, maar voor het personeel.
|
||||
|
||||
Elke school wordt geteld met haar eigen Informat-koppeling (Personeel-API, zie services/gopro_service.py):
|
||||
een personeelslid telt mee als het op de peildatum een lopende opdracht heeft (één keer per persoon).
|
||||
- Huidig aantal: vandaag, bewaard in Personeelsaantal_huidig.
|
||||
- Telling op peildatum: bewaard in Personeelsaantal_tellingen, later opnieuw te bekijken en te exporteren.
|
||||
Het sjabloon is gedeeld met de leerlingenaantallen (leerlingenaantal.html).
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
from flask import Blueprint, render_template, request, flash, redirect, url_for, session, Response
|
||||
from blueprints.auth import login_required
|
||||
from blueprints.leerlingenaantal_bp import (INFORMAT_VELDEN, NIVEAUS, _datum, _detail, _fmt, _groepen, _niveau,
|
||||
_som)
|
||||
from database import central_query
|
||||
from rechten import recht_vereist
|
||||
from services.gopro_service import GoProService
|
||||
from tenancy import alle_scholen, school_op_code, in_school, _automatisch_schooljaar
|
||||
|
||||
personeelsaantal_bp = Blueprint('personeelsaantal', __name__)
|
||||
|
||||
WEERGAVE = dict(ep='personeelsaantal', pagina='personeelsaantal_page', titel='Personeelsaantallen',
|
||||
eenheid='Personeelsleden', kort='pers.')
|
||||
|
||||
|
||||
def _huidig(query=''):
|
||||
per = {r['School_ID']: r for r in central_query(
|
||||
"SELECT School_ID, Aantal, Detail, Gesynchroniseerd FROM Personeelsaantal_huidig")}
|
||||
rows = []
|
||||
for s in alle_scholen():
|
||||
if query and query.lower() not in s.naam.lower():
|
||||
continue
|
||||
h = per.get(s.id) or {}
|
||||
rows.append({'Code': s.code, 'Naam_school': s.naam, 'Niveau': _niveau(s),
|
||||
'Huidig': h['Aantal'] if h else '–', 'Laatste_synchronisatie': h.get('Gesynchroniseerd'),
|
||||
'Informat': s.heeft(*INFORMAT_VELDEN), 'Campussen': _detail(h.get('Detail'))})
|
||||
return rows
|
||||
|
||||
|
||||
def _telling(peildatum, query=''):
|
||||
per = {r['School_ID']: r for r in central_query(
|
||||
"SELECT School_ID, Aantal, Detail, Schooljaar, Opgevraagd_op, Door FROM Personeelsaantal_tellingen "
|
||||
"WHERE Peildatum = %s", (peildatum,))}
|
||||
rows = []
|
||||
for s in alle_scholen():
|
||||
if query and query.lower() not in s.naam.lower():
|
||||
continue
|
||||
t = per.get(s.id) or {}
|
||||
rows.append({'Code': s.code, 'Naam_school': s.naam, 'Niveau': _niveau(s), 'Aantal': t.get('Aantal'),
|
||||
'Schooljaar': t.get('Schooljaar'), 'Opgevraagd_op': t.get('Opgevraagd_op'),
|
||||
'Door': t.get('Door'), 'Informat': s.heeft(*INFORMAT_VELDEN),
|
||||
'Campussen': _detail(t.get('Detail'))})
|
||||
return rows
|
||||
|
||||
|
||||
def _peildata():
|
||||
return central_query("SELECT Peildatum, COUNT(*) AS Scholen, SUM(Aantal) AS Totaal, MAX(Opgevraagd_op) AS Laatst "
|
||||
"FROM Personeelsaantal_tellingen GROUP BY Peildatum ORDER BY Peildatum DESC")
|
||||
|
||||
|
||||
def _tel(school, peildatum):
|
||||
"""(ok, aantal of foutmelding, detail) voor één school."""
|
||||
try:
|
||||
with in_school(school):
|
||||
totaal, detail = GoProService().tel(peildatum)
|
||||
return True, totaal, detail
|
||||
except Exception as e:
|
||||
return False, str(e), None
|
||||
|
||||
|
||||
@personeelsaantal_bp.route("/", methods=["GET"])
|
||||
@login_required
|
||||
@recht_vereist('personeelsaantal')
|
||||
def personeelsaantal_page():
|
||||
query = (request.values.get("query") or "").strip()
|
||||
results = _huidig(query)
|
||||
peildatum = _datum(request.args.get('peildatum'))
|
||||
telling = _telling(peildatum, query) if peildatum else None
|
||||
return render_template("leerlingenaantal.html", results=results, query=query, totaal=_som(results, 'Huidig'),
|
||||
groepen=_groepen(results, 'Huidig'),
|
||||
telling_groepen=_groepen(telling, 'Aantal') if telling else [],
|
||||
peildatum=peildatum, telling=telling,
|
||||
telling_totaal=_som(telling, 'Aantal') if telling else 0,
|
||||
peildata=_peildata(), vandaag=date.today(), **WEERGAVE)
|
||||
|
||||
|
||||
@personeelsaantal_bp.route("/sync-aanvragen", methods=["POST"])
|
||||
@login_required
|
||||
@recht_vereist('personeelsaantal')
|
||||
def sync_aanvragen():
|
||||
codes = [s.code for s in alle_scholen()] if request.form.get("alle") else [request.form.get("school_code")]
|
||||
for code in codes:
|
||||
school = school_op_code(code)
|
||||
if not school:
|
||||
continue
|
||||
if not school.heeft(*INFORMAT_VELDEN):
|
||||
if not request.form.get("alle"):
|
||||
flash(f"{school.naam}: Informat is nog niet ingesteld.", "error")
|
||||
continue
|
||||
ok, res, detail = _tel(school, date.today())
|
||||
if not ok:
|
||||
flash(f"{school.naam}: {res}", "error")
|
||||
continue
|
||||
central_query(
|
||||
"INSERT INTO Personeelsaantal_huidig (School_ID, Aantal, Detail, Gesynchroniseerd) VALUES (%s, %s, %s, NOW()) "
|
||||
"ON DUPLICATE KEY UPDATE Aantal = VALUES(Aantal), Detail = VALUES(Detail), Gesynchroniseerd = NOW()",
|
||||
(school.id, res, json.dumps(detail) if detail else None), commit=True)
|
||||
flash(f"{school.naam}: {res} personeelsleden" + (" (" + ", ".join(f"{d['naam']}: {d['aantal']}" for d in detail)
|
||||
+ ")" if detail else "") + ".", "success")
|
||||
return redirect(url_for('personeelsaantal.personeelsaantal_page'))
|
||||
|
||||
|
||||
@personeelsaantal_bp.route("/peildatum", methods=["POST"])
|
||||
@login_required
|
||||
@recht_vereist('personeelsaantal')
|
||||
def tel_peildatum():
|
||||
peildatum = _datum(request.form.get('peildatum'))
|
||||
if not peildatum:
|
||||
flash("Kies een geldige datum.", "error")
|
||||
return redirect(url_for('personeelsaantal.personeelsaantal_page'))
|
||||
code = request.form.get('school_code')
|
||||
scholen = [school_op_code(code)] if code else alle_scholen()
|
||||
gelukt = 0
|
||||
for school in scholen:
|
||||
if not school or not school.heeft(*INFORMAT_VELDEN):
|
||||
if school and code:
|
||||
flash(f"{school.naam}: Informat is nog niet ingesteld.", "error")
|
||||
continue
|
||||
ok, res, detail = _tel(school, peildatum)
|
||||
if not ok:
|
||||
flash(f"{school.naam}: {res}", "error")
|
||||
continue
|
||||
central_query(
|
||||
"INSERT INTO Personeelsaantal_tellingen (School_ID, Peildatum, Schooljaar, Aantal, Detail, Opgevraagd_op, Door) "
|
||||
"VALUES (%s, %s, %s, %s, %s, NOW(), %s) ON DUPLICATE KEY UPDATE Schooljaar = VALUES(Schooljaar), "
|
||||
"Aantal = VALUES(Aantal), Detail = VALUES(Detail), Opgevraagd_op = NOW(), Door = VALUES(Door)",
|
||||
(school.id, peildatum, _automatisch_schooljaar(peildatum), res, json.dumps(detail) if detail else None,
|
||||
session.get('user')), commit=True)
|
||||
gelukt += 1
|
||||
if gelukt:
|
||||
flash(f"Personeel geteld op {peildatum:%d-%m-%Y} voor {gelukt} school/scholen.", "success")
|
||||
return redirect(url_for('personeelsaantal.personeelsaantal_page', peildatum=peildatum.isoformat()))
|
||||
|
||||
|
||||
@personeelsaantal_bp.route("/peildatum/verwijderen", methods=["POST"])
|
||||
@login_required
|
||||
@recht_vereist('personeelsaantal')
|
||||
def verwijder_peildatum():
|
||||
peildatum = _datum(request.form.get('peildatum'))
|
||||
if peildatum:
|
||||
central_query("DELETE FROM Personeelsaantal_tellingen WHERE Peildatum = %s", (peildatum,), commit=True)
|
||||
flash(f"Telling van {peildatum:%d-%m-%Y} verwijderd.", "success")
|
||||
return redirect(url_for('personeelsaantal.personeelsaantal_page'))
|
||||
|
||||
|
||||
def _csv(naam, kop, rijen):
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf, delimiter=';')
|
||||
w.writerow(kop)
|
||||
w.writerows(rijen)
|
||||
return Response('' + buf.getvalue(), mimetype='text/csv',
|
||||
headers={'Content-Disposition': f'attachment; filename={naam}'})
|
||||
|
||||
|
||||
@personeelsaantal_bp.route("/export.csv", methods=["GET"])
|
||||
@login_required
|
||||
@recht_vereist('personeelsaantal')
|
||||
def export_csv():
|
||||
"""?peildatum=YYYY-MM-DD → die telling; ?alle=1 → alle peildata naast elkaar; anders het huidige aantal."""
|
||||
query = (request.args.get('query') or '').strip()
|
||||
|
||||
if request.args.get('alle'):
|
||||
data = central_query("SELECT School_ID, Peildatum, Aantal, Detail FROM Personeelsaantal_tellingen ORDER BY Peildatum")
|
||||
datums = sorted({r['Peildatum'] for r in data})
|
||||
per = {(r['School_ID'], r['Peildatum']): r['Aantal'] for r in data}
|
||||
campus, campusnamen = {}, {}
|
||||
for r in data:
|
||||
for d in _detail(r['Detail']):
|
||||
campus[(r['School_ID'], d['naam'], r['Peildatum'])] = d['aantal']
|
||||
campusnamen.setdefault(r['School_ID'], [])
|
||||
if d['naam'] not in campusnamen[r['School_ID']]:
|
||||
campusnamen[r['School_ID']].append(d['naam'])
|
||||
scholen = [s for s in alle_scholen() if not query or query.lower() in s.naam.lower()]
|
||||
rijen = []
|
||||
for code, label in NIVEAUS:
|
||||
groep = [s for s in scholen if _niveau(s) == code]
|
||||
if not groep:
|
||||
continue
|
||||
for s in groep:
|
||||
rijen.append([label, s.naam, ''] + [per.get((s.id, d), '') for d in datums])
|
||||
for naam in campusnamen.get(s.id, []):
|
||||
rijen.append([label, s.naam, naam] + [campus.get((s.id, naam, d), '') for d in datums])
|
||||
if code:
|
||||
rijen.append([f"Subtotaal {label}", '', ''] + [sum(per.get((s.id, d)) or 0 for s in groep) for d in datums])
|
||||
rijen.append(['Totaal', '', ''] + [sum(per.get((s.id, d)) or 0 for s in scholen) for d in datums])
|
||||
return _csv(f"personeelsaantallen_alle_peildata_{date.today():%Y%m%d}.csv",
|
||||
['Onderwijsniveau', 'School', 'Instelling'] + [f"{d:%d-%m-%Y}" for d in datums], rijen)
|
||||
|
||||
peildatum = _datum(request.args.get('peildatum'))
|
||||
if peildatum:
|
||||
rows = _telling(peildatum, query)
|
||||
rijen = []
|
||||
for g in _groepen(rows, 'Aantal'):
|
||||
for r in g['rijen']:
|
||||
rijen.append([g['label'], r['Naam_school'], '', '' if r['Aantal'] is None else r['Aantal'],
|
||||
r['Schooljaar'] or '', _fmt(r['Opgevraagd_op'], 'niet geteld'), r['Door'] or ''])
|
||||
for c in r['Campussen']:
|
||||
rijen.append([g['label'], r['Naam_school'], c['naam'], c['aantal'], r['Schooljaar'] or '', '', ''])
|
||||
if g['code']:
|
||||
rijen.append([f"Subtotaal {g['label']}", '', '', g['subtotaal'], '', '', ''])
|
||||
rijen.append(['Totaal', '', '', _som(rows, 'Aantal'), '', '', ''])
|
||||
return _csv(f"personeelsaantallen_{peildatum:%Y%m%d}.csv",
|
||||
['Onderwijsniveau', 'School', 'Instelling', f"Personeelsleden op {peildatum:%d-%m-%Y}", 'Schooljaar',
|
||||
'Opgevraagd op', 'Door'], rijen)
|
||||
|
||||
rows = _huidig(query)
|
||||
rijen = []
|
||||
for g in _groepen(rows, 'Huidig'):
|
||||
for r in g['rijen']:
|
||||
rijen.append([g['label'], r['Naam_school'], '', r['Huidig'] if isinstance(r['Huidig'], int) else '',
|
||||
_fmt(r['Laatste_synchronisatie'], 'nog nooit')])
|
||||
for c in r['Campussen']:
|
||||
rijen.append([g['label'], r['Naam_school'], c['naam'], c['aantal'], ''])
|
||||
if g['code']:
|
||||
rijen.append([f"Subtotaal {g['label']}", '', '', g['subtotaal'], ''])
|
||||
rijen.append(['Totaal', '', '', _som(rows, 'Huidig'), ''])
|
||||
return _csv(f"personeelsaantallen_{date.today():%Y%m%d}.csv",
|
||||
['Onderwijsniveau', 'School', 'Instelling', 'Personeelsleden', 'Laatste synchronisatie'], rijen)
|
||||
@@ -0,0 +1,125 @@
|
||||
import io
|
||||
import zipfile
|
||||
import qrcode
|
||||
import pandas as pd
|
||||
from flask import Blueprint, render_template, request, send_file, flash, redirect, url_for
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist # Zorg dat dit pad klopt naar je auth_utils.py
|
||||
|
||||
# definieren blueprint
|
||||
qr_code_bp = Blueprint('qr_generator', __name__, template_folder='templates')
|
||||
|
||||
def generate_qr_images(excel_file, show_top, show_bottom):
|
||||
# Overslaan van de header rij
|
||||
df = pd.read_excel(excel_file, header=0)
|
||||
|
||||
zip_buffer = io.BytesIO()
|
||||
|
||||
try:
|
||||
# Try loading your local or system Arial font
|
||||
font = ImageFont.truetype("arial.ttf", 20)
|
||||
except IOError:
|
||||
try:
|
||||
|
||||
font = ImageFont.load_default_font(size=20)
|
||||
except AttributeError:
|
||||
# Absolute fallback if Pillow is outdated, but textbbox WILL fail if this hits
|
||||
font = ImageFont.load_default()
|
||||
|
||||
with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED) as zip_file:
|
||||
for index, row in df.iterrows():
|
||||
# Gebruik .iloc voor veiligheid tegen kolomnamen
|
||||
qr_text = str(row.iloc[0]) if not pd.isna(row.iloc[0]) else ""
|
||||
top_text = str(row.iloc[1]) if not pd.isna(row.iloc[1]) else ""
|
||||
bottom_text = str(row.iloc[2]) if not pd.isna(row.iloc[2]) else ""
|
||||
|
||||
if qr_text.strip() == "":
|
||||
continue
|
||||
|
||||
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_H)
|
||||
qr.add_data(qr_text)
|
||||
qr.make(fit=True)
|
||||
qr_img = qr.make_image(fill_color="black", back_color="white").convert("RGB")
|
||||
|
||||
qr_width, qr_height = qr_img.size
|
||||
padding = 40
|
||||
total_height = qr_height + (2 * padding)
|
||||
|
||||
canvas = Image.new("RGB", (qr_width + 40, total_height + 40), "white")
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
|
||||
qr_x = (canvas.width - qr_width) // 2
|
||||
qr_y = padding
|
||||
canvas.paste(qr_img, (qr_x, qr_y))
|
||||
|
||||
if show_top and top_text:
|
||||
bbox = draw.textbbox((0, 0), top_text, font=font)
|
||||
text_w = bbox[2] - bbox[0]
|
||||
draw.text(((canvas.width - text_w) / 2, 5), top_text, fill="black", font=font)
|
||||
|
||||
if show_bottom and bottom_text:
|
||||
bbox = draw.textbbox((0, 0), bottom_text, font=font)
|
||||
text_w = bbox[2] - bbox[0]
|
||||
draw.text(((canvas.width - text_w) / 2, qr_y + qr_height + 5), bottom_text, fill="black", font=font)
|
||||
|
||||
img_io = io.BytesIO()
|
||||
canvas.save(img_io, 'PNG')
|
||||
img_io.seek(0)
|
||||
zip_file.writestr(f"qr_{index+1}.png", img_io.getvalue())
|
||||
|
||||
zip_buffer.seek(0)
|
||||
return zip_buffer
|
||||
|
||||
@qr_code_bp.route('/')
|
||||
@login_required
|
||||
@recht_vereist('qr_codes')
|
||||
def qr_generator():
|
||||
|
||||
return render_template('qr_code.html')
|
||||
|
||||
@qr_code_bp.route('/download-template')
|
||||
@login_required
|
||||
@recht_vereist('qr_codes')
|
||||
def download_template():
|
||||
df = pd.DataFrame(columns=["QR-Code (Link/ID)", "Koptekst", "Voettekst"])
|
||||
|
||||
buffer = io.BytesIO()
|
||||
with pd.ExcelWriter(buffer, engine='openpyxl') as writer:
|
||||
df.to_excel(writer, index=False)
|
||||
|
||||
buffer.seek(0)
|
||||
|
||||
return send_file(
|
||||
buffer,
|
||||
as_attachment=True,
|
||||
download_name='sjabloon_qr.xlsx',
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
)
|
||||
|
||||
@qr_code_bp.route('/upload', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('qr_codes')
|
||||
def upload_file():
|
||||
if 'file' not in request.files:
|
||||
flash("Geen bestand geselecteerd", "warning")
|
||||
return redirect(url_for('qr_generator.qr_generator'))
|
||||
|
||||
file = request.files['file']
|
||||
show_top = request.form.get('show_top') == 'on'
|
||||
show_bottom = request.form.get('show_bottom') == 'on'
|
||||
|
||||
if file and file.filename != '':
|
||||
try:
|
||||
zip_data = generate_qr_images(file, show_top, show_bottom)
|
||||
return send_file(
|
||||
zip_data,
|
||||
mimetype='application/zip',
|
||||
as_attachment=True,
|
||||
download_name='qr_codes.zip'
|
||||
)
|
||||
except Exception as e:
|
||||
flash(f"Fout bij verwerken: {str(e)}", "danger")
|
||||
return redirect(url_for('qr_generator.qr_generator'))
|
||||
|
||||
return redirect(url_for('qr_generator.qr_generator'))
|
||||
@@ -0,0 +1,145 @@
|
||||
from flask import Blueprint, render_template, request, session, redirect, url_for, flash
|
||||
from database import execute_query
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist
|
||||
from services import CIP_inleveren # Voor de Ingeven_herstelling logica
|
||||
from services.signpost_byod_service import get_cases_by_serial
|
||||
from services import toestellen as tst
|
||||
|
||||
# Herstellingen met de kolomnamen die herstellingen.html verwacht
|
||||
HERSTELLINGEN_SQL = """
|
||||
SELECT h.Incident_ID AS Incident_nr, t.Schooleigen_ID AS KAMXM_nummer, t.Serienummer,
|
||||
h.Omschrijving AS Soort_herstelling, h.Status_herstelling AS Status, h.Datum, h.Kostprijs,
|
||||
l.Internnummer
|
||||
FROM Signpost_herstellingen_DB h
|
||||
JOIN Toestellen t ON t.Toestel_ID = h.Toestel_ID
|
||||
LEFT JOIN Leerlingen l ON l.Leerling_ID = h.Leerling_ID
|
||||
"""
|
||||
|
||||
# Definieer de blueprint
|
||||
repairs_bp = Blueprint('repairs', __name__)
|
||||
|
||||
#---------------------------------------------------
|
||||
# --- ROUTE: OVERZICHT & ZOEKEN ---
|
||||
#----------------------------------------------------
|
||||
@repairs_bp.route('/herstellingen', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@recht_vereist('herstellingen')
|
||||
def herstellingen():
|
||||
query = None
|
||||
results = []
|
||||
|
||||
# Haal de zoekterm op bij een POST request
|
||||
if request.method == 'POST':
|
||||
query = request.form.get('query')
|
||||
sql = HERSTELLINGEN_SQL + " WHERE t.Schooleigen_ID LIKE %s OR h.Omschrijving LIKE %s ORDER BY h.Incident_ID DESC"
|
||||
results = execute_query(sql, (f'%{query}%', f'%{query}%'))
|
||||
else:
|
||||
# Toon standaard alle herstellingen
|
||||
results = execute_query(HERSTELLINGEN_SQL + " ORDER BY h.Incident_ID DESC")
|
||||
|
||||
return render_template('herstellingen.html', results=results, query=query)
|
||||
|
||||
#------------------------------------------------
|
||||
# --- ROUTE: TOEVOEGEN HERSTELLING ---
|
||||
#------------------------------------------------
|
||||
@repairs_bp.route('/repairs.add_herstelling', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('herstellingen')
|
||||
def add_herstelling():
|
||||
kamxm_nummer = request.form.get('kamxm_nummer')
|
||||
soort_herstelling = request.form.get('soort_herstelling')
|
||||
|
||||
if not kamxm_nummer or not soort_herstelling:
|
||||
flash("Vul alle velden in!", "error")
|
||||
return redirect(url_for('repairs.herstellingen'))
|
||||
|
||||
try:
|
||||
# Gebruik de logica uit de CIP_inleveren module
|
||||
CIP_inleveren.Ingeven_herstelling(kamxm_nummer, soort_herstelling)
|
||||
if CIP_inleveren.foutmelding:
|
||||
flash(CIP_inleveren.foutmelding)
|
||||
except Exception as e:
|
||||
flash(f"Er is een fout opgetreden: {str(e)}", "error")
|
||||
|
||||
return redirect(url_for('repairs.herstellingen'))
|
||||
|
||||
#functie achter wijzig herstellingen
|
||||
@repairs_bp.route('/wijzig_status_herstelling', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('herstellingen')
|
||||
def wijzig_status_herstelling():
|
||||
# Haal alle gegevens op uit het formulier
|
||||
kamxm_nummer = request.form.get('kamxm_nummer')
|
||||
nieuwe_status = request.form.get('new_status')
|
||||
incident_nr = request.form.get("incident_nr") # Zorg dat dit uit je modal komt
|
||||
|
||||
# Validatie: we hebben nu alle drie de velden nodig voor een unieke update
|
||||
if not kamxm_nummer or not nieuwe_status or not incident_nr:
|
||||
flash("Fout: Ontbrekende gegevens (Toestel, Status of Incident nr).", "error")
|
||||
return redirect(url_for('repairs.herstellingen'))
|
||||
|
||||
try:
|
||||
# Update alleen de rij waar zowel KAMXM als Incident_nr overeenkomen
|
||||
execute_query("UPDATE Signpost_herstellingen_DB SET Status_herstelling = %s WHERE Incident_ID = %s",
|
||||
(nieuwe_status, incident_nr), commit=True)
|
||||
|
||||
flash(f"Status van incident {incident_nr} ({kamxm_nummer}) is nu: {nieuwe_status}", "success")
|
||||
except Exception as err:
|
||||
flash(f"Database fout: {err}", "error")
|
||||
|
||||
return redirect(url_for('repairs.herstellingen'))
|
||||
|
||||
#-----------------------------------------------
|
||||
# --- ROUTE: GEGEVENS BEWERKEN ---
|
||||
#-----------------------------------------------
|
||||
@repairs_bp.route('/bewerk_herstelling', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('herstellingen')
|
||||
def bewerk_herstelling():
|
||||
# Haal de originele sleutels op om de juiste rij te vinden
|
||||
old_kamxm = request.form.get('kamxm_nummer_original')
|
||||
incident_nr = request.form.get('incident_nr') # Dit is essentieel voor de unieke selectie
|
||||
|
||||
# Haal de nieuwe waarden op
|
||||
new_kamxm = request.form.get('kamxm_nummer')
|
||||
soort = request.form.get('soort_herstelling')
|
||||
datum = request.form.get('datum')
|
||||
|
||||
try:
|
||||
toestel = tst.zoek_toestel(new_kamxm)
|
||||
if not toestel:
|
||||
flash(f"Toestel {new_kamxm} niet gevonden.", "error")
|
||||
return redirect(url_for('repairs.herstellingen'))
|
||||
execute_query("UPDATE Signpost_herstellingen_DB SET Toestel_ID = %s, Omschrijving = %s, Datum = %s "
|
||||
"WHERE Incident_ID = %s",
|
||||
(toestel['Toestel_ID'], soort, datum or None, incident_nr), commit=True)
|
||||
flash(f"Herstelling {incident_nr} voor {new_kamxm} succesvol bijgewerkt!", "success")
|
||||
except Exception as err:
|
||||
flash(f"Fout bij bijwerken: {err}", "error")
|
||||
|
||||
return redirect(url_for('repairs.herstellingen'))
|
||||
|
||||
#--------------------------------------------------
|
||||
#--- ROUTE: Signpost herstelling
|
||||
#--------------------------------------------------
|
||||
@repairs_bp.route('/herstellingen/signpost_search', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('herstellingen')
|
||||
def signpost_search():
|
||||
serial = request.form.get('serial_number')
|
||||
signpost_results = []
|
||||
|
||||
if serial:
|
||||
signpost_results = get_cases_by_serial(serial)
|
||||
if not signpost_results:
|
||||
flash(f"Geen Signpost cases gevonden voor {serial}", "info")
|
||||
|
||||
# We sturen de gebruiker terug naar de tab, maar met de resultaten
|
||||
# Tip: Om de tab 'active' te houden na een POST, kun je een parameter meesturen
|
||||
results = execute_query(HERSTELLINGEN_SQL + " ORDER BY h.Incident_ID DESC")
|
||||
return render_template('herstellingen.html',
|
||||
results=results,
|
||||
signpost_results=signpost_results,
|
||||
active_tab='signpost-tab',
|
||||
searched_serial=serial)
|
||||
@@ -0,0 +1,683 @@
|
||||
"""
|
||||
Scholenbeheer.
|
||||
|
||||
- Superbeheerders (administrators in Gebruikers, of config.ini › superbeheerders via Google):
|
||||
scholen toevoegen, alle instellingen wijzigen, tussen alle scholen wisselen.
|
||||
- Gebruikers met het recht 'koppelingen' in een school: de koppelingen van DIE school wijzigen
|
||||
(wachtwoorden, Informat, Google, ...), maar niet de code, domeinen of database.
|
||||
- Iedereen kan wisselen naar een school waarin hij rechten heeft.
|
||||
"""
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import requests
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, g
|
||||
|
||||
import tenancy
|
||||
from tenancy import VELDEN, VELD, GROEPEN, alle_scholen, school_op_code, in_school
|
||||
from database import central_query, execute_query, get_central_connection, vergeet_pools
|
||||
import rechten
|
||||
|
||||
scholen_bp = Blueprint('scholen', __name__)
|
||||
|
||||
CODE_RE = re.compile(r'^[a-z0-9_-]{2,32}$')
|
||||
DB_RE = re.compile(r'^[A-Za-z0-9_-]{1,64}$')
|
||||
# Velden die enkel een superbeheerder mag wijzigen
|
||||
ENKEL_SUPER_GROEPEN = {'database'}
|
||||
|
||||
|
||||
def superbeheerder_required(f):
|
||||
@functools.wraps(f)
|
||||
def wrapped(*a, **kw):
|
||||
if not session.get('auth') or not session.get('superbeheerder'):
|
||||
flash("Enkel voor superbeheerders van de scholengroep.", "error")
|
||||
return redirect(url_for('auth.login'))
|
||||
return f(*a, **kw)
|
||||
return wrapped
|
||||
|
||||
|
||||
# Koppelingen van de eigen school: recht 'koppelingen' in die school
|
||||
schoolbeheerder_required = rechten.recht_vereist('koppelingen')
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Overzicht en wisselen
|
||||
# ------------------------------------------------------------------
|
||||
@scholen_bp.route('/')
|
||||
@superbeheerder_required
|
||||
def overzicht():
|
||||
tenancy.herlaad(force=True)
|
||||
scholen = alle_scholen(ook_inactief=True)
|
||||
# Onderverdeeld per onderwijsniveau; scholen zonder (geldig) niveau komen in de laatste groep
|
||||
niveaus = list(tenancy.ONDERWIJSNIVEAUS) + [('', 'Onderwijsniveau niet gekozen')]
|
||||
codes = {c for c, _ in tenancy.ONDERWIJSNIVEAUS}
|
||||
niveau = lambda sch: sch.waarden.get('ONDERWIJSNIVEAU') if sch.waarden.get('ONDERWIJSNIVEAU') in codes else ''
|
||||
groepen = [{'code': code or 'geen', 'label': label, 'scholen': [x for x in scholen if niveau(x) == code]}
|
||||
for code, label in niveaus]
|
||||
tests = laatste_tests()
|
||||
return render_template('scholen.html', scholen=scholen, groepen=[g for g in groepen if g['scholen']],
|
||||
badges={x.code: koppeling_badges(x, tests.get(x.id, {})) for x in scholen})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Resultaten van de koppelingstest (voor de badges in het overzicht)
|
||||
# ------------------------------------------------------------------
|
||||
# Badge in het overzicht → onderdelen uit test_school() die erbij horen
|
||||
BADGES = [
|
||||
('Smartschool', ('SS_API_PLATFORM', 'SS_API_PW'), ('Smartschool webservices', 'Smartschool OneRoster')),
|
||||
('Informat', ('INFORMAT_INST_NUMBER', 'INFORMAT_ID', 'INFORMAT_SECRET'), ('Informat',)),
|
||||
('Google', None, ('Google Workspace',)),
|
||||
('Signpost', ('M4S_CLIENT_ID', 'M4S_CLIENT_SECRET'), ('Signpost / M4S',)),
|
||||
]
|
||||
|
||||
|
||||
def _informat_ingesteld(school):
|
||||
"""Informat telt als ingesteld met client-ID/secret en een instellingsnummer van de school óf van het internaat."""
|
||||
return school.heeft('INFORMAT_ID', 'INFORMAT_SECRET') and (
|
||||
school.heeft('INFORMAT_INST_NUMBER') or school.heeft('INFORMAT_INTERNAAT_NUMMER'))
|
||||
|
||||
|
||||
def bewaar_test(school, resultaten):
|
||||
for onderdeel, ok, detail in resultaten:
|
||||
central_query(
|
||||
"INSERT INTO Koppeling_tests (School_ID, Onderdeel, Ok, Detail, Getest_op) VALUES (%s, %s, %s, %s, NOW()) "
|
||||
"ON DUPLICATE KEY UPDATE Ok = VALUES(Ok), Detail = VALUES(Detail), Getest_op = NOW()",
|
||||
(school.id, onderdeel, None if ok is None else int(bool(ok)), (detail or '')[:500]), commit=True)
|
||||
|
||||
|
||||
def laatste_tests():
|
||||
"""{school_id: {onderdeel: rij}}"""
|
||||
uit = {}
|
||||
try:
|
||||
for r in central_query("SELECT School_ID, Onderdeel, Ok, Detail, Getest_op FROM Koppeling_tests") or []:
|
||||
uit.setdefault(r['School_ID'], {})[r['Onderdeel']] = r
|
||||
except Exception:
|
||||
pass
|
||||
return uit
|
||||
|
||||
|
||||
def koppeling_badges(school, tests):
|
||||
"""[{label, toestand: ok|fout|ongetest|niet, titel}] volgens de laatste test. Een test van vóór de laatste
|
||||
wijziging aan de school telt niet meer (dan 'ongetest')."""
|
||||
from datetime import datetime
|
||||
try:
|
||||
gewijzigd = datetime.fromisoformat(school.versie).replace(microsecond=0) if school.versie else None
|
||||
except ValueError:
|
||||
gewijzigd = None
|
||||
uit = []
|
||||
for label, velden, onderdelen in BADGES:
|
||||
if label == 'Informat':
|
||||
ingesteld = _informat_ingesteld(school)
|
||||
else:
|
||||
ingesteld = school.google_sleutel is not None if velden is None else school.heeft(*velden)
|
||||
if not ingesteld:
|
||||
uit.append({'label': label, 'toestand': 'niet', 'titel': 'niet ingesteld'})
|
||||
continue
|
||||
rijen = [tests[o] for o in onderdelen if o in tests and tests[o]['Ok'] is not None]
|
||||
if not rijen or (gewijzigd and any(r['Getest_op'] < gewijzigd for r in rijen)):
|
||||
uit.append({'label': label, 'toestand': 'ongetest',
|
||||
'titel': 'ingesteld, maar nog niet getest sinds de laatste wijziging – klik op Testen'})
|
||||
continue
|
||||
fout = [r for r in rijen if not r['Ok']]
|
||||
wanneer = max(r['Getest_op'] for r in rijen).strftime('%d-%m-%Y %H:%M')
|
||||
if fout:
|
||||
uit.append({'label': label, 'toestand': 'fout',
|
||||
'titel': f"test mislukt op {wanneer}: " + ' · '.join(r['Detail'] for r in fout)[:300]})
|
||||
else:
|
||||
uit.append({'label': label, 'toestand': 'ok', 'titel': f"test geslaagd op {wanneer}"})
|
||||
return uit
|
||||
|
||||
|
||||
@scholen_bp.route('/wissel/<code>')
|
||||
def wissel(code):
|
||||
"""Naar een andere school gaan: superbeheerders overal, anderen waar ze rechten hebben."""
|
||||
if not session.get('auth'):
|
||||
return redirect(url_for('auth.login'))
|
||||
school = school_op_code(code)
|
||||
if not school:
|
||||
flash("Onbekende of inactieve school.", "error")
|
||||
return redirect(url_for('main.dashboard'))
|
||||
if not session.get('superbeheerder'):
|
||||
gid = session.get('gebruiker_id')
|
||||
if not gid or school.id not in rechten.scholen_met_rechten(gid):
|
||||
flash(f"Je hebt geen rechten in {school.naam}.", "error")
|
||||
return redirect(url_for('main.dashboard'))
|
||||
session['school'] = school.code
|
||||
rechten.bewaar_laatste_school(session.get('gebruiker_id'), school.code) # volgende keer meteen deze school
|
||||
flash(f"Je werkt nu in {school.naam}.", "success")
|
||||
from blueprints.auth import _onthoud_school
|
||||
return _onthoud_school(redirect(url_for('main.dashboard')), school)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Formulier
|
||||
# ------------------------------------------------------------------
|
||||
# Velden die een uur van de dag zijn (0–23)
|
||||
UUR_VELDEN = {'AFWEZIGHEID_SYNC_UUR'}
|
||||
|
||||
|
||||
def _controleer_waarden(waarden):
|
||||
"""Geeft een foutmelding als een getal-veld ongeldig is, anders None."""
|
||||
for v in VELDEN:
|
||||
w = str(waarden.get(v.key) or '').strip()
|
||||
if v.soort != 'getal' or not w:
|
||||
continue
|
||||
if not w.isdigit():
|
||||
return f"'{v.label}' moet een getal zijn (nu: {w})."
|
||||
if v.key in UUR_VELDEN and not 0 <= int(w) <= 23:
|
||||
return f"'{v.label}' moet een uur tussen 0 en 23 zijn (nu: {w})."
|
||||
return None
|
||||
|
||||
|
||||
def _form_waarden(school, mag_alles):
|
||||
"""Leest het formulier. Lege geheimen = ongewijzigd laten (tenzij 'wissen' aangevinkt)."""
|
||||
oud = dict(school.waarden) if school else {}
|
||||
nieuw = dict(oud)
|
||||
for v in VELDEN:
|
||||
if v.groep in ENKEL_SUPER_GROEPEN and not mag_alles:
|
||||
continue
|
||||
naam = f"v_{v.key}"
|
||||
if v.soort == 'janee':
|
||||
nieuw[v.key] = 'ja' if request.form.get(naam) else 'nee'
|
||||
continue
|
||||
waarde = (request.form.get(naam) or '').strip()
|
||||
if v.soort == 'geheim':
|
||||
if request.form.get(f"wis_{v.key}"):
|
||||
nieuw.pop(v.key, None)
|
||||
elif waarde:
|
||||
nieuw[v.key] = waarde
|
||||
continue
|
||||
if waarde:
|
||||
nieuw[v.key] = waarde
|
||||
else:
|
||||
nieuw.pop(v.key, None)
|
||||
return nieuw
|
||||
|
||||
|
||||
def _google_sleutel_uit_form():
|
||||
"""Geeft (sleutel_dict | None, foutmelding | None)."""
|
||||
bestand = request.files.get('google_sleutel')
|
||||
tekst = ''
|
||||
if bestand and bestand.filename:
|
||||
tekst = bestand.read().decode('utf-8', errors='replace')
|
||||
elif (request.form.get('google_sleutel_tekst') or '').strip():
|
||||
tekst = request.form['google_sleutel_tekst'].strip()
|
||||
if not tekst:
|
||||
return None, None
|
||||
try:
|
||||
data = json.loads(tekst)
|
||||
except ValueError:
|
||||
return None, "De Google-sleutel is geen geldig JSON-bestand."
|
||||
if data.get('type') != 'service_account' or not data.get('private_key') or not data.get('client_email'):
|
||||
return None, "Dit is geen sleutel van een Google-serviceaccount (type 'service_account')."
|
||||
return data, None
|
||||
|
||||
|
||||
def database_status(school):
|
||||
"""'ok' (PIVOT-tabellen aanwezig), 'leeg' (database bestaat, tabellen ontbreken) of 'ontbreekt'."""
|
||||
import mysql.connector
|
||||
cfg = school.db_config()
|
||||
from config import Config
|
||||
try:
|
||||
conn = mysql.connector.connect(host=cfg['host'], port=Config.DB_PORT, user=cfg['user'],
|
||||
password=cfg['password'], connection_timeout=5)
|
||||
except Exception:
|
||||
return 'onbekend'
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s", (school.db_naam,))
|
||||
if not cur.fetchone()[0]:
|
||||
return 'ontbreekt'
|
||||
cur.execute("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s "
|
||||
"AND LOWER(TABLE_NAME) = 'toestellen'", (school.db_naam,))
|
||||
return 'ok' if cur.fetchone()[0] else 'leeg'
|
||||
except Exception:
|
||||
return 'onbekend'
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _render_form(school, nieuw, mag_alles, form=None):
|
||||
return render_template('school_bewerken.html', school=school, nieuw=nieuw, mag_alles=mag_alles,
|
||||
db_status=database_status(school) if school else None,
|
||||
groepen=GROEPEN, velden=VELDEN, enkel_super=ENKEL_SUPER_GROEPEN,
|
||||
form=form or {}, andere_scholen=[s for s in alle_scholen(ook_inactief=True) if not school or s.code != school.code])
|
||||
|
||||
|
||||
@scholen_bp.route('/nieuw', methods=['GET', 'POST'])
|
||||
@superbeheerder_required
|
||||
def nieuw():
|
||||
if request.method == 'GET':
|
||||
return _render_form(None, True, True)
|
||||
|
||||
code = (request.form.get('code') or '').strip().lower()
|
||||
naam = (request.form.get('naam') or '').strip()
|
||||
db_naam = (request.form.get('db_naam') or '').strip()
|
||||
fouten = []
|
||||
if not CODE_RE.match(code):
|
||||
fouten.append("De code mag enkel kleine letters, cijfers, - en _ bevatten (2-32 tekens).")
|
||||
elif school_op_code(code, ook_inactief=True):
|
||||
fouten.append(f"Er bestaat al een school met code '{code}'.")
|
||||
if not naam:
|
||||
fouten.append("Vul de naam van de school in.")
|
||||
if not DB_RE.match(db_naam):
|
||||
fouten.append("Ongeldige databasenaam.")
|
||||
domeinen, fout = _domeinen(request.form.get('domeinen'))
|
||||
if fout:
|
||||
fouten.append(fout)
|
||||
sleutel, fout = _google_sleutel_uit_form()
|
||||
if fout:
|
||||
fouten.append(fout)
|
||||
if fouten:
|
||||
for f in fouten:
|
||||
flash(f, "error")
|
||||
return _render_form(None, True, True, form=request.form)
|
||||
|
||||
waarden = _form_waarden(None, True)
|
||||
fout = _controleer_waarden(waarden)
|
||||
if fout:
|
||||
flash(fout, "error")
|
||||
return _render_form(None, True, True, form=request.form)
|
||||
|
||||
# Optioneel: database aanmaken (standaardstructuur, of een volledige kopie van een bestaande school)
|
||||
keuze = request.form.get('kopieer_schema_van')
|
||||
bron = school_op_code(keuze, ook_inactief=True) if keuze and keuze != STANDAARD else None
|
||||
if keuze == STANDAARD:
|
||||
ok, msg = maak_schooldatabase(db_naam, waarden)
|
||||
flash(msg, "success" if ok else "error")
|
||||
if not ok:
|
||||
return _render_form(None, True, True, form=request.form)
|
||||
elif bron:
|
||||
if request.form.get('kopie_koppelingen'):
|
||||
# Koppelingen en instellingen van de bron overnemen (database-server blijft dezelfde);
|
||||
# aanpassen kan meteen daarna op de pagina Bewerken.
|
||||
waarden = dict(bron.waarden)
|
||||
sleutel = sleutel or bron.google_sleutel
|
||||
ok, msg = kopieer_school_db(bron, db_naam, waarden)
|
||||
flash(msg, "success" if ok else "error")
|
||||
if not ok:
|
||||
return _render_form(None, True, True, form=request.form)
|
||||
|
||||
tenancy.opslaan(code, naam, ','.join(domeinen), _hostnamen(request.form.get('hostnamen')), db_naam,
|
||||
bool(request.form.get('actief')), waarden, sleutel, gebruiker=session.get('user'), nieuw=True)
|
||||
flash(f"School {naam} toegevoegd.", "success")
|
||||
if bron:
|
||||
if request.form.get('kopie_koppelingen'):
|
||||
flash(f"Koppelingen en instellingen van {bron.naam} overgenomen. Kijk ze hieronder na"
|
||||
+ (" (de school staat actief: geplande synchronisaties lopen ook voor de kopie)."
|
||||
if request.form.get('actief') else "."), "info")
|
||||
if request.form.get('kopie_rechten'):
|
||||
n = kopieer_rechten(bron, code, session.get('user'))
|
||||
flash(f"Rechten van {n} gebruiker(s) overgenomen van {bron.naam}.", "success")
|
||||
return redirect(url_for('scholen.bewerken', code=code))
|
||||
|
||||
|
||||
@scholen_bp.route('/<code>/bewerken', methods=['GET', 'POST'])
|
||||
@superbeheerder_required
|
||||
def bewerken(code):
|
||||
return _bewerk(code, mag_alles=True)
|
||||
|
||||
|
||||
@scholen_bp.route('/<code>/database-aanmaken', methods=['POST'])
|
||||
@superbeheerder_required
|
||||
def database_aanmaken(code):
|
||||
"""Database van een bestaande school (alsnog) aanmaken met de standaardstructuur.
|
||||
Veilig om te herhalen: bestaande tabellen en gegevens blijven behouden."""
|
||||
school = school_op_code(code, ook_inactief=True)
|
||||
if not school:
|
||||
flash("Onbekende school.", "error")
|
||||
return redirect(url_for('scholen.overzicht'))
|
||||
ok, msg = maak_schooldatabase(school.db_naam, school.waarden)
|
||||
vergeet_pools(school.db_naam)
|
||||
flash(msg, "success" if ok else "error")
|
||||
return redirect(url_for('scholen.bewerken', code=school.code))
|
||||
|
||||
|
||||
@scholen_bp.route('/mijn-school', methods=['GET', 'POST'])
|
||||
@schoolbeheerder_required
|
||||
def mijn_school():
|
||||
return _bewerk(session['school'], mag_alles=bool(session.get('superbeheerder')))
|
||||
|
||||
|
||||
def _bewerk(code, mag_alles):
|
||||
school = school_op_code(code, ook_inactief=True)
|
||||
if not school:
|
||||
flash("Onbekende school.", "error")
|
||||
return redirect(url_for('main.dashboard'))
|
||||
if request.method == 'GET':
|
||||
return _render_form(school, False, mag_alles)
|
||||
|
||||
if request.form.get('wis_google_sleutel'):
|
||||
tenancy.wis_google_sleutel(school.code, session.get('user'))
|
||||
school = school_op_code(code, ook_inactief=True)
|
||||
|
||||
sleutel, fout = _google_sleutel_uit_form()
|
||||
if fout:
|
||||
flash(fout, "error")
|
||||
return _render_form(school, False, mag_alles, form=request.form)
|
||||
|
||||
if mag_alles:
|
||||
naam = (request.form.get('naam') or '').strip() or school.naam
|
||||
domeinen, fout = _domeinen(request.form.get('domeinen'), school.code)
|
||||
db_naam = (request.form.get('db_naam') or '').strip()
|
||||
if fout or not DB_RE.match(db_naam):
|
||||
flash(fout or "Ongeldige databasenaam.", "error")
|
||||
return _render_form(school, False, mag_alles, form=request.form)
|
||||
hostnamen = _hostnamen(request.form.get('hostnamen'))
|
||||
actief = bool(request.form.get('actief'))
|
||||
else:
|
||||
naam, domeinen, db_naam = school.naam, school.domeinen, school.db_naam
|
||||
hostnamen, actief = ','.join(school.hostnamen), school.actief
|
||||
|
||||
waarden = _form_waarden(school, mag_alles)
|
||||
fout = _controleer_waarden(waarden)
|
||||
if fout:
|
||||
flash(fout, "error")
|
||||
return _render_form(school, False, mag_alles, form=request.form)
|
||||
tenancy.opslaan(school.code, naam, ','.join(domeinen), hostnamen, db_naam, actief, waarden, sleutel,
|
||||
gebruiker=session.get('user'))
|
||||
vergeet_pools()
|
||||
flash(f"Instellingen van {naam} opgeslagen.", "success")
|
||||
if request.endpoint == 'scholen.bewerken':
|
||||
return redirect(url_for('scholen.bewerken', code=school.code))
|
||||
return redirect(url_for('scholen.mijn_school'))
|
||||
|
||||
|
||||
def _domeinen(tekst, eigen_code=None):
|
||||
domeinen = [d.strip().lower().lstrip('@') for d in re.split(r'[,;\s]+', tekst or '') if d.strip()]
|
||||
if not domeinen:
|
||||
return [], "Geef minstens één e-maildomein op (bv. school.be)."
|
||||
for d in domeinen:
|
||||
if not re.match(r'^[a-z0-9.-]+\.[a-z]{2,}$', d):
|
||||
return [], f"'{d}' is geen geldig domein."
|
||||
# Meerdere scholen mogen hetzelfde domein delen (bv. basis- en secundaire school van één campus).
|
||||
# Wie zich met Google aanmeldt, komt dan in de school waar hij toegang toe heeft (zie auth.callback).
|
||||
return domeinen, None
|
||||
|
||||
|
||||
def _hostnamen(tekst):
|
||||
return ','.join(h.strip().lower() for h in re.split(r'[,;\s]+', tekst or '') if h.strip())
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database aanmaken
|
||||
# ------------------------------------------------------------------
|
||||
STANDAARD = '__standaard__'
|
||||
SCHOOL_SQL = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'sql', 'school.sql')
|
||||
|
||||
|
||||
def sql_statements(pad):
|
||||
"""Leest een .sql-bestand en geeft de afzonderlijke statements (commentaar weggelaten)."""
|
||||
with open(pad, encoding='utf-8') as f:
|
||||
regels = [r for r in f.read().split('\n') if not r.strip().startswith('--')]
|
||||
return [st.strip() for st in re.split(r';\s*\n', '\n'.join(regels) + '\n') if st.strip()]
|
||||
|
||||
|
||||
def maak_schooldatabase(db_naam, waarden=None):
|
||||
"""Maakt db_naam aan met de standaardstructuur uit sql/school.sql (lege tabellen)."""
|
||||
import mysql.connector
|
||||
from config import Config
|
||||
waarden = waarden or {}
|
||||
if not DB_RE.match(db_naam or ''):
|
||||
return False, "Ongeldige databasenaam."
|
||||
try:
|
||||
conn = mysql.connector.connect(
|
||||
host=waarden.get('DB_HOST') or Config.DB_HOST, port=Config.DB_PORT,
|
||||
user=waarden.get('DB_USER') or Config.DB_USER,
|
||||
password=waarden.get('DB_PASSWORD') or Config.DB_PASSWORD, connection_timeout=10,
|
||||
charset='utf8mb4', collation='utf8mb4_unicode_ci')
|
||||
except Exception as e:
|
||||
return False, f"Kan niet verbinden met de databaseserver: {e}"
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(f"CREATE DATABASE IF NOT EXISTS `{db_naam}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
|
||||
cur.execute(f"USE `{db_naam}`")
|
||||
stmts = sql_statements(SCHOOL_SQL)
|
||||
for st in stmts:
|
||||
cur.execute(st)
|
||||
conn.commit()
|
||||
n = sum(1 for st in stmts if st.upper().startswith('CREATE TABLE'))
|
||||
return True, f"Database {db_naam} aangemaakt met de standaardstructuur ({n} tabellen)."
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return False, f"Database aanmaken mislukt: {e}"
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
def kopieer_school_db(bron, db_naam, waarden):
|
||||
"""Maakt db_naam aan (zelfde server) als volledige kopie van de database van `bron`: alle tabellen mét gegevens.
|
||||
De doeldatabase moet nieuw of leeg zijn. Mislukt het, dan wordt een database die we zelf aanmaakten weer verwijderd."""
|
||||
bron_db = bron.db_naam
|
||||
if waarden.get('DB_HOST') and waarden.get('DB_HOST') != (bron.waarden.get('DB_HOST') or ''):
|
||||
return False, "Een school kopiëren kan enkel binnen dezelfde databaseserver."
|
||||
if db_naam == bron_db:
|
||||
return False, "Kies een andere databasenaam dan die van de school die je kopieert."
|
||||
conn = get_central_connection()
|
||||
cur = conn.cursor()
|
||||
zelf_aangemaakt = False
|
||||
try:
|
||||
cur.execute("SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s", (db_naam,))
|
||||
bestond = bool(cur.fetchall())
|
||||
if bestond:
|
||||
cur.execute(f"SHOW FULL TABLES FROM `{db_naam}` WHERE Table_type = 'BASE TABLE'")
|
||||
if cur.fetchall():
|
||||
return False, (f"De database {db_naam} bestaat al en bevat tabellen. Kies een nieuwe databasenaam "
|
||||
"om er een kopie in te zetten.")
|
||||
else:
|
||||
cur.execute(f"CREATE DATABASE `{db_naam}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
|
||||
zelf_aangemaakt = True
|
||||
cur.execute(f"SHOW FULL TABLES FROM `{bron_db}` WHERE Table_type = 'BASE TABLE'")
|
||||
tabellen = [r[0] for r in cur.fetchall()]
|
||||
cur.execute("SET FOREIGN_KEY_CHECKS = 0")
|
||||
rijen = 0
|
||||
for t in tabellen:
|
||||
cur.execute(f"CREATE TABLE `{db_naam}`.`{t}` LIKE `{bron_db}`.`{t}`")
|
||||
cur.execute(f"INSERT INTO `{db_naam}`.`{t}` SELECT * FROM `{bron_db}`.`{t}`")
|
||||
rijen += max(cur.rowcount or 0, 0)
|
||||
cur.execute("SET FOREIGN_KEY_CHECKS = 1")
|
||||
conn.commit()
|
||||
return True, (f"Database {db_naam} aangemaakt als kopie van {bron.naam}: "
|
||||
f"{len(tabellen)} tabellen, {rijen} rijen gegevens.")
|
||||
except Exception as e:
|
||||
try:
|
||||
conn.rollback()
|
||||
cur.execute("SET FOREIGN_KEY_CHECKS = 1")
|
||||
if zelf_aangemaakt:
|
||||
cur.execute(f"DROP DATABASE IF EXISTS `{db_naam}`")
|
||||
except Exception:
|
||||
pass
|
||||
return False, f"School kopiëren mislukt: {e}"
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def kopieer_rechten(bron, nieuwe_code, door=None):
|
||||
"""Geeft iedereen die rechten heeft in `bron` dezelfde rechten in de nieuwe school."""
|
||||
nieuw = school_op_code(nieuwe_code, ook_inactief=True)
|
||||
if not nieuw:
|
||||
return 0
|
||||
central_query("INSERT IGNORE INTO Gebruiker_rechten (Gebruiker_ID, School_ID, Onderdeel_ID, Toegekend_op, Toegekend_door) "
|
||||
"SELECT Gebruiker_ID, %s, Onderdeel_ID, NOW(), %s FROM Gebruiker_rechten WHERE School_ID = %s",
|
||||
(nieuw.id, door, bron.id), commit=True)
|
||||
return central_query("SELECT COUNT(DISTINCT Gebruiker_ID) AS n FROM Gebruiker_rechten WHERE School_ID = %s",
|
||||
(nieuw.id,))[0]['n']
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Verbindingstest
|
||||
# ------------------------------------------------------------------
|
||||
def test_school(school):
|
||||
"""Test elke ingestelde koppeling. Geeft een lijst (onderdeel, ok|None, detail)."""
|
||||
res = []
|
||||
with in_school(school):
|
||||
# Database (eerst tabelnamen met hoofdletters herstellen, voor een database die van Windows komt)
|
||||
try:
|
||||
from services.tabelnamen import herstel_school
|
||||
for m in herstel_school(school):
|
||||
res.append(('Tabelnamen', 'mislukt' not in m and 'samen' not in m, m))
|
||||
except Exception as e:
|
||||
res.append(('Tabelnamen', False, str(e)[:200]))
|
||||
try:
|
||||
n = execute_query("SELECT COUNT(*) AS n FROM information_schema.tables WHERE table_schema = DATABASE()")[0]['n']
|
||||
heeft = execute_query("SHOW TABLES LIKE 'Toestellen'")
|
||||
res.append(('Database', bool(heeft), f"{school.db_naam}: {n} tabellen"
|
||||
+ ('' if heeft else " – tabel Toestellen ontbreekt (python beheer.py nieuwe-schooldatabase)")))
|
||||
except Exception as e:
|
||||
res.append(('Database', False, str(e)))
|
||||
|
||||
# Smartschool webservices
|
||||
if school.heeft('SS_API_PLATFORM', 'SS_API_PW'):
|
||||
try:
|
||||
from services.smartschool_api import SmartschoolService
|
||||
from config import Config
|
||||
ss = SmartschoolService(Config.SS_API_PLATFORM, Config.SS_API_PW)
|
||||
raw = ss.client.service.getClassListJson(Config.SS_API_PW)
|
||||
if isinstance(raw, (int, float)) or (isinstance(raw, str) and raw.strip().lstrip('-').isdigit()):
|
||||
res.append(('Smartschool webservices', False, f"foutcode {raw} (wachtwoord of rechten)"))
|
||||
else:
|
||||
res.append(('Smartschool webservices', True, Config.SS_API_PLATFORM))
|
||||
except Exception as e:
|
||||
res.append(('Smartschool webservices', False, str(e)[:200]))
|
||||
else:
|
||||
res.append(('Smartschool webservices', None, 'niet ingesteld'))
|
||||
|
||||
# Smartschool OneRoster
|
||||
if school.heeft('SS_API_PLATFORM', 'SS_CLIENT_ID', 'SS_CLIENT_SECRET'):
|
||||
try:
|
||||
from config import Config
|
||||
r = requests.post(Config.SS_TOKEN_URL, data={"grant_type": "client_credentials"},
|
||||
auth=(Config.SS_CLIENT_ID, Config.SS_CLIENT_SECRET), timeout=10)
|
||||
res.append(('Smartschool OneRoster', r.ok and bool(r.json().get('access_token')),
|
||||
'token ontvangen' if r.ok else f"HTTP {r.status_code}"))
|
||||
except Exception as e:
|
||||
res.append(('Smartschool OneRoster', False, str(e)[:200]))
|
||||
else:
|
||||
res.append(('Smartschool OneRoster', None, 'niet ingesteld'))
|
||||
|
||||
# Informat: eerst de leerlingen-API; lukt die niet, dan de Internaat-API (een internaat gebruikt in
|
||||
# Informat geen leerlingen maar internen, dus de leerlingen-scope wordt daar geweigerd)
|
||||
if _informat_ingesteld(school):
|
||||
fout = None
|
||||
if school.heeft('INFORMAT_INST_NUMBER'):
|
||||
try:
|
||||
from services.sync_service import SyncService
|
||||
if SyncService().get_informat_token():
|
||||
res.append(('Informat', True, 'token ontvangen (leerlingen)'))
|
||||
else:
|
||||
fout = 'authenticatie mislukt'
|
||||
except Exception as e:
|
||||
fout = str(e)[:200]
|
||||
else:
|
||||
fout = 'geen instellingsnummer voor leerlingen'
|
||||
if fout is not None:
|
||||
try:
|
||||
from services.internaat_service import InternaatService
|
||||
svc = InternaatService()
|
||||
if not svc.nummers:
|
||||
raise RuntimeError('geen instellingsnummer')
|
||||
for nr in svc.nummers:
|
||||
svc._token(nr)
|
||||
res.append(('Informat', True, f"token ontvangen (internaat {', '.join(svc.nummers)})"))
|
||||
except Exception as e:
|
||||
res.append(('Informat', False, f"{fout}; internaat: {str(e)[:150]}"))
|
||||
else:
|
||||
res.append(('Informat', None, 'niet ingesteld'))
|
||||
|
||||
# Google
|
||||
if school.google_sleutel and not school.heeft('ADMIN_EMAIL'):
|
||||
res.append(('Google Workspace', False, "Sleutel staat erin, maar 'Beheerder (Google-admin)' is nog niet "
|
||||
"ingevuld (Algemeen). PIVOT moet weten namens welk beheerdersaccount het werkt."))
|
||||
elif school.google_sleutel:
|
||||
try:
|
||||
from services.google_service import GoogleAdminService
|
||||
svc = GoogleAdminService()
|
||||
svc.service.users().list(customer='my_customer', maxResults=1).execute()
|
||||
res.append(('Google Workspace', True, f"namens {school.get('ADMIN_EMAIL')}"))
|
||||
except Exception as e:
|
||||
res.append(('Google Workspace', False, google_fout_uitleg(str(e), school)))
|
||||
else:
|
||||
res.append(('Google Workspace', None, 'geen serviceaccount-sleutel'))
|
||||
|
||||
# Signpost
|
||||
if school.heeft('M4S_CLIENT_ID', 'M4S_CLIENT_SECRET'):
|
||||
from services.signpost_byod_service import get_signpost_token
|
||||
ok = bool(get_signpost_token())
|
||||
res.append(('Signpost / M4S', ok, 'token ontvangen' if ok else 'authenticatie mislukt'))
|
||||
else:
|
||||
res.append(('Signpost / M4S', None, 'niet ingesteld'))
|
||||
return res
|
||||
|
||||
|
||||
def google_fout_uitleg(fout, school):
|
||||
"""Vertaalt de meest voorkomende Google-fouten naar wat je moet nakijken."""
|
||||
admin = school.get('ADMIN_EMAIL')
|
||||
f = fout.lower()
|
||||
# Bij een HttpError staat de eigenlijke reden na 'returned "...": die tonen i.p.v. de lange URL
|
||||
m = re.search(r'returned "([^"]+)"', fout)
|
||||
reden = m.group(1) if m else fout
|
||||
if 'has not been used' in f or 'is disabled' in f or 'accessnotconfigured' in f or 'service_disabled' in f:
|
||||
uitleg = ("De Admin SDK API staat nog niet aan in het Google Cloud-project van het serviceaccount "
|
||||
"(console.cloud.google.com › API's en services › Bibliotheek › Admin SDK API › Inschakelen).")
|
||||
elif 'unauthorized_client' in f:
|
||||
uitleg = ("De domeinbrede delegatie is (nog) niet in orde: controleer in admin.google.com › Beveiliging › "
|
||||
"API-beheer › Domeinbrede delegatie de client-ID (Unieke ID van het serviceaccount) en de OAuth-bereiken. "
|
||||
"Een nieuwe instelling kan tot een uur duren.")
|
||||
elif 'invalid_grant' in f:
|
||||
uitleg = (f"Google kent het beheerdersaccount '{admin}' niet of het is geschorst. "
|
||||
"Vul bij 'Beheerder (Google-admin)' een bestaand account van dit domein in.")
|
||||
elif 'not authorized' in f or '403' in f:
|
||||
uitleg = (f"'{admin}' mag de gebruikerslijst niet lezen in Google. Controleer in admin.google.com › Account › "
|
||||
"Beheerdersrollen dat dit account superbeheerder is (een gewone gebruiker of een beheerder met een "
|
||||
"beperkte rol volstaat niet), en dat het in dezelfde Google-organisatie zit als de domeinbrede delegatie.")
|
||||
elif 'invalid input' in f:
|
||||
uitleg = (f"Google aanvaardt '{admin}' niet als beheerder van dit domein. Controleer of 'Beheerder (Google-admin)' "
|
||||
"een echt beheerdersaccount is (geen groep of alias) van hetzelfde domein als het serviceaccount.")
|
||||
else:
|
||||
return fout[:300]
|
||||
return f"{uitleg} (Google: {reden[:200]})"
|
||||
|
||||
|
||||
@scholen_bp.route('/<code>/test', methods=['POST'])
|
||||
def test(code):
|
||||
if not session.get('auth'):
|
||||
return redirect(url_for('auth.login'))
|
||||
if not session.get('superbeheerder') and not rechten.heeft_recht('koppelingen'):
|
||||
flash("Onvoldoende rechten voor deze actie.", "error")
|
||||
return redirect(url_for('main.dashboard'))
|
||||
if not session.get('superbeheerder') and code != session.get('school'):
|
||||
flash("Je kan enkel je eigen school testen.", "error")
|
||||
return redirect(url_for('main.dashboard'))
|
||||
school = school_op_code(code, ook_inactief=True)
|
||||
if not school:
|
||||
flash("Onbekende school.", "error")
|
||||
return redirect(url_for('main.dashboard'))
|
||||
resultaten = test_school(school)
|
||||
try:
|
||||
bewaar_test(school, resultaten)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"PIVOT: testresultaat bewaren mislukt: {e}\n")
|
||||
return render_template('school_test.html', school=school, resultaten=resultaten)
|
||||
|
||||
|
||||
@scholen_bp.route('/alles-testen', methods=['POST'])
|
||||
@superbeheerder_required
|
||||
def alles_testen():
|
||||
"""Test de koppelingen van alle actieve scholen en werkt de badges in het overzicht bij."""
|
||||
goed, fout = 0, []
|
||||
for school in alle_scholen():
|
||||
try:
|
||||
res = test_school(school)
|
||||
bewaar_test(school, res)
|
||||
if any(ok is False for _, ok, _ in res):
|
||||
fout.append(school.naam)
|
||||
else:
|
||||
goed += 1
|
||||
except Exception as e:
|
||||
fout.append(f"{school.naam} ({e})")
|
||||
flash(f"{goed} school/scholen zonder fouten." + (f" Met fouten: {', '.join(fout)}." if fout else ""),
|
||||
"warning" if fout else "success")
|
||||
return redirect(url_for('scholen.overzicht'))
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Smartschool › SGR groepen: subgroepen van de hoofdgroep (standaard 'groepen GO-Antwerpen') met hun leden."""
|
||||
import csv
|
||||
import io
|
||||
import time
|
||||
from datetime import date
|
||||
|
||||
from flask import Blueprint, render_template, request, Response, redirect, url_for, flash
|
||||
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist
|
||||
from services import sgr_groepen_service as sgr
|
||||
from tenancy import huidige_school
|
||||
|
||||
sgr_groepen_bp = Blueprint('sgr_groepen', __name__)
|
||||
|
||||
|
||||
def _gevraagd():
|
||||
return (request.args.get('groep') or '').strip() or None
|
||||
|
||||
|
||||
@sgr_groepen_bp.route("/", methods=["GET"])
|
||||
@login_required
|
||||
@recht_vereist('sgr_groepen')
|
||||
def overzicht():
|
||||
school = huidige_school()
|
||||
standaard = (school.get('SS_SGR_GROEP') or sgr.STANDAARD_GROEP).strip()
|
||||
gevraagd = _gevraagd()
|
||||
hoofd, groepen, fout, opgehaald = None, [], None, None
|
||||
try:
|
||||
hoofd, groepen, t = sgr.overzicht(gevraagd, vernieuw=bool(request.args.get('vernieuw')))
|
||||
opgehaald = time.strftime('%d-%m-%Y %H:%M', time.localtime(t))
|
||||
except sgr.SgrFout as e:
|
||||
fout = str(e)
|
||||
except Exception as e:
|
||||
fout = f"Groepen ophalen uit Smartschool mislukt: {e}"
|
||||
if request.args.get('vernieuw'):
|
||||
return redirect(url_for('sgr_groepen.overzicht', groep=gevraagd))
|
||||
leden = [l for g in groepen for l in g['leden']]
|
||||
return render_template("sgr_groepen.html", hoofd=hoofd, groepen=groepen, fout=fout, opgehaald=opgehaald,
|
||||
groep=gevraagd or standaard, standaard=standaard, school_naam=school.naam,
|
||||
aantal_leden=len(leden),
|
||||
uniek=len({l['gebruikersnaam'] or (l['naam'], l['voornaam']) for l in leden}))
|
||||
|
||||
|
||||
@sgr_groepen_bp.route("/export.csv", methods=["GET"])
|
||||
@login_required
|
||||
@recht_vereist('sgr_groepen')
|
||||
def export():
|
||||
try:
|
||||
hoofd, groepen, _ = sgr.overzicht(_gevraagd())
|
||||
except Exception as e:
|
||||
flash(f"Kon niet exporteren: {e}", "error")
|
||||
return redirect(url_for('sgr_groepen.overzicht', groep=_gevraagd()))
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf, delimiter=';')
|
||||
w.writerow(['Groep', 'Pad', 'Groepscode', 'Naam', 'Voornaam', 'Gebruikersnaam', 'Rol', 'E-mail'])
|
||||
for g in groepen:
|
||||
pad = ' › '.join(g['pad'] + [g['naam']])
|
||||
if not g['leden']:
|
||||
w.writerow([g['naam'], pad, g['code'], '', '', '', '', ''])
|
||||
for l in g['leden']:
|
||||
w.writerow([g['naam'], pad, g['code'], l['naam'], l['voornaam'], l['gebruikersnaam'], l['rol'], l['email']])
|
||||
naam = ''.join(c if c.isalnum() else '_' for c in hoofd['naam'])[:40]
|
||||
return Response('' + buf.getvalue(), mimetype='text/csv',
|
||||
headers={'Content-Disposition': f'attachment; filename=SGR_{naam}_{date.today():%Y%m%d}.csv'})
|
||||
@@ -0,0 +1,135 @@
|
||||
import requests
|
||||
from flask import Blueprint, render_template, request, flash, redirect, url_for
|
||||
from database import execute_query
|
||||
from services import toestellen as tst
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist
|
||||
# Importeer de benodigdheden uit je bestaande service-bestand
|
||||
from services.signpost_byod_service import BASE_DATA_URL, get_signpost_token, signpost_token_met_reden
|
||||
|
||||
signpost_devices_bp = Blueprint('signpost_devices', __name__)
|
||||
|
||||
def sync_signpost_devices_to_db():
|
||||
"""
|
||||
Haalt alle toestellen op via de Signpost-API en voegt toestellen die
|
||||
nog niet bestaan (op basis van serienummer) toe aan Signpost_database.
|
||||
"""
|
||||
token, fout = signpost_token_met_reden()
|
||||
if not token:
|
||||
return False, fout
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
|
||||
page_number = 1
|
||||
page_size = 100
|
||||
toegevoegd_teller = 0
|
||||
overgeslagen_teller = 0
|
||||
|
||||
print("Starten met synchroniseren van toestellen naar Signpost_database...")
|
||||
|
||||
while True:
|
||||
params = {
|
||||
"$top": page_size,
|
||||
"$skip": (page_number - 1) * page_size
|
||||
}
|
||||
|
||||
url = "https://byod-api.signpost.be/v1/devices"
|
||||
|
||||
try:
|
||||
res = requests.get(url, headers=headers, params=params, timeout=15)
|
||||
if res.status_code != 200:
|
||||
print(f"Fout bij pagina {page_number}: {res.status_code}")
|
||||
return False, f"Signpost API gaf foutcode {res.status_code} op pagina {page_number}."
|
||||
|
||||
data = res.json()
|
||||
items = data.get("items", [])
|
||||
|
||||
if not items:
|
||||
break
|
||||
|
||||
for item in items:
|
||||
signpost_nummer = item.get("signpostLabel")
|
||||
serienummer = item.get("serialNumber")
|
||||
model = item.get("modelName")
|
||||
warranty_void_raw = item.get("warrantyVoidAt")
|
||||
|
||||
garantie = warranty_void_raw.split("T")[0] if warranty_void_raw else None
|
||||
|
||||
if not serienummer:
|
||||
continue
|
||||
|
||||
# Bestaat het toestel al (op serienummer)? Zo niet: aanmaken in Toestellen.
|
||||
bestaand = execute_query(
|
||||
"SELECT t.Toestel_ID, sp.Toestel_ID AS heeft_signpost FROM Toestellen t "
|
||||
"LEFT JOIN Signpost_DB sp ON sp.Toestel_ID = t.Toestel_ID WHERE t.Serienummer = %s LIMIT 1",
|
||||
(serienummer,))
|
||||
if bestaand and bestaand[0]['heeft_signpost']:
|
||||
overgeslagen_teller += 1
|
||||
continue
|
||||
toestel_id = bestaand[0]['Toestel_ID'] if bestaand else tst.nieuw_toestel(
|
||||
serienummer=serienummer, type_toestel=model)
|
||||
execute_query(
|
||||
"INSERT INTO Signpost_DB (Toestel_ID, Signpostnummer, Garantie) VALUES (%s, %s, %s) "
|
||||
"ON DUPLICATE KEY UPDATE Signpostnummer = VALUES(Signpostnummer), Garantie = VALUES(Garantie)",
|
||||
(toestel_id, signpost_nummer or None, garantie), commit=True)
|
||||
toegevoegd_teller += 1
|
||||
|
||||
if len(items) < page_size:
|
||||
break
|
||||
|
||||
page_number += 1
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"Fout tijdens database/API sync: {e}")
|
||||
traceback.print_exc() # Dit print de échte boosdoener in je terminal (bijv. IndexError of MySQL-fout)
|
||||
return False, f"Er is een fout opgetreden tijdens de synchronisatie: {str(e)}"
|
||||
|
||||
return True, f"Synchronisatie voltooid! {toegevoegd_teller} nieuw(e) toestel(len) toegevoegd. {overgeslagen_teller} bestaand(e) toestel(len) overgeslagen."
|
||||
|
||||
@signpost_devices_bp.route('/signpost_database', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@recht_vereist('signpost')
|
||||
def signpost_database_overzicht():
|
||||
# Komt overeen met jouw methode om de query op te vangen bij GET of POST
|
||||
query = request.form.get('query') if request.method == 'POST' else request.args.get('query')
|
||||
|
||||
# De basis-query aangepast naar de tabel Signpost_database en de gevraagde kolommen
|
||||
basis_sql = """
|
||||
SELECT sp.Signpostnummer, t.Serienummer AS Device_serienummer, t.Type AS Device_model, sp.Garantie,
|
||||
sp.Aankoopbedrag, sp.Aankoopdatum, t.Schooleigen_ID
|
||||
FROM Signpost_DB sp
|
||||
JOIN Toestellen t ON t.Toestel_ID = sp.Toestel_ID
|
||||
"""
|
||||
|
||||
if query:
|
||||
# Als er gezocht wordt, zoeken we veilig op serienummer of signpostnummer
|
||||
sql = basis_sql + " WHERE t.Serienummer LIKE %s OR sp.Signpostnummer LIKE %s ORDER BY Signpostnummer DESC"
|
||||
params = (f"%{query}%", f"%{query}%")
|
||||
results = execute_query(sql, params)
|
||||
else:
|
||||
# Als er geen zoekterm is, halen we alles op
|
||||
sql = basis_sql + " ORDER BY Signpostnummer DESC"
|
||||
results = execute_query(sql)
|
||||
|
||||
# We sturen de resultaten door naar de HTML-pagina
|
||||
return render_template('signpost_devices.html', results=results, query=query)
|
||||
|
||||
|
||||
@signpost_devices_bp.route('/sync_signpost', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('signpost')
|
||||
def sync_signpost():
|
||||
# Voer de vernieuwde synchronisatie uit
|
||||
succes, melding = sync_signpost_devices_to_db()
|
||||
|
||||
if succes:
|
||||
flash(melding, 'success')
|
||||
else:
|
||||
flash(melding, 'danger')
|
||||
|
||||
# Stuur de gebruiker terug naar het overzicht
|
||||
return redirect(url_for('signpost_devices.signpost_database_overzicht'))
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Skore-synchronisatie in Smartschool: manueel starten, planning instellen en logboek bekijken."""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
|
||||
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist
|
||||
import rechten
|
||||
from services import skore_service as skore
|
||||
|
||||
skore_bp = Blueprint('skore', __name__)
|
||||
|
||||
|
||||
@skore_bp.route('/', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('skore')
|
||||
def overzicht():
|
||||
try:
|
||||
p = skore.planning()
|
||||
log = skore.logboek()
|
||||
except Exception as e:
|
||||
flash(f"Kon de Skore-gegevens niet lezen: {e}", "error")
|
||||
p, log = None, []
|
||||
try:
|
||||
loopt = skore.bezig()
|
||||
except Exception:
|
||||
loopt = False
|
||||
return render_template('skore.html', planning=p, log=log, frequenties=skore.FREQUENTIES, loopt=loopt,
|
||||
weekdagen=skore.WEEKDAGEN, volgende=skore.volgende_moment(p) if p else None)
|
||||
|
||||
|
||||
@skore_bp.route('/start', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('skore')
|
||||
def start():
|
||||
rij = skore.start(session.get('user'), manier='manueel')
|
||||
rechten.log(actie="Skore-synchronisatie gestart")
|
||||
if rij['Status'] == 'mislukt':
|
||||
flash(rij['Melding'], "error")
|
||||
else:
|
||||
flash("Skore-synchronisatie gestart bij Smartschool. PIVOT volgt de status automatisch op; "
|
||||
"deze pagina ververst vanzelf tot ze klaar is.", "success")
|
||||
return redirect(url_for('skore.overzicht'))
|
||||
|
||||
|
||||
@skore_bp.route('/status/<int:log_id>', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('skore')
|
||||
def status(log_id):
|
||||
rij = skore.controleer(log_id)
|
||||
if rij:
|
||||
flash(f"Status: {rij['Status']}", "info")
|
||||
return redirect(url_for('skore.overzicht'))
|
||||
|
||||
|
||||
@skore_bp.route('/planning', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('skore')
|
||||
def planning():
|
||||
f = request.form
|
||||
try:
|
||||
skore.bewaar_planning(f.get('frequentie', 'uit'), f.get('uur', 6), f.get('minuut', 0),
|
||||
f.get('weekdag', 0), f.get('dag_van_maand', 1), session.get('user'))
|
||||
rechten.log(actie=f"Skore-planning: {f.get('frequentie')}")
|
||||
flash("Planning opgeslagen.", "success")
|
||||
except (ValueError, TypeError) as e:
|
||||
flash(str(e) or "Ongeldige planning.", "error")
|
||||
return redirect(url_for('skore.overzicht'))
|
||||
@@ -0,0 +1,104 @@
|
||||
from flask import Blueprint, render_template, request, flash, jsonify, redirect, url_for, Response
|
||||
from services.smartschool_api import SmartschoolService
|
||||
from config import Config
|
||||
from services.google_service import GoogleAdminService
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist
|
||||
from services.coaccounts_service import CoAccountsService
|
||||
from tenancy import huidige_school
|
||||
# Definieer de blueprint
|
||||
ss_bp = Blueprint('smartschool', __name__)
|
||||
|
||||
|
||||
|
||||
|
||||
@ss_bp.route('/coaccount-overzicht', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('smartschool_activiteit')
|
||||
def coaccount_overzicht():
|
||||
"""Alle co-accounts van alle leerlingen (klas, leerling, laatste aanmelding). Standaard enkel wie nog nooit
|
||||
aanmeldde. De gegevens worden op de achtergrond opgehaald als ze ouder zijn dan een dag."""
|
||||
svc = CoAccountsService()
|
||||
rows, fout, laatst = [], None, None
|
||||
ingesteld = huidige_school().heeft('SS_API_PLATFORM', 'SS_API_PW')
|
||||
try:
|
||||
svc.ensure_tables()
|
||||
if ingesteld and svc.verouderd():
|
||||
svc.sync_op_achtergrond()
|
||||
rows, laatst = svc.alle(), svc.laatst_opgehaald()
|
||||
except Exception as e:
|
||||
fout = f"Gegevens konden niet gelezen worden: {e}"
|
||||
return render_template('smartschool_activiteit.html', rows=rows, fout=fout, laatst=laatst,
|
||||
ingesteld=ingesteld, status=svc.status,
|
||||
nooit=sum(1 for r in rows if not r['Laatste_login']),
|
||||
leerlingen=len({r['Internnummer'] for r in rows}))
|
||||
|
||||
|
||||
@ss_bp.route('/coaccount-overzicht/ophalen', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('smartschool_activiteit')
|
||||
def coaccount_ophalen():
|
||||
if not huidige_school().heeft('SS_API_PLATFORM', 'SS_API_PW'):
|
||||
flash("Smartschool is nog niet ingesteld voor deze school (🔌 Koppelingen › Smartschool).", "error")
|
||||
elif CoAccountsService().sync_op_achtergrond():
|
||||
flash("Co-accounts worden opgehaald uit Smartschool. Dit kan enkele minuten duren.", "info")
|
||||
else:
|
||||
flash("Er loopt al een ophaling.", "warning")
|
||||
return redirect(url_for('smartschool.coaccount_overzicht'))
|
||||
|
||||
|
||||
@ss_bp.route('/coaccount-overzicht/status.json', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('smartschool_activiteit')
|
||||
def coaccount_status():
|
||||
s = CoAccountsService().status
|
||||
return jsonify(bezig=s['bezig'], voortgang=s['voortgang'], totaal=s['totaal'], fout=s['fout'],
|
||||
resultaat=s['resultaat'])
|
||||
|
||||
|
||||
@ss_bp.route('/coaccount-overzicht/export.csv', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('smartschool_activiteit')
|
||||
def coaccount_export():
|
||||
"""CSV met dezelfde filters als de tabel: ?nooit=1 (enkel nog nooit aangemeld) en ?zoek=..."""
|
||||
import csv, io
|
||||
from datetime import date
|
||||
rows = CoAccountsService().alle()
|
||||
if request.args.get('nooit') == '1':
|
||||
rows = [r for r in rows if not r['Laatste_login']]
|
||||
zoek = (request.args.get('zoek') or '').strip().lower()
|
||||
if zoek:
|
||||
velden = ('Klas', 'Leerling_voornaam', 'Leerling_naam', 'Gebruikersnaam', 'Internnummer', 'Voornaam', 'Naam', 'Email')
|
||||
rows = [r for r in rows if zoek in ' '.join(str(r[v] or '') for v in velden).lower()]
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf, delimiter=';') # ; zodat Excel (Belgische instellingen) de kolommen meteen splitst
|
||||
w.writerow(['Klas', 'Leerling naam', 'Leerling voornaam', 'Internnummer', 'Co-account voornaam', 'Co-account naam',
|
||||
'Type', 'E-mail', 'Laatste aanmelding'])
|
||||
for r in rows:
|
||||
w.writerow([r['Klas'] or '', r['Leerling_naam'] or '', r['Leerling_voornaam'] or '', r['Internnummer'],
|
||||
r['Voornaam'] or '', r['Naam'] or '', r['Type'] or '', r['Email'] or '',
|
||||
r['Laatste_login'].strftime('%d-%m-%Y %H:%M') if r['Laatste_login'] else 'Nooit'])
|
||||
naam = f"co-accounts{'_nooit_aangemeld' if request.args.get('nooit') == '1' else ''}_{date.today():%Y%m%d}.csv"
|
||||
return Response('\ufeff' + buf.getvalue(), mimetype='text/csv; charset=utf-8',
|
||||
headers={'Content-Disposition': f'attachment; filename={naam}'})
|
||||
|
||||
|
||||
@ss_bp.route('/send-reminder-email', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('smartschool_activiteit')
|
||||
def send_reminder():
|
||||
data = request.json
|
||||
email = data.get('email')
|
||||
subject = data.get('subject')
|
||||
message_body = data.get('message')
|
||||
|
||||
if not email or "@" not in email:
|
||||
return jsonify({"status": "error", "message": "Geen geldig e-mailadres gevonden voor dit co-account."}), 400
|
||||
|
||||
email_service = GoogleAdminService()
|
||||
success, msg = email_service.send_custom_mail(email, subject, message_body)
|
||||
|
||||
if success:
|
||||
return jsonify({"status": "success", "message": msg})
|
||||
else:
|
||||
return jsonify({"status": "error", "message": f"Fout bij verzenden: {msg}"}), 500
|
||||
@@ -0,0 +1,108 @@
|
||||
from flask import Blueprint, render_template, request, jsonify, make_response, flash
|
||||
from io import BytesIO # <-- Deze regel lost de NameError op
|
||||
from xhtml2pdf import pisa
|
||||
from datetime import datetime
|
||||
from services.smartschool_api import SmartschoolService
|
||||
from config import Config
|
||||
from database import execute_query
|
||||
from tenancy import per_school
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist
|
||||
|
||||
smartschool_bp = Blueprint("/ss",__name__)
|
||||
|
||||
# Eén Smartschool-client per school (met het webservice-wachtwoord van die school)
|
||||
ss_service = per_school(lambda: SmartschoolService(
|
||||
platform=Config.SS_API_PLATFORM,
|
||||
webservice_pwd=Config.SS_API_PW
|
||||
))
|
||||
|
||||
@smartschool_bp.route('/smartschool', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@recht_vereist('smartschool')
|
||||
def smartschool():
|
||||
results = []
|
||||
query = (request.form.get('query') or '').strip()
|
||||
if request.method == 'POST' and query:
|
||||
# 1) Exact internnummer of gebruikersnaam: rechtstreeks bij Smartschool
|
||||
try:
|
||||
user_data = ss_service.get_student_details(query)
|
||||
except Exception as e:
|
||||
user_data = None
|
||||
flash(f"Smartschool is niet bereikbaar of nog niet ingesteld voor deze school ({e}).", "error")
|
||||
if user_data:
|
||||
results = [user_data]
|
||||
else:
|
||||
# 2) Deel van een naam, voornaam of gebruikersnaam: zoeken in de leerlingen van PIVOT
|
||||
results = zoek_leerlingen_lokaal(query)
|
||||
return render_template('smartschool_beheer.html', results=results, query=query)
|
||||
|
||||
|
||||
MAX_RESULTATEN = 25
|
||||
|
||||
|
||||
def zoek_leerlingen_lokaal(query):
|
||||
"""Leerlingen uit de tabel Leerlingen (gevuld door de Informat-sync) die op de zoekterm lijken.
|
||||
Geeft dicts met dezelfde sleutels als Smartschool (internnummer, voornaam, naam, gebruikersnaam)."""
|
||||
zoek = f"%{query}%"
|
||||
rows = execute_query(
|
||||
"SELECT Internnummer, Voornaam, Naam, Gebruikersnaam FROM Leerlingen "
|
||||
"WHERE Internnummer = %s OR Gebruikersnaam LIKE %s OR Voornaam LIKE %s OR Naam LIKE %s "
|
||||
" OR CONCAT_WS(' ', Voornaam, Naam) LIKE %s OR CONCAT_WS(' ', Naam, Voornaam) LIKE %s "
|
||||
"ORDER BY Naam, Voornaam LIMIT %s",
|
||||
(query, zoek, zoek, zoek, zoek, zoek, MAX_RESULTATEN))
|
||||
return [{'internnummer': r['Internnummer'], 'voornaam': r['Voornaam'] or '', 'naam': r['Naam'] or '',
|
||||
'gebruikersnaam': r['Gebruikersnaam'] or ''} for r in rows]
|
||||
|
||||
@smartschool_bp.route('/smartschool/reset', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('smartschool')
|
||||
def smartschool_reset():
|
||||
identifier = request.form.get('username')
|
||||
new_pass = request.form.get('new_password')
|
||||
acc_type = int(request.form.get('account_type', 0))
|
||||
generate_pdf = request.form.get('generate_pdf') == 'true'
|
||||
display_name = request.form.get('display_name') # Ontvang de naam uit de browser
|
||||
|
||||
result = ss_service.reset_password(identifier, new_pass, acc_type)
|
||||
|
||||
if result == "0":
|
||||
if generate_pdf:
|
||||
# We gebruiken de display_name die we van de frontend hebben gekregen
|
||||
# Voor de e-mail label kunnen we nog steeds student details ophalen indien nodig
|
||||
student_data = ss_service.get_student_details(identifier)
|
||||
|
||||
email_label = student_data.get('gebruikersnaam') if acc_type == 0 else f"Co-account van {student_data['voornaam']}"
|
||||
|
||||
rendered_html = render_template(
|
||||
'pdf_template_leerling.html',
|
||||
naam=display_name,
|
||||
datum=datetime.now().strftime("%d/%m/%Y"),
|
||||
email=email_label,
|
||||
wachtwoord=new_pass
|
||||
)
|
||||
|
||||
pdf_out = BytesIO()
|
||||
pisa_status = pisa.CreatePDF(rendered_html, dest=pdf_out)
|
||||
|
||||
if pisa_status.err:
|
||||
return jsonify({"status": "error", "message": "PDF generatie mislukt"})
|
||||
|
||||
pdf_out.seek(0)
|
||||
response = make_response(pdf_out.read())
|
||||
response.headers['Content-Type'] = 'application/pdf'
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=Account_{identifier}.pdf'
|
||||
return response
|
||||
|
||||
return jsonify({"status": "success"})
|
||||
|
||||
return jsonify({"status": "error", "message": f"Code {result}"})
|
||||
|
||||
@smartschool_bp.route('/smartschool/coaccounts', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('smartschool')
|
||||
def smartschool_coaccounts():
|
||||
username = request.form.get('username')
|
||||
# Gebruik de nieuwe parser functie
|
||||
co_accounts = ss_service.get_co_accounts_from_details(username)
|
||||
return jsonify({"status": "success", "accounts": co_accounts})
|
||||
@@ -0,0 +1,190 @@
|
||||
from flask import Blueprint, render_template, request, session, redirect, url_for, flash
|
||||
from database import execute_query, get_db_connection
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist
|
||||
from services.google_service import GoogleAdminService
|
||||
from services.sync_service import SyncService, InformatSmartschoolSync
|
||||
from config import Config
|
||||
students_bp = Blueprint('students', __name__)
|
||||
|
||||
#-----------------------------------------------
|
||||
# --- ROUTE: INVENTARIS LEERLINGEN ---
|
||||
#-----------------------------------------------
|
||||
@students_bp.route('/inventaris', methods=["GET", "POST"])
|
||||
@login_required
|
||||
@recht_vereist('leerlingen_bekijken')
|
||||
def inventaris_leerlingen():
|
||||
query = request.form.get('query') if request.method == 'POST' else request.args.get('query')
|
||||
toon_vertrokken = (request.form.get('vertrokken') if request.method == 'POST' else request.args.get('vertrokken')) == '1'
|
||||
from services.sync_service import zorg_vertrokken_kolom
|
||||
zorg_vertrokken_kolom()
|
||||
|
||||
# De basis-query waarin we de tabellen 'Leerlingen' (l) en 'Schooljaar' (s) aan elkaar koppelen
|
||||
basis_sql = """
|
||||
SELECT
|
||||
l.Internnummer,
|
||||
l.Gebruikersnaam,
|
||||
l.Voornaam,
|
||||
l.Naam,
|
||||
l.SS_nummer,
|
||||
l.Google_adres,
|
||||
l.Vertrokken_op,
|
||||
s.Schooljaar AS Schooljaar -- Hier halen we de tekst (bijv. '2025-2026') op!
|
||||
FROM Leerlingen l
|
||||
LEFT JOIN Schooljaar s ON l.Schooljaar_ID = s.Schooljaar_ID
|
||||
"""
|
||||
|
||||
voorwaarden, params = [], []
|
||||
if query:
|
||||
voorwaarden.append("(l.Gebruikersnaam LIKE %s OR l.Internnummer LIKE %s OR l.Naam LIKE %s OR l.Voornaam LIKE %s)")
|
||||
params += [f"%{query}%"] * 4
|
||||
if not toon_vertrokken:
|
||||
voorwaarden.append("l.Vertrokken_op IS NULL")
|
||||
sql = basis_sql + (" WHERE " + " AND ".join(voorwaarden) if voorwaarden else "")
|
||||
data = execute_query(sql, tuple(params))
|
||||
vertrokken = (execute_query("SELECT COUNT(*) AS n FROM Leerlingen WHERE Vertrokken_op IS NOT NULL") or [{'n': 0}])[0]['n']
|
||||
|
||||
return render_template('Inventaris_Leerlingen.html', results=data, query=query,
|
||||
toon_vertrokken=toon_vertrokken, aantal_vertrokken=vertrokken)
|
||||
|
||||
#----------------------------------------
|
||||
# --- ROUTE: BEWERK GEBRUIKER ---
|
||||
#----------------------------------------
|
||||
@students_bp.route("/bewerk_gebruiker", methods=["POST"])
|
||||
@login_required
|
||||
@recht_vereist('leerlingen_bekijken')
|
||||
def bewerk_gebruiker():
|
||||
print("bewerken wordt uitgevoerd")
|
||||
internnummer = request.form.get("internnummer")
|
||||
gebruikersnaam = request.form.get("gebruikersnaam")
|
||||
ss_nummer = request.form.get("ss_nummer")
|
||||
google_adres = request.form.get('google_adres')
|
||||
naam= request.form.get('naam')
|
||||
voornaam= request.form.get('voornaam')
|
||||
query = request.form.get("query", "")
|
||||
|
||||
if not internnummer:
|
||||
flash("Fout: Geen internnummer ontvangen.", "error")
|
||||
return redirect(url_for("students.inventaris_leerlingen"))
|
||||
|
||||
sql = "UPDATE Leerlingen SET Gebruikersnaam = %s, SS_nummer = %s, Google_adres = %s, Naam = %s, Voornaam= %s WHERE Internnummer = %s"
|
||||
rows_affected = execute_query(sql, (gebruikersnaam, ss_nummer, google_adres, naam, voornaam, internnummer ), commit=True)
|
||||
|
||||
if rows_affected:
|
||||
flash(f"Gegevens voor {internnummer} succesvol bijgewerkt!", "success")
|
||||
else:
|
||||
flash("Er zijn geen wijzigingen opgeslagen.", "info")
|
||||
|
||||
return redirect(url_for("students.inventaris_leerlingen", query=query))
|
||||
|
||||
#----------------------------------------------------
|
||||
# --- ROUTE: SYNC GOOGLE ACCOUNTS ---
|
||||
#----------------------------------------------------
|
||||
@students_bp.route("/sync_google_accounts", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@recht_vereist('leerlingen_synchroniseren')
|
||||
def sync_google_accounts():
|
||||
"""Maakt Google accounts aan voor leerlingen die er nog geen hebben."""
|
||||
google = GoogleAdminService()
|
||||
|
||||
# Haal leerlingen op die nog geen email (SS_nummer) hebben
|
||||
lln_zonder_mail = execute_query("SELECT Internnummer, Gebruikersnaam FROM Leerlingen WHERE SS_nummer IS NULL OR SS_nummer = ''")
|
||||
|
||||
created_count = 0
|
||||
error_count = 0
|
||||
|
||||
for lln in lln_zonder_mail:
|
||||
gebruikersnaam_db = lln['Gebruikersnaam']
|
||||
# Splits naam (bijv: "Janssens.Jan")
|
||||
if "." in gebruikersnaam_db:
|
||||
achternaam, voornaam = gebruikersnaam_db.split(".", 1)
|
||||
else:
|
||||
achternaam, voornaam = gebruikersnaam_db, "lln"
|
||||
|
||||
# Genereer wachtwoord (bijv: 12345Jan)
|
||||
password = f"{lln['Internnummer']}{voornaam.capitalize()}"
|
||||
|
||||
# Gebruik je service om de gebruiker aan te maken
|
||||
success, message = google.create_user(
|
||||
first_name=voornaam,
|
||||
last_name=achternaam,
|
||||
password=password,
|
||||
org_unit_path=google.conf.OU_NEW_STUDENTS
|
||||
)
|
||||
|
||||
if success:
|
||||
# Update database met het nieuwe emailadres
|
||||
email = f"{voornaam.lower()}.{achternaam.lower()}@{google.conf.SCHOOL_DOMAIN}"
|
||||
execute_query("UPDATE Leerlingen SET SS_nummer = %s WHERE Internnummer = %s",
|
||||
(email, lln['Internnummer']), commit=True)
|
||||
created_count += 1
|
||||
else:
|
||||
print(f"Fout voor {gebruikersnaam_db}: {message}")
|
||||
error_count += 1
|
||||
|
||||
flash(f"Sync voltooid: {created_count} accounts aangemaakt, {error_count} fouten.", "success")
|
||||
return redirect(url_for("students.inventaris_leerlingen"))
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# --- ROUTE: SYNC INFORMAT (Gebruikmakend van SyncService) ---
|
||||
#-------------------------------------------------------------------
|
||||
@students_bp.route("/sync_informat", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@recht_vereist('leerlingen_synchroniseren')
|
||||
def sync_informat():
|
||||
"""Haalt de nieuwste leerlinggegevens op uit Informat."""
|
||||
#syncer = SyncService()
|
||||
syncer = InformatSmartschoolSync()
|
||||
#success, result = syncer.sync_leerlingen_informat()
|
||||
success, result = syncer.sync_actieve_leerlingen()
|
||||
|
||||
if success:
|
||||
flash(f"Synchronisatie gelukt: {result['melding']}", "success")
|
||||
if result.get('smartschool_fout'):
|
||||
flash("Smartschool kon niet bevraagd worden, dus gebruikersnamen en Smartschool-nummers zijn niet "
|
||||
f"aangepast (enkel de gegevens uit Informat). Reden: {result['smartschool_fout']}", "warning")
|
||||
else:
|
||||
flash(f"Synchronisatie mislukt: {result}", "error")
|
||||
|
||||
return redirect(url_for("students.inventaris_leerlingen"))
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# --- Leerling manueel toevoegen
|
||||
#-------------------------------------------------------------------
|
||||
|
||||
@students_bp.route('/registreer_leerling', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('leerlingen_synchroniseren')
|
||||
def registreer_leerling():
|
||||
print('Toevoegen gestart')
|
||||
# 1. Haal de gegevens uit het formulier op
|
||||
internnummer = request.form.get('inter_id')
|
||||
gebruikersnaam = request.form.get('gebruikersnaam')
|
||||
ss_nummer = request.form.get('ss_nummer')
|
||||
google_adres = request.form.get('google_adres')
|
||||
schooljaar_id= Config.SCHOOLYEAR[:4]
|
||||
naam= request.form.get('naam')
|
||||
voornaam= request.form.get('voornaam')
|
||||
|
||||
# 2. Validatie
|
||||
if not internnummer or not gebruikersnaam:
|
||||
flash('Zowel Internnummer als Gebruikersnaam zijn vereist.', 'danger')
|
||||
return redirect(url_for('students.inventaris_leerlingen'))
|
||||
|
||||
# 3. Opslaan in de database
|
||||
try:
|
||||
# We gebruiken geparametriseerde queries (%s) om SQL-injectie te voorkomen
|
||||
sql = "INSERT INTO Leerlingen (Internnummer, Gebruikersnaam, Naam, Voornaam, SS_nummer, Google_adres, Schooljaar_ID) " \
|
||||
"VALUES (%s, %s, %s, %s, %s, %s, %s)"
|
||||
params = (internnummer, gebruikersnaam,naam, voornaam, ss_nummer,google_adres, schooljaar_id)
|
||||
|
||||
execute_query(sql, params, commit=True)
|
||||
|
||||
flash(f'Leerling {gebruikersnaam} (ID: {internnummer}) succesvol toegevoegd aan de database!', 'success')
|
||||
except Exception as e:
|
||||
# Als bijvoorbeeld het internnummer al bestaat (Duplicate Entry)
|
||||
print(f"Database error: {e}")
|
||||
flash('Fout bij het registreren: Mogelijk bestaat dit Internnummer al.', 'danger')
|
||||
|
||||
# 4. Keer terug naar het overzicht
|
||||
return redirect(url_for('students.inventaris_leerlingen'))
|
||||
@@ -0,0 +1,44 @@
|
||||
from flask import Blueprint, render_template, request, session, redirect, url_for, flash
|
||||
from blueprints.auth import login_required
|
||||
from tenancy import huidige_school
|
||||
|
||||
tools_bp = Blueprint('tools', __name__)
|
||||
|
||||
|
||||
def _staat():
|
||||
"""Internetstatus en notitie: per school bijgehouden (in het geheugen)."""
|
||||
return huidige_school().object('internet_status', lambda: {'status': 1, 'notitie': ''})
|
||||
|
||||
|
||||
@tools_bp.route('/internet')
|
||||
def internet_status():
|
||||
"""Toont de huidige status van de internetverbinding."""
|
||||
staat = _staat()
|
||||
return render_template(
|
||||
'internet_connection.html',
|
||||
current_status=staat['status'],
|
||||
current_note=staat['notitie'],
|
||||
current_user=session.get('user')
|
||||
)
|
||||
|
||||
|
||||
@tools_bp.route('/toggle_internet', methods=['POST'])
|
||||
def toggle_internet():
|
||||
"""Wijzigt de status van de internetverbinding."""
|
||||
new_status = request.form.get('new_status', type=int)
|
||||
|
||||
if new_status in [0, 1]:
|
||||
_staat()['status'] = new_status
|
||||
flash(f"Status gewijzigd naar {'Online' if new_status == 1 else 'Offline'}", "success")
|
||||
else:
|
||||
flash("Ongeldige statuswaarde.", "error")
|
||||
|
||||
return redirect(url_for('tools.internet_status'))
|
||||
|
||||
|
||||
@tools_bp.route('/save_note', methods=['POST'])
|
||||
def save_note():
|
||||
"""Slaat de korte tekst op."""
|
||||
_staat()['notitie'] = request.form.get('status_note', '')
|
||||
flash("Notitie bijgewerkt!", "success")
|
||||
return redirect(url_for('tools.internet_status'))
|
||||
@@ -0,0 +1,177 @@
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
|
||||
from tenancy import huidige_school, per_school
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist
|
||||
from config import Config
|
||||
from services.tweestaps_service import TweestapsService, TweestapsError
|
||||
|
||||
tweestaps_bp = Blueprint('tweestaps', __name__)
|
||||
LOG_PER_PAGINA = 25 # standaard; te kiezen: 10 / 25 / 50 / 100 / alles (0)
|
||||
LOG_KEUZES = (10, 25, 50, 100, 0)
|
||||
service = per_school(TweestapsService)
|
||||
|
||||
_tables_ready = set() # scholen waarvoor de tabellen al gecontroleerd zijn
|
||||
|
||||
|
||||
def _ensure_tables():
|
||||
if huidige_school().code not in _tables_ready:
|
||||
try:
|
||||
service.ensure_tables()
|
||||
_tables_ready.add(huidige_school().code)
|
||||
except Exception as e:
|
||||
flash(f"Kon de 2FA-tabellen niet aanmaken: {e}", "error")
|
||||
|
||||
|
||||
def _render(email=None, codes=None, code_actie=None, fout=None):
|
||||
"""Bouwt de pagina op; codes worden enkel in deze response getoond, nooit bewaard of gelogd."""
|
||||
tab = request.args.get('tab', 'hulp')
|
||||
zoek = (request.args.get('zoek') or '').strip()
|
||||
email = email or (request.args.get('email') or '').strip()
|
||||
gebruiker, uitzondering, zoekresultaten, reset_wacht = None, None, [], None
|
||||
|
||||
if zoek and '@' not in zoek:
|
||||
zoekresultaten = service.search_users(zoek)
|
||||
if not zoekresultaten:
|
||||
fout = fout or f"Geen gebruikers gevonden voor '{zoek}'."
|
||||
elif zoek:
|
||||
email = zoek
|
||||
|
||||
if email:
|
||||
try:
|
||||
gebruiker = service.get_user(email)
|
||||
uitzondering = service.active_for(gebruiker['email'])
|
||||
reset_wacht = service.reset_bezig(gebruiker['email'])
|
||||
except TweestapsError as e:
|
||||
fout = fout or str(e)
|
||||
|
||||
try:
|
||||
actief = service.active_exceptions()
|
||||
# Aantal per pagina: uit ?logper=..., anders de vorige keuze (sessie), anders 25
|
||||
per = request.args.get('logper', type=int)
|
||||
if per in LOG_KEUZES:
|
||||
session['tweestaps_logper'] = per
|
||||
per = session.get('tweestaps_logper', LOG_PER_PAGINA)
|
||||
grootte = per or 100000 # 0 = alles
|
||||
log_pagina = max(1, request.args.get('logpagina', 1, type=int) or 1)
|
||||
logboek, log_totaal = service.recent_log(log_pagina, grootte)
|
||||
log_paginas = max(1, -(-log_totaal // grootte))
|
||||
if log_pagina > log_paginas:
|
||||
log_pagina = log_paginas
|
||||
logboek, log_totaal = service.recent_log(log_pagina, grootte)
|
||||
except Exception:
|
||||
actief, logboek, log_totaal, log_pagina, log_paginas, per = [], [], 0, 1, 1, LOG_PER_PAGINA
|
||||
|
||||
lijst, lijst_fout = None, None
|
||||
if tab == 'lijst':
|
||||
try:
|
||||
lijst = service.niet_ingesteld(force=request.args.get('vernieuw') == '1')
|
||||
lijst_fout = service.ou_melding() # sommige OU's bestaan niet: lijst tonen + waarschuwing
|
||||
except TweestapsError as e:
|
||||
lijst_fout = str(e)
|
||||
except Exception as e:
|
||||
lijst_fout = TweestapsService._friendly(e)
|
||||
|
||||
return render_template('tweestaps.html', tab=tab, zoek=zoek, zoekresultaten=zoekresultaten,
|
||||
gebruiker=gebruiker, uitzondering=uitzondering, reset_wacht=reset_wacht, codes=codes, code_actie=code_actie,
|
||||
fout=fout, actief=actief, logboek=logboek, log_totaal=log_totaal, log_pagina=log_pagina, log_per=per, log_keuzes=LOG_KEUZES,
|
||||
log_paginas=log_paginas, lijst=lijst, lijst_fout=lijst_fout,
|
||||
einde=service.einde_vandaag(), groep=service.exception_group,
|
||||
ous=Config.TWEESTAPS_OUS, nu=datetime.now())
|
||||
|
||||
|
||||
@tweestaps_bp.route('/', methods=['GET'])
|
||||
@login_required
|
||||
@recht_vereist('tweestaps_codes', 'tweestaps_uitzetten')
|
||||
def overzicht():
|
||||
_ensure_tables()
|
||||
return _render()
|
||||
|
||||
|
||||
#---------------------------------------------
|
||||
# Back-upcodes
|
||||
#---------------------------------------------
|
||||
@tweestaps_bp.route('/codes', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('tweestaps_codes')
|
||||
def codes():
|
||||
_ensure_tables()
|
||||
email = request.form.get('email', '')
|
||||
actie = request.form.get('actie', 'tonen')
|
||||
door = session.get('user')
|
||||
try:
|
||||
user = service.get_user(email)
|
||||
if actie == 'ongeldig':
|
||||
service.invalidate_codes(user['email'])
|
||||
service.log(door, 'codes ongeldig gemaakt', user['email'])
|
||||
flash(f"Alle back-upcodes van {user['email']} zijn ongeldig gemaakt.", "success")
|
||||
return redirect(url_for('tweestaps.overzicht', email=user['email']))
|
||||
|
||||
bestaand = [] if actie == 'nieuw' else service.list_codes(user['email'])
|
||||
if bestaand:
|
||||
lijst = bestaand
|
||||
service.log(door, 'code getoond', user['email'])
|
||||
else:
|
||||
lijst = service.generate_codes(user['email'])
|
||||
service.log(door, 'nieuwe codes', user['email'], f"{len(lijst)} codes")
|
||||
return _render(email=user['email'], codes=lijst, code_actie='nieuw' if not bestaand else 'bestaand')
|
||||
except TweestapsError as e:
|
||||
return _render(email=email, fout=str(e))
|
||||
|
||||
|
||||
#---------------------------------------------
|
||||
# Tijdelijke uitzondering
|
||||
#---------------------------------------------
|
||||
@tweestaps_bp.route('/uitzondering', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('tweestaps_uitzetten')
|
||||
def uitzondering():
|
||||
_ensure_tables()
|
||||
email = request.form.get('email', '')
|
||||
reden = (request.form.get('reden') or '').strip()
|
||||
eindtijd = service.einde_vandaag()
|
||||
try:
|
||||
user = service.grant_exception(email, eindtijd, session.get('user'), reden)
|
||||
flash(f"{user['email']} kan tot {eindtijd:%H:%M} zonder 2FA inloggen. Daarna zet PIVOT 2FA automatisch "
|
||||
"terug aan. Let op: het kan enkele minuten duren voor Google de uitzondering toepast.", "success")
|
||||
except TweestapsError as e:
|
||||
flash(str(e), "error")
|
||||
return redirect(url_for('tweestaps.overzicht', email=email.strip()))
|
||||
|
||||
|
||||
@tweestaps_bp.route('/reset', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('tweestaps_uitzetten')
|
||||
def reset():
|
||||
"""2FA resetten voor iemand met een nieuwe of verloren telefoon."""
|
||||
_ensure_tables()
|
||||
email = request.form.get('email', '')
|
||||
eindtijd = service.einde_vandaag()
|
||||
try:
|
||||
user = service.reset_2fa(email, eindtijd, session.get('user'), (request.form.get('reden') or '').strip())
|
||||
if user.get('wacht'):
|
||||
flash(f"{user['email']} staat nu in de uitzonderingsgroep, maar Google past dat pas na enkele minuten toe. "
|
||||
"PIVOT probeert de 2FA-reset elke minuut opnieuw (tot de uitzondering afloopt om "
|
||||
f"{eindtijd:%H:%M}). Je hoeft niets meer te doen; vernieuw deze pagina om de status te zien.", "info")
|
||||
return redirect(url_for('tweestaps.overzicht', email=email.strip()))
|
||||
tekst = f"2FA is uitgezet voor {user['email']}. Hij kan nu inloggen en 2FA opnieuw instellen met zijn nieuwe telefoon"
|
||||
if user.get('uitzondering'):
|
||||
tekst += f" (vóór {eindtijd:%H:%M}; daarna is 2FA weer verplicht)"
|
||||
flash(tekst + ". Het kan enkele minuten duren voor Google dit toepast.", "success")
|
||||
except TweestapsError as e:
|
||||
flash(str(e), "error")
|
||||
return redirect(url_for('tweestaps.overzicht', email=email.strip()))
|
||||
|
||||
|
||||
@tweestaps_bp.route('/uitzondering/<int:uitz_id>/stop', methods=['POST'])
|
||||
@login_required
|
||||
@recht_vereist('tweestaps_uitzetten')
|
||||
def stop_uitzondering(uitz_id):
|
||||
_ensure_tables()
|
||||
try:
|
||||
email = service.end_exception(uitz_id, session.get('user'))
|
||||
flash(f"2FA is opnieuw verplicht voor {email}.", "success")
|
||||
except TweestapsError as e:
|
||||
flash(str(e), "error")
|
||||
terug = request.form.get('terug')
|
||||
return redirect(url_for('tweestaps.overzicht', email=terug) if terug else url_for('tweestaps.overzicht'))
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Google › Verzendlijsten: Google Groepen van de school bekijken, aanmaken en aanpassen (leden en rollen)."""
|
||||
from flask import Blueprint, render_template, request, flash, redirect, url_for, session
|
||||
|
||||
from blueprints.auth import login_required
|
||||
from rechten import recht_vereist, log
|
||||
from services.verzendlijsten_service import (VerzendlijstenService, VerzendlijstFout, ROLLEN, WIE_MAILT, adressen)
|
||||
from tenancy import huidige_school
|
||||
|
||||
verzendlijsten_bp = Blueprint('verzendlijsten', __name__)
|
||||
|
||||
|
||||
@verzendlijsten_bp.route("/", methods=["GET"])
|
||||
@login_required
|
||||
@recht_vereist('verzendlijsten')
|
||||
def overzicht():
|
||||
lijsten, fout = [], None
|
||||
try:
|
||||
lijsten = VerzendlijstenService().alle()
|
||||
except Exception as e:
|
||||
fout = str(e)
|
||||
return render_template("verzendlijsten.html", lijsten=lijsten, fout=fout,
|
||||
domeinen=huidige_school().domeinen, wie_mailt=WIE_MAILT)
|
||||
|
||||
|
||||
@verzendlijsten_bp.route("/nieuw", methods=["POST"])
|
||||
@login_required
|
||||
@recht_vereist('verzendlijsten')
|
||||
def nieuw():
|
||||
deel = (request.form.get('adres') or '').strip().lower()
|
||||
domein = (request.form.get('domein') or '').strip().lower()
|
||||
email = deel if '@' in deel else f"{deel}@{domein}"
|
||||
svc = VerzendlijstenService()
|
||||
try:
|
||||
svc.aanmaken(email, request.form.get('naam'), request.form.get('beschrijving'))
|
||||
except VerzendlijstFout as e:
|
||||
flash(str(e), "error")
|
||||
return redirect(url_for('verzendlijsten.overzicht'))
|
||||
log(f"Verzendlijst aangemaakt: {email}")
|
||||
melding = f"Verzendlijst {email} aangemaakt."
|
||||
wie = request.form.get('wie_mailt')
|
||||
if wie:
|
||||
try:
|
||||
svc.zet_wie_mailt(email, wie)
|
||||
except VerzendlijstFout as e:
|
||||
melding += f" 'Wie mag mailen' kon niet ingesteld worden: {e}"
|
||||
flash(melding + " Google kan er een minuutje over doen voor ze overal zichtbaar is.", "success")
|
||||
return redirect(url_for('verzendlijsten.detail', email=email))
|
||||
|
||||
|
||||
@verzendlijsten_bp.route("/<email>", methods=["GET"])
|
||||
@login_required
|
||||
@recht_vereist('verzendlijsten')
|
||||
def detail(email):
|
||||
svc = VerzendlijstenService()
|
||||
try:
|
||||
groep = svc.groep(email)
|
||||
leden = svc.leden(email)
|
||||
except VerzendlijstFout as e:
|
||||
flash(str(e), "error")
|
||||
return redirect(url_for('verzendlijsten.overzicht'))
|
||||
return render_template("verzendlijst.html", groep=groep, leden=leden, rollen=ROLLEN,
|
||||
wie_mailt=WIE_MAILT, huidig_wie=svc.wie_mailt(email))
|
||||
|
||||
|
||||
@verzendlijsten_bp.route("/<email>/bewerken", methods=["POST"])
|
||||
@login_required
|
||||
@recht_vereist('verzendlijsten')
|
||||
def bewerken(email):
|
||||
svc = VerzendlijstenService()
|
||||
try:
|
||||
svc.bijwerken(email, request.form.get('naam'), request.form.get('beschrijving'))
|
||||
if request.form.get('wie_mailt'):
|
||||
svc.zet_wie_mailt(email, request.form.get('wie_mailt'))
|
||||
log(f"Verzendlijst aangepast: {email}")
|
||||
flash("Verzendlijst bijgewerkt.", "success")
|
||||
except VerzendlijstFout as e:
|
||||
flash(str(e), "error")
|
||||
return redirect(url_for('verzendlijsten.detail', email=email))
|
||||
|
||||
|
||||
@verzendlijsten_bp.route("/<email>/leden/toevoegen", methods=["POST"])
|
||||
@login_required
|
||||
@recht_vereist('verzendlijsten')
|
||||
def leden_toevoegen(email):
|
||||
goed, ongeldig = adressen(request.form.get('adressen'))
|
||||
if ongeldig:
|
||||
flash("Geen geldig e-mailadres, overgeslagen: " + ", ".join(ongeldig), "warning")
|
||||
if goed:
|
||||
gelukt, fouten = VerzendlijstenService().leden_toevoegen(email, goed, request.form.get('rol') or 'MEMBER')
|
||||
if gelukt:
|
||||
log(f"Verzendlijst {email}: {gelukt} lid/leden toegevoegd")
|
||||
flash(f"{gelukt} lid/leden toegevoegd.", "success")
|
||||
for adres, fout in fouten:
|
||||
flash(f"{adres}: {fout}", "error")
|
||||
elif not ongeldig:
|
||||
flash("Vul minstens één e-mailadres in.", "error")
|
||||
return redirect(url_for('verzendlijsten.detail', email=email))
|
||||
|
||||
|
||||
@verzendlijsten_bp.route("/<email>/leden/rol", methods=["POST"])
|
||||
@login_required
|
||||
@recht_vereist('verzendlijsten')
|
||||
def lid_rol(email):
|
||||
lid = request.form.get('lid') or ''
|
||||
try:
|
||||
VerzendlijstenService().lid_rol(email, lid, request.form.get('rol'))
|
||||
flash(f"Rol van {lid} aangepast.", "success")
|
||||
except VerzendlijstFout as e:
|
||||
flash(str(e), "error")
|
||||
return redirect(url_for('verzendlijsten.detail', email=email))
|
||||
|
||||
|
||||
@verzendlijsten_bp.route("/<email>/leden/verwijderen", methods=["POST"])
|
||||
@login_required
|
||||
@recht_vereist('verzendlijsten')
|
||||
def lid_verwijderen(email):
|
||||
lid = request.form.get('lid') or ''
|
||||
try:
|
||||
VerzendlijstenService().lid_verwijderen(email, lid)
|
||||
log(f"Verzendlijst {email}: {lid} verwijderd")
|
||||
flash(f"{lid} uit de verzendlijst gehaald.", "success")
|
||||
except VerzendlijstFout as e:
|
||||
flash(str(e), "error")
|
||||
return redirect(url_for('verzendlijsten.detail', email=email))
|
||||
Reference in new issue
Block a user