PIVOT 1.1.0

This commit is contained in:
marijn committed 2026-10-14 13:34:37 +02:00
commit 8a53234eb6
161 files changed
+31109

No files matched your search

View File
Whitespace-only changes.
+291
View File
@@ -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))
+157
View File
@@ -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))
+326
View File
@@ -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)
+67
View File
@@ -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"})
+74
View File
@@ -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'))
+62
View File
@@ -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))
+125
View File
@@ -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)
+115
View File
@@ -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)
+19
View File
@@ -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)
+100
View File
@@ -0,0 +1,100 @@
"""
Beheer › Database: tabellen van de school aanvullen vanuit Excel.
Toestellen:
GET /beheer/database/toestellen → pagina (upload + sjabloon)
GET /beheer/database/toestellen/sjabloon.xlsx → sjabloon downloaden
POST /beheer/database/toestellen/controleren → Excel lezen en voorbeeld tonen (niets bewaard)
POST /beheer/database/toestellen/importeren → bevestigen: het voorbeeld uitvoeren
"""
import json
from flask import Blueprint, render_template, request, Response, redirect, url_for, flash, session
from blueprints.auth import login_required
from database import execute_query
from rechten import recht_vereist, log
from services import toestellen_import as ti
from tenancy import huidige_school
database_bp = Blueprint('database_beheer', __name__)
@database_bp.route('/toestellen', methods=['GET'])
@login_required
@recht_vereist('database_toestellen')
def toestellen():
try:
aantal = execute_query("SELECT COUNT(*) AS n FROM Toestellen")[0]['n']
except Exception:
aantal = None
return render_template('database_toestellen.html', plan=None, aantal=aantal, kolommen=ti.KOLOMMEN,
statussen=ti.STATUSSEN, max_rijen=ti.MAX_RIJEN)
@database_bp.route('/toestellen/sjabloon.xlsx', methods=['GET'])
@login_required
@recht_vereist('database_toestellen')
def toestellen_sjabloon():
school = huidige_school()
data = ti.maak_sjabloon(school.naam)
return Response(data, mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={'Content-Disposition': f"attachment; filename=Sjabloon_toestellen_{school.code}.xlsx"})
@database_bp.route('/toestellen/controleren', methods=['POST'])
@login_required
@recht_vereist('database_toestellen')
def toestellen_controleren():
terug = redirect(url_for('database_beheer.toestellen'))
bestand = request.files.get('bestand')
if not bestand or not bestand.filename:
flash("Kies eerst een Excel-bestand.", "error")
return terug
if not bestand.filename.lower().endswith(('.xlsx', '.xlsm')):
flash("Enkel Excel-bestanden (.xlsx) kunnen ingelezen worden. Sla een .xls of .csv eerst op als .xlsx.", "error")
return terug
modus = 'overschrijven' if request.form.get('modus') == 'overschrijven' else 'aanvullen'
try:
rijen, onbekend = ti.lees_excel(bestand)
except ti.ImportFout as e:
flash(str(e), "error")
return terug
if not rijen:
flash("Het bestand bevat geen toestellen (enkel kolomkoppen of lege rijen).", "warning")
return terug
try:
plan = ti.maak_plan(rijen, modus)
except Exception as e:
flash(f"Controleren mislukt: {e}", "error")
return terug
return render_template('database_toestellen.html', plan=plan, bestandsnaam=bestand.filename, onbekend=onbekend,
rijen_json=json.dumps(rijen, ensure_ascii=False), kolommen=ti.KOLOMMEN,
statussen=ti.STATUSSEN, max_rijen=ti.MAX_RIJEN, aantal=None)
@database_bp.route('/toestellen/importeren', methods=['POST'])
@login_required
@recht_vereist('database_toestellen')
def toestellen_importeren():
terug = redirect(url_for('database_beheer.toestellen'))
modus = 'overschrijven' if request.form.get('modus') == 'overschrijven' else 'aanvullen'
try:
rijen = json.loads(request.form.get('rijen') or '[]')
if not isinstance(rijen, list) or len(rijen) > ti.MAX_RIJEN or not all(isinstance(r, dict) for r in rijen):
raise ValueError
except ValueError:
flash("De gegevens van het voorbeeld zijn ongeldig. Upload het bestand opnieuw.", "error")
return terug
try:
plan, nieuw, bijgewerkt = ti.voer_uit(rijen, modus, session.get('user'))
except Exception as e:
flash(f"Importeren mislukt, er is niets bewaard: {e}", "error")
return terug
t = plan['tellers']
log(f"Toestellen geïmporteerd uit Excel: {nieuw} nieuw, {bijgewerkt} bijgewerkt ({modus})")
ongewijzigd = f", {t['ongewijzigd']} ongewijzigd" if t['ongewijzigd'] else ""
flash(f"Import klaar: {nieuw} toestel(len) toegevoegd, {bijgewerkt} bijgewerkt{ongewijzigd}.", "success")
if t['fout']:
flash(f"{t['fout']} rij(en) met een fout werden overgeslagen.", "warning")
return terug
+603
View File
@@ -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
+96
View File
@@ -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"})
+116
View File
@@ -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'))
+156
View File
@@ -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))
+84
View File
@@ -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')})
+112
View File
@@ -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'))
+95
View File
@@ -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}'})
+378
View File
@@ -0,0 +1,378 @@
"""
Scholengroep › GO! Pro: personeelsleden van de actieve school uit Informat, met een rol per personeelslid en
functie, en een export in de kolommen van het GO! Pro-importbestand.
"""
import csv
import io
import time
from datetime import date
from flask import Blueprint, render_template, request, jsonify, Response, session, redirect, url_for, flash
from blueprints.auth import login_required
from config import Config
from rechten import recht_vereist
from services import gopro_service as gp
from tenancy import huidige_school
gopro_bp = Blueprint('gopro', __name__)
CACHE_SECONDEN = 15 * 60 # Informat niet bij elke klik opnieuw bevragen
def _rijen(vernieuw=False):
"""Personeel van de actieve school (15 minuten bewaard per school). De rollen komen altijd vers uit de database."""
school = huidige_school()
bewaard = school.cache.get('gopro')
if vernieuw or not bewaard or time.time() - bewaard[0] > CACHE_SECONDEN:
rijen = gp.GoProService().personeel(school.domein, Config.INSTELLINGSNUMMER)
bewaard = (time.time(), rijen)
school.cache['gopro'] = bewaard
gekozen = gp.keuzes()
for r in bewaard[1]:
gp.pas_keuzes_toe(r, gekozen)
gp.markeer_dubbel(bewaard[1])
return bewaard[1], bewaard[0]
def _niet_verzameld(rijen, per_soort=False):
"""Laat de personeelsleden weg die al in de verzameltabel of in de rode lijst (genegeerd) staan.
Geeft (rijen, aantal weggelaten) of met per_soort=True (rijen, {'groen': n, 'genegeerd': m})."""
sleutels = gp.verzameld_sleutels()
code = huidige_school().code
over, weg = [], {'groen': 0, 'genegeerd': 0}
for r in rijen:
soort = gp.is_verzameld(r, code, sleutels)
if soort:
weg[soort] = weg.get(soort, 0) + 1
else:
over.append(r)
return over, (weg if per_soort else len(rijen) - len(over))
@gopro_bp.route("/", methods=["GET"])
@login_required
@recht_vereist('gopro')
def overzicht():
school = huidige_school()
rijen, fout, opgehaald, verzameld_hier = [], None, None, {'groen': 0, 'genegeerd': 0, 'verzonden': 0}
if not school.heeft('INFORMAT_INST_NUMBER', 'INFORMAT_ID', 'INFORMAT_SECRET'):
fout = "Informat is nog niet ingesteld voor deze school (🔌 Koppelingen › Informat)."
else:
try:
rijen, t = _rijen(vernieuw=bool(request.args.get('vernieuw')))
rijen, verzameld_hier = _niet_verzameld(rijen, per_soort=True) # verzameld of genegeerd: niet meer tonen
opgehaald = time.strftime('%d-%m-%Y %H:%M', time.localtime(t))
except Exception as e:
fout = str(e)
return render_template("gopro.html", rijen=rijen, fout=fout, opgehaald=opgehaald, rollen=gp.ROLLEN,
domein=school.domein, school_naam=school.naam,
dubbel=sum(1 for r in rijen if r['dubbel']), verzameld_hier=verzameld_hier,
verzameld_totaal=len(gp.verzameld_alle()),
genegeerd_totaal=len(gp.verzameld_alle(soort='genegeerd')),
verzonden_school=gp.aantal_verzonden(school.code),
nacht=gp.nacht_instelling(), periode=gp.schooljaar_periode())
@gopro_bp.route("/verplaats", methods=["POST"])
@login_required
@recht_vereist('gopro')
def verplaats():
"""Eén personeelslid met de gegevens uit het venster (aangepast of niet) naar de verzameltabel."""
from datetime import datetime as _dt
data = request.get_json(silent=True) or {}
pid = str(data.get('person_id') or '')
try:
rijen, _ = _rijen()
except Exception as e:
return jsonify(ok=False, fout=str(e)), 400
rij = next((r for r in rijen if str(r['person_id']) == pid), None)
if not rij:
return jsonify(ok=False, fout="Personeelslid niet gevonden (haal de lijst opnieuw op)."), 404
def tekst(veld, maxlen):
return str(data.get(veld) or '').strip()[:maxlen]
def datum(veld):
w = tekst(veld, 10)
if not w:
return None
try:
return _dt.strptime(w, '%Y-%m-%d').date()
except ValueError:
raise ValueError(f"Ongeldige datum: {w}")
try:
nieuw = dict(rij)
nieuw.update(voornaam=tekst('voornaam', 100), achternaam=tekst('achternaam', 100),
email=tekst('email', 255).lower(), upn=tekst('upn', 255).lower(),
instellingsnummer=tekst('instellingsnummer', 20), functie=tekst('functie', 100),
rol=tekst('rol', 100), start=datum('start'), einde=datum('einde'), telefoon=tekst('telefoon', 50))
except ValueError as e:
return jsonify(ok=False, fout=str(e)), 400
if not nieuw['voornaam'] or not nieuw['achternaam']:
return jsonify(ok=False, fout="Voornaam en achternaam zijn verplicht."), 400
if nieuw['email'] and '@' not in nieuw['email']:
return jsonify(ok=False, fout="Het e-mailadres is niet geldig."), 400
if nieuw['start'] and nieuw['einde'] and nieuw['einde'] < nieuw['start']:
return jsonify(ok=False, fout="De einddatum ligt voor de startdatum."), 400
toegevoegd, _ = gp.verzamel_toevoegen([nieuw], huidige_school(), session.get('naam') or session.get('user'))
if not toegevoegd:
return jsonify(ok=False, fout=f"{nieuw['email']} staat al in de verzameltabel of op de rode lijst."), 409
return jsonify(ok=True, totaal=len(gp.verzameld_alle()))
@gopro_bp.route("/nachtelijk", methods=["POST"])
@login_required
@recht_vereist('gopro')
def nachtelijk():
"""Schakelaar: elke nacht van alle scholen de groene naar de verzameltabel (of ?nu=1: meteen één keer)."""
door = session.get('naam') or session.get('user')
if request.form.get('nu') == '1':
if gp.verzamel_alle_scholen_op_achtergrond(door):
flash("De groene personeelsleden van alle scholen worden nu verzameld. Dit kan enkele minuten duren; "
"het resultaat verschijnt onder de schakelaar.", "info")
else:
flash("Er loopt al een verzameling.", "warning")
return redirect(url_for('gopro.overzicht'))
aan = request.form.get('aan') == '1'
gp.zet_nacht(aan, door)
from rechten import log
log(f"GO! Pro: nachtelijk verzamelen {'aangezet' if aan else 'uitgezet'}")
flash("Elke nacht (tussen 2 en 6 uur) worden de groene personeelsleden van alle scholen automatisch naar de "
"verzameltabel gezet." if aan else "Het automatisch verzamelen 's nachts staat uit.", "success")
return redirect(url_for('gopro.overzicht'))
@gopro_bp.route("/negeer", methods=["POST"])
@login_required
@recht_vereist('gopro')
def negeer():
"""Eén personeelslid naar de rode lijst: wordt genegeerd (niet geëxporteerd en niet meer getoond)."""
data = request.get_json(silent=True) or request.form
pid = str(data.get('person_id') or '')
try:
rijen, _ = _rijen()
except Exception as e:
return jsonify(ok=False, fout=str(e)), 400
rij = next((r for r in rijen if str(r['person_id']) == pid), None)
if not rij:
return jsonify(ok=False, fout="Personeelslid niet gevonden (haal de lijst opnieuw op)."), 404
toegevoegd, _ = gp.verzamel_toevoegen([rij], huidige_school(), session.get('naam') or session.get('user'),
soort='genegeerd')
return jsonify(ok=True, toegevoegd=toegevoegd, totaal=len(gp.verzameld_alle(soort='genegeerd')))
@gopro_bp.route("/keuze", methods=["POST"])
@login_required
@recht_vereist('gopro')
def keuze():
"""Bewaart de gekozen functie of rol van één personeelslid (veld = functie | rol)."""
data = request.get_json(silent=True) or request.form
pid, veld = data.get('person_id') or '', data.get('veld') or ''
try:
gp.keuze_opslaan(pid, veld, data.get('waarde') or '', session.get('user'))
except ValueError as e:
return jsonify(ok=False, fout=str(e)), 400
# De waarde die nu geldt (bij een leeg gemaakt e-mail/UPN-veld: weer het standaardadres)
waarde = data.get('waarde')
bewaard = huidige_school().cache.get('gopro')
if bewaard and veld in ('email', 'upn'):
rij = next((r for r in bewaard[1] if r['person_id'] == pid), None)
if rij:
gp.pas_keuzes_toe(rij, gp.keuzes())
waarde = rij[veld]
return jsonify(ok=True, waarde=waarde)
@gopro_bp.route("/google.json", methods=["POST"])
@login_required
@recht_vereist('gopro')
def google_check():
"""Welke van de doorgestuurde adressen bestaan in Google Workspace: {adres: 'primair' | 'alias'}."""
data = request.get_json(silent=True) or {}
gevraagd = [str(a).strip().lower() for a in data.get('adressen') or [] if a][:5000]
try:
bestaand = gp.google_adressen(vernieuw=bool(data.get('vernieuw')))
except Exception as e:
return jsonify(ok=False, fout=f"Google kon niet bevraagd worden: {e}"), 200
return jsonify(ok=True, adressen={a: bestaand[a] for a in set(gevraagd) if a in bestaand})
@gopro_bp.route("/export.csv", methods=["GET"])
@login_required
@recht_vereist('gopro')
def export():
"""CSV in de kolommen van het GO! Pro-importbestand (; als scheidingsteken, UTF-8, datums dd/mm/jjjj)."""
try:
rijen, _ = _rijen()
rijen, _ = _niet_verzameld(rijen)
except Exception as e:
flash(str(e), "error")
return redirect(url_for('gopro.overzicht'))
# ?kleur=account|alias|geen → enkel die rijen, net als de kleurfilter op de pagina
kleur = request.args.get('kleur') or ''
if kleur in ('account', 'alias', 'geen'):
try:
google = gp.google_adressen()
except Exception as e:
flash(f"Google kon niet bevraagd worden, dus de kleurfilter kan niet toegepast worden: {e}", "error")
return redirect(url_for('gopro.overzicht'))
rijen = [r for r in rijen if gp.google_kleur(r, google) == kleur]
datum = lambda d: d.strftime('%d/%m/%Y') if d else ''
buf = io.StringIO()
w = csv.writer(buf, delimiter=';')
w.writerow(gp.EXPORT_KOLOMMEN)
for r in rijen:
w.writerow([r['voornaam'], r['achternaam'], r['email'], r['upn'], r['email'], r['instellingsnummer'],
r['functie'], r['rol'], datum(r['start']), datum(r['einde']), r['telefoon']])
return Response('\ufeff' + buf.getvalue(), mimetype='text/csv; charset=utf-8', headers={
'Content-Disposition': f"attachment; filename=GO_Pro_{huidige_school().code}"
f"{'_' + {'account': 'met_account', 'alias': 'alias', 'geen': 'zonder_account'}[kleur] if kleur in ('account', 'alias', 'geen') else ''}"
f"_{date.today():%Y%m%d}.csv"})
def _csv(rijen, bestandsnaam):
"""CSV in de kolommen van het GO! Pro-importbestand. `rijen` = [(voornaam, achternaam, email, upn, instelling,
functie, rol, start, einde, telefoon)]."""
datum = lambda d: d.strftime('%d/%m/%Y') if d else ''
buf = io.StringIO()
w = csv.writer(buf, delimiter=';')
w.writerow(gp.EXPORT_KOLOMMEN)
for vn, an, em, upn, inst, fu, rol, st, ei, tel in rijen:
w.writerow([vn, an, em, upn, em, inst, fu, rol, datum(st), datum(ei), tel])
return Response('\ufeff' + buf.getvalue(), mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': f"attachment; filename={bestandsnaam}"})
# ---------------------------------------------------------------------
# Verzameltabel: de groene personeelsleden van alle scholen samen
# ---------------------------------------------------------------------
@gopro_bp.route("/verzamel", methods=["POST"])
@login_required
@recht_vereist('gopro')
def verzamel():
"""Zet alle personeelsleden met een account in Google (groen) van de actieve school in de verzameltabel."""
school = huidige_school()
try:
rijen, _ = _rijen()
rijen, _ = _niet_verzameld(rijen)
google = gp.google_adressen()
except Exception as e:
flash(f"Kon niet verzamelen: {e}", "error")
return redirect(url_for('gopro.overzicht'))
groen = [r for r in rijen if gp.google_kleur(r, google) == 'account']
if not groen:
flash("Er zijn geen (nieuwe) groene personeelsleden om toe te voegen.", "warning")
return redirect(url_for('gopro.overzicht'))
toegevoegd, overgeslagen = gp.verzamel_toevoegen(groen, school, session.get('naam') or session.get('user'))
from rechten import log
log(f"GO! Pro: {toegevoegd} personeelsleden van {school.code} naar de verzameltabel")
flash(f"{toegevoegd} personeelsleden van {school.naam} toegevoegd aan de verzameltabel"
f"{f' ({overgeslagen} stonden er al in met hetzelfde e-mailadres)' if overgeslagen else ''}. "
"Ze worden hier niet meer getoond.", "success")
return redirect(url_for('gopro.overzicht'))
LIJSTEN = {'groen': 'groen', 'rood': 'genegeerd', 'verzonden': 'verzonden'}
LIJSTNAMEN = {'groen': 'de verzameltabel', 'rood': 'de rode lijst', 'verzonden': 'de lijst met verzonden personeelsleden'}
def _lijst(waarde):
return waarde if waarde in LIJSTEN else 'groen'
@gopro_bp.route("/verzameltabel", methods=["GET"])
@login_required
@recht_vereist('gopro')
def verzameltabel():
"""?lijst=rood → de rode lijst (genegeerd), ?lijst=verzonden → al verzonden, anders de groene verzameltabel."""
lijst = _lijst(request.args.get('lijst'))
rijen = gp.verzameld_alle(soort=LIJSTEN[lijst])
scholen = sorted({(r['School_code'], r['School_naam'] or r['School_code']) for r in rijen}, key=lambda x: x[1].lower())
per_soort = gp.aantallen()
aantal = {k: (len(rijen) if k == lijst else per_soort.get(v, 0)) for k, v in LIJSTEN.items()}
from collections import Counter
per_school = Counter(r['School_code'] for r in rijen)
return render_template("gopro_verzameld.html", rijen=rijen, scholen=scholen, rollen=gp.ROLLEN, per_school=per_school,
huidige=huidige_school().code, lijst=lijst, rood=lijst == 'rood',
verzonden=lijst == 'verzonden', aantal=aantal,
aantal_groen=aantal['groen'], aantal_rood=aantal['rood'])
@gopro_bp.route("/verzameltabel/naar-rood", methods=["POST"])
@login_required
@recht_vereist('gopro')
def verzameltabel_naar_rood():
"""Eén personeelslid uit de verzameltabel naar de rode lijst (genegeerd: niet exporteren, nooit meer tonen)."""
from database import central_query
from rechten import log
gp.verzamel_ensure()
n = central_query("UPDATE GoPro_verzameltabel SET Soort = 'genegeerd', Door = %s, Toegevoegd = NOW() "
"WHERE ID = %s AND Soort = 'groen'",
(session.get('naam') or session.get('user'), request.form.get('id')), commit=True)
if n:
log(f"GO! Pro: {request.form.get('naam')} van de verzameltabel naar de rode lijst")
melding = (f"{request.form.get('naam')} staat nu op de rode lijst." if n
else "Dit personeelslid staat niet (meer) in de verzameltabel.")
if request.headers.get('X-Requested-With') == 'fetch': # vanuit de tabel: zoekopdracht blijft staan
return jsonify(ok=bool(n), melding=melding)
flash(melding, "success" if n else "error")
return redirect(url_for('gopro.verzameltabel'))
@gopro_bp.route("/verzameltabel/verzonden", methods=["POST"])
@login_required
@recht_vereist('gopro')
def verzameltabel_verzonden():
"""De verzameltabel (of één school, school=...) markeren als verzonden naar GO! Pro."""
from rechten import log
code = request.form.get('school') or None
n = gp.markeer_verzonden(session.get('naam') or session.get('user'), code)
log(f"GO! Pro: {n} personeelsleden{' van ' + code if code else ''} gemarkeerd als verzonden")
flash(f"{n} personeelsleden gemarkeerd als verzonden. Ze worden niet meer aan de verzameltabel toegevoegd.", "success")
return redirect(url_for('gopro.verzameltabel'))
@gopro_bp.route("/verzameltabel/export.csv", methods=["GET"])
@login_required
@recht_vereist('gopro')
def verzameltabel_export():
"""Eén CSV voor GO! Pro met alle scholen (of ?school=code voor één school)."""
code = request.args.get('school') or None
lijst = 'verzonden' if request.args.get('lijst') == 'verzonden' else 'groen'
rijen = gp.verzameld_alle(code, LIJSTEN[lijst])
return _csv([(r['Voornaam'], r['Achternaam'], r['Email'], r['UPN'], r['Instellingsnummer'], r['Functie'], r['Rol'],
r['Startdatum'], r['Einddatum'], r['Telefoon']) for r in rijen],
f"GO_Pro_{'verzonden_' if lijst == 'verzonden' else ''}{code or 'alle_scholen'}_{date.today():%Y%m%d}.csv")
@gopro_bp.route("/verzameltabel/verwijderen", methods=["POST"])
@login_required
@recht_vereist('gopro')
def verzameltabel_verwijderen():
"""Eén rij (id=...), alle rijen van één school (school=...) of alles (alles=1) uit de verzameltabel halen.
Die personeelsleden verschijnen dan weer op de GO! Pro-pagina van hun school."""
from database import central_query
from rechten import log
gp.verzamel_ensure()
lijst = _lijst(request.form.get('lijst'))
soort, naam = LIJSTEN[lijst], LIJSTNAMEN[lijst]
if request.form.get('id'):
n = central_query("DELETE FROM GoPro_verzameltabel WHERE ID = %s AND Soort = %s",
(request.form.get('id'), soort), commit=True)
if request.headers.get('X-Requested-With') == 'fetch': # vanuit de tabel: zoekopdracht blijft staan
return jsonify(ok=bool(n), melding=f"Personeelslid uit {naam} gehaald." if n else "Niet (meer) gevonden.")
flash(f"Personeelslid uit {naam} gehaald.", "success")
elif request.form.get('school'):
code = request.form.get('school')
n = len(gp.verzameld_alle(code, soort))
central_query("DELETE FROM GoPro_verzameltabel WHERE School_code = %s AND Soort = %s", (code, soort), commit=True)
log(f"GO! Pro: {n} rijen van {code} uit {naam} gehaald")
flash(f"{n} personeelsleden van deze school uit {naam} gehaald.", "success")
elif request.form.get('alles') == '1':
n = len(gp.verzameld_alle(soort=soort))
central_query("DELETE FROM GoPro_verzameltabel WHERE Soort = %s", (soort,), commit=True)
log(f"GO! Pro: {naam} leeggemaakt ({n} rijen)")
flash(f"{naam[0].upper() + naam[1:]} leeggemaakt ({n} personeelsleden).", "success")
return redirect(url_for('gopro.verzameltabel', lijst=None if lijst == 'groen' else lijst))
+273
View File
@@ -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'))
+179
View File
@@ -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'})
+229
View File
@@ -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"})
+285
View File
@@ -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'))
+273
View File
@@ -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')
+318
View File
@@ -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())
+232
View File
@@ -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)
+125
View File
@@ -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'))
+145
View File
@@ -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)
+683
View File
@@ -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'))
+65
View File
@@ -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'})
+135
View File
@@ -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'))
+66
View File
@@ -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'))
+104
View File
@@ -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
+108
View File
@@ -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})
+190
View File
@@ -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'))
+44
View File
@@ -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'))
+177
View File
@@ -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'))
+124
View File
@@ -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))