Compare commits

..
2 Commits
Author SHA1 Message Date
marijn 9f6549e0e1 Samengevoegd met Gitea: lokale versie behouden 2026-10-14 13:39:02 +02:00
marijn 8a53234eb6 PIVOT 1.1.0 2026-10-14 13:34:37 +02:00
17 changed files with 810 additions and 56 deletions

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
# Geheimen: NOOIT in git (wachtwoorden, sleutels, Google-serviceaccounts)
config.ini
geheimen/
import/
.env
*api_key*.json
# Python
__pycache__/
*.pyc
venv/
.venv/
# Logboeken en tijdelijke bestanden
*.log
.vscode/
.idea/
.DS_Store
Thumbs.db
+4
View File
@@ -117,6 +117,7 @@ python beheer.py rechten jan sg-antwerpen alles | geen
| `gopro` | GO! Pro: personeel van de school uit Informat, rollen en export (blok *Personeel*) |
| `koppelingen` | Koppelingen van deze school |
| `gebruikersbeheer` | Gebruikers & rechten van deze school |
| `database_toestellen` | Beheer › Database: toestellen importeren uit Excel |
Een nieuw onderdeel voeg je toe in `rechten.py › ONDERDELEN`. Bij het opstarten komt het automatisch in de tabel `Onderdelen`.
@@ -159,6 +160,9 @@ Wat PIVOT extra nodig heeft, staat in `sql/school.sql` gemarkeerd met `[+]`.
`Geblokkeerd` (1 = geblokkeerd in Google Admin) wordt vanzelf toegevoegd; de knop in 💻 Alle toestellen en 🔒 bij Eigendom
houden hem bij, en "Chromebooks ophalen uit Google" neemt de status uit Google over.
`Schooleigen_ID` is het toestelnummer van de school; in de code heette dat vroeger `KAMXM_nummer`.
Aanvullen kan ook in bulk via **⚙️ Beheer › 🗄️ Database › Toestellen importeren**: sjabloon downloaden, Excel uploaden,
voorbeeld nakijken en bevestigen. Toestellen worden herkend aan `Schooleigen_ID` (anders `Serienummer`); er wordt nooit
iets verwijderd en een lege cel wist niets (`services/toestellen_import.py`).
- **Signpost_DB**: één rij per toestel (`Toestel_ID` = sleutel). Het veld "Aankoopberdrag" heet `Aankoopbedrag`.
De synchronisatie met Signpost maakt een toestel aan als het serienummer nog niet bestaat.
- **Opmerkingen** en **Signpost_herstellingen_DB** verwijzen naar `Toestellen.Toestel_ID`. Dat is hetzelfde nummer als
+2
View File
@@ -152,6 +152,8 @@ def create_app():
app.register_blueprint(verzendlijsten_bp, url_prefix='/google/verzendlijsten')
from blueprints.ipblokkade_bp import ipblokkade_bp
app.register_blueprint(ipblokkade_bp, url_prefix='/beheer/ip-blokkades')
from blueprints.database_bp import database_bp
app.register_blueprint(database_bp, url_prefix='/beheer/database') # Beheer › Database (Excel-import)
#------------------------------------------------------
# Welke school? (per request)
+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
+5 -2
View File
@@ -290,8 +290,11 @@ def verzameltabel():
lijst = _lijst(request.args.get('lijst'))
rijen = gp.verzameld_alle(soort=LIJSTEN[lijst])
scholen = sorted({(r['School_code'], r['School_naam'] or r['School_code']) for r in rijen}, key=lambda x: x[1].lower())
aantal = {k: (len(rijen) if k == lijst else len(gp.verzameld_alle(soort=v))) for k, v in LIJSTEN.items()}
return render_template("gopro_verzameld.html", rijen=rijen, scholen=scholen, rollen=gp.ROLLEN,
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'])
-42
View File
@@ -1,42 +0,0 @@
# =====================================================================
# PIVOT – algemene instellingen (gelden voor ALLE scholen)
#
# Alles wat per school verschilt (Smartschool, Informat, Google, Signpost,
# CO2, personeel, database van de school, ...) staat NIET meer hier, maar
# in het scholenregister: in PIVOT via 🏫 Scholen (superbeheerder) of
# 🔌 Koppelingen van deze school (beheerder van een school).
#
# Geheimen kan je ook als omgevingsvariabele meegeven i.p.v. hier:
# PIVOT_DB_PASSWORD, PIVOT_SSO_CLIENT_SECRET, FLASK_SECRET_KEY, PIVOT_SLEUTEL
# =====================================================================
[database]
# Server en standaardlogin: voor de centrale database én voor elke schooldatabase
# (per school te overschrijven in het scholenregister)
host = localhost
port = 3306
user = marijn
password =8778Zappa4861!
# Database met de lijst van scholen (wordt automatisch ingericht)
centrale_database = pivot
[google_sso]
# Gedeelde 'Inloggen met Google' voor alle scholen (OAuth-client van het type
# 'Webtoepassing', toestemmingsscherm 'Extern'). Leeg laten als elke school een
# eigen client gebruikt (in te stellen per school).
# Toegestane redirect-URI: https://<jouw-pivot-adres>/callback
client_id =
client_secret =
[algemeen]
app_version = 1.1.0
# Superbeheerders van de scholengroep (Google-accounts, komma-gescheiden):
# mogen scholen toevoegen/wijzigen en tussen scholen wisselen.
superbeheerders = ict@kamerksem.be
# Geheime sleutel voor sessies (liever via FLASK_SECRET_KEY)
secret_key =
debug = nee
# Elke bezochte pagina bewaren in de tabel Logs (ja/nee)
logboek = ja
# Aantal reverse proxies vóór PIVOT (nginx, IIS, firewall met https). 0 = PIVOT staat rechtstreeks op het net.
proxy_aantal = 0
-1
View File
@@ -1 +0,0 @@
100c148b256ee08843e482847b4a5d1f86797b380957c476ce6a29f1de2dd7cb
+1
View File
@@ -87,6 +87,7 @@ ONDERDELEN = [
('bugs_beheren', 'Status van bugs wijzigen', 'Beheer'),
('nieuw_schooljaar', 'Nieuw schooljaar (accounts & toestellen)', 'Beheer'),
('ip_whitelist', 'IP-adressen toestaan voor deze school', 'Beheer'),
('database_toestellen', 'Database: toestellen importeren uit Excel', 'Beheer'),
# --- PIVOT ---
('gebruikersbeheer', 'Gebruikers & rechten van deze school', 'PIVOT'),
('koppelingen', 'Koppelingen van deze school', 'PIVOT'),
+23 -1
View File
@@ -369,10 +369,14 @@ def schooljaar_periode(vandaag=None):
_periode_gezet = {'dag': None}
_tabel_klaar = {'ok': False} # tabel/kolommen/index maar één keer per proces nakijken
def verzamel_ensure():
from database import central_query
if _tabel_klaar['ok']:
_periode_bijwerken()
return
central_query("""
CREATE TABLE IF NOT EXISTS GoPro_verzameltabel (
ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
@@ -395,7 +399,8 @@ def verzamel_ensure():
Verzonden DATETIME NULL,
Verzonden_door VARCHAR(255) NULL,
UNIQUE KEY uq_gopro_verz (School_code, Person_ID),
KEY idx_gopro_verz_email (Email)
KEY idx_gopro_verz_email (Email),
KEY idx_gopro_verz_soort (Soort, School_naam)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True)
# Soort: 'groen' = gaat mee in de GO! Pro-CSV, 'genegeerd' = rode lijst (nooit meer tonen, niet exporteren),
# 'verzonden' = al naar GO! Pro doorgestuurd (niet meer in de verzameltabel, nooit opnieuw toevoegen)
@@ -405,6 +410,15 @@ def verzamel_ensure():
if 'verzonden' not in kolommen:
central_query("ALTER TABLE GoPro_verzameltabel ADD COLUMN Verzonden DATETIME NULL, "
"ADD COLUMN Verzonden_door VARCHAR(255) NULL", commit=True)
indexen = {r['Key_name'] for r in central_query("SHOW INDEX FROM GoPro_verzameltabel") or []}
if 'idx_gopro_verz_soort' not in indexen:
central_query("ALTER TABLE GoPro_verzameltabel ADD KEY idx_gopro_verz_soort (Soort, School_naam)", commit=True)
_tabel_klaar['ok'] = True
_periode_bijwerken()
def _periode_bijwerken():
from database import central_query
# Iedereen in de verzameltabel: periode = het lopende schooljaar (1 keer per dag nagekeken)
if _periode_gezet['dag'] != date.today():
start, einde = schooljaar_periode()
@@ -465,6 +479,14 @@ def verzameld_alle(school_code=None, soort='groen'):
"ORDER BY School_naam, Achternaam, Voornaam", (soort,)) or []
def aantallen():
"""{'groen': n, 'genegeerd': n, 'verzonden': n} in één query (in plaats van alle rijen op te halen)."""
from database import central_query
verzamel_ensure()
return {r['Soort']: int(r['n']) for r in
central_query("SELECT Soort, COUNT(*) AS n FROM GoPro_verzameltabel GROUP BY Soort") or []}
def markeer_verzonden(door=None, school_code=None):
"""Alle personeelsleden in de verzameltabel (of enkel die van één school) als verzonden markeren.
Ze verdwijnen uit de verzameltabel en worden nooit opnieuw toegevoegd. Geeft het aantal."""
+419
View File
@@ -0,0 +1,419 @@
"""
Beheer › Database › Toestellen: de tabel Toestellen aanvullen vanuit een Excel-bestand.
Werkwijze (twee stappen, zodat er niets in de database komt zonder dat je het gezien hebt):
1. lees_excel() : het geüploade bestand → ruwe rijen (enkel tekst / ISO-datums)
2. maak_plan() : ruwe rijen → per rij 'nieuw', 'bijwerken', 'ongewijzigd' of 'fout' (niets wordt bewaard)
3. voer_uit() : het plan in één transactie uitvoeren (het plan wordt opnieuw gemaakt, nooit blind vertrouwd)
Een toestel wordt herkend aan het toestelnummer (Schooleigen_ID) en anders aan het serienummer.
Er wordt nooit iets verwijderd. Lege cellen wissen nooit een bestaande waarde.
"""
import io
from datetime import date, datetime
from database import execute_query, get_db_connection
from services import toestellen as tst
STATUSSEN = ('Beschikbaar', 'Uitgeleend', 'Defect')
MAX_RIJEN = 5000
# (sleutel, kolomkop in het sjabloon, max. lengte, uitleg, andere namen die ook herkend worden)
KOLOMMEN = [
('schooleigen_id', 'Toestelnummer', 50,
"Het eigen toestelnummer van de school (Schooleigen_ID). Uniek. Toestelnummer of serienummer is verplicht.",
('schooleigen_id', 'schooleigen id', 'kamxm_nummer', 'kamxm nummer', 'kamxm', 'nummer', 'toestel')),
('serienummer', 'Serienummer', 100,
"Serienummer van de fabrikant (nodig om het toestel in Google te blokkeren). "
"Toestelnummer of serienummer is verplicht.",
('serial', 'serial number', 'serienr', 'serie')),
('type', 'Type', 100, "Model van het toestel, bv. Lenovo 300e Gen 4.", ('model', 'toesteltype')),
('status', 'Status', None,
"Beschikbaar, Uitgeleend of Defect. Leeg bij een nieuw toestel = Beschikbaar.", ()),
('afschrijfdatum', 'Afschrijfdatum', None,
"Datum (dd-mm-jjjj) of een Excel-datum. Mag leeg.", ('afschrijving', 'afschrijf datum')),
('lokaal', 'Lokaal', None,
"Naam van een lokaal dat al in PIVOT bestaat (zie het tabblad Lokalen). Mag leeg.", ('lokaalnaam', 'lokaal_id')),
]
SLEUTELS = [k[0] for k in KOLOMMEN]
KOPPEN = {k[0]: k[1] for k in KOLOMMEN}
# Databasekolom per sleutel (lokaal → Lokaal_ID wordt apart opgezocht)
DB_KOLOM = {'schooleigen_id': 'Schooleigen_ID', 'serienummer': 'Serienummer', 'type': 'Type',
'status': 'Status', 'afschrijfdatum': 'Afschrijfdatum', 'lokaal': 'Lokaal_ID'}
class ImportFout(Exception):
"""Het bestand zelf kan niet gelezen worden (geen rijfout)."""
def _norm(tekst):
return ' '.join(str(tekst or '').strip().lower().replace('_', ' ').replace('-', ' ').split())
_HERKEN = {}
for _k, _kop, _m, _u, _alias in KOLOMMEN:
for _naam in (_k, _kop) + tuple(_alias):
_HERKEN[_norm(_naam)] = _k
# ---------------------------------------------------------------------------
# Sjabloon
# ---------------------------------------------------------------------------
def lokalen():
"""Alle lokalen van de huidige school: [{'Lokaal_ID', 'Naam', 'Campus'}]."""
return execute_query(
"SELECT l.Lokaal_ID, l.Naam, c.Campus_naam AS Campus FROM Lokalen l "
"LEFT JOIN Campus c ON c.Campus_ID = l.Campus_ID ORDER BY l.Naam") or []
def maak_sjabloon(schoolnaam=''):
"""Excel-sjabloon (bytes): tabblad Toestellen (in te vullen), Uitleg en Lokalen."""
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
from openpyxl.worksheet.datavalidation import DataValidation
wb = Workbook()
ws = wb.active
ws.title = 'Toestellen'
ws.append([k[1] for k in KOLOMMEN])
kop_vulling = PatternFill('solid', fgColor='1E6FA5')
for c in ws[1]:
c.font = Font(bold=True, color='FFFFFF')
c.fill = kop_vulling
c.alignment = Alignment(vertical='center')
breedtes = {'Toestelnummer': 16, 'Serienummer': 20, 'Type': 26, 'Status': 14, 'Afschrijfdatum': 16, 'Lokaal': 18}
for i, k in enumerate(KOLOMMEN):
letter = ws.cell(row=1, column=i + 1).column_letter
ws.column_dimensions[letter].width = breedtes.get(k[1], 16)
ws.freeze_panes = 'A2'
ws.row_dimensions[1].height = 22
kol = {k[0]: ws.cell(row=1, column=i + 1).column_letter for i, k in enumerate(KOLOMMEN)}
# Nummers als tekst (anders maakt Excel van 00123 → 123)
for sleutel in ('schooleigen_id', 'serienummer'):
for r in range(2, MAX_RIJEN + 2):
ws[f"{kol[sleutel]}{r}"].number_format = '@'
for r in range(2, MAX_RIJEN + 2):
ws[f"{kol['afschrijfdatum']}{r}"].number_format = 'DD-MM-YYYY'
dv_status = DataValidation(type='list', formula1='"' + ','.join(STATUSSEN) + '"', allow_blank=True,
showErrorMessage=True, errorTitle='Status',
error='Kies Beschikbaar, Uitgeleend of Defect (of laat leeg).')
dv_status.add(f"{kol['status']}2:{kol['status']}{MAX_RIJEN + 1}")
ws.add_data_validation(dv_status)
dv_datum = DataValidation(type='date', operator='greaterThan', formula1='1', allow_blank=True,
showErrorMessage=True, errorTitle='Afschrijfdatum',
error='Vul een datum in, bv. 31-08-2030.')
dv_datum.add(f"{kol['afschrijfdatum']}2:{kol['afschrijfdatum']}{MAX_RIJEN + 1}")
ws.add_data_validation(dv_datum)
# Lokalen van deze school (keuzelijst in de kolom Lokaal)
wl = wb.create_sheet('Lokalen')
wl.append(['Lokaal', 'Campus'])
for c in wl[1]:
c.font = Font(bold=True)
lijst = []
try:
lijst = lokalen()
except Exception:
pass
for l in lijst:
wl.append([l['Naam'], l.get('Campus') or ''])
wl.column_dimensions['A'].width = 24
wl.column_dimensions['B'].width = 28
if lijst:
dv_lokaal = DataValidation(type='list', formula1=f"=Lokalen!$A$2:$A${len(lijst) + 1}", allow_blank=True,
showErrorMessage=True, errorTitle='Lokaal',
error='Kies een lokaal uit de lijst (tabblad Lokalen) of laat leeg.')
dv_lokaal.add(f"{kol['lokaal']}2:{kol['lokaal']}{MAX_RIJEN + 1}")
ws.add_data_validation(dv_lokaal)
# Uitleg
wu = wb.create_sheet('Uitleg')
wu.column_dimensions['A'].width = 18
wu.column_dimensions['B'].width = 95
wu.append([f"Toestellen importeren in PIVOT{' – ' + schoolnaam if schoolnaam else ''}"])
wu['A1'].font = Font(bold=True, size=14)
regels = [
"Vul het tabblad 'Toestellen' in: één toestel per rij, vanaf rij 2. Laat de kolomkoppen in rij 1 staan.",
"Een toestel wordt herkend aan het toestelnummer, en anders aan het serienummer.",
"Bestaat het toestel nog niet, dan wordt het toegevoegd.",
"Bestaat het al, dan worden (afhankelijk van je keuze bij het uploaden) enkel lege velden aangevuld "
"of de ingevulde velden overschreven. Een lege cel wist nooit iets.",
"Er wordt nooit een toestel verwijderd. Je krijgt eerst een voorbeeld te zien voordat er iets bewaard wordt.",
f"Maximaal {MAX_RIJEN} rijen per bestand.",
]
for t in regels:
wu.append(['', t])
wu.append([])
wu.append(['Kolom', 'Uitleg'])
for c in wu[wu.max_row]:
c.font = Font(bold=True)
for _k, kop, maxlen, uitleg, _a in KOLOMMEN:
wu.append([kop, uitleg + (f" Max. {maxlen} tekens." if maxlen else '')])
for rij in wu.iter_rows(min_row=2):
for c in rij:
c.alignment = Alignment(wrap_text=True, vertical='top')
wu.append([])
wu.append(['Voorbeeld', 'Toestelnummer CB-0123 · Serienummer PF3ABC12 · Type Lenovo 300e Gen 4 · '
'Status Beschikbaar · Afschrijfdatum 31-08-2030 · Lokaal A101'])
wb.active = 0
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
# ---------------------------------------------------------------------------
# Excel lezen
# ---------------------------------------------------------------------------
def _cel(waarde):
"""Celwaarde → tekst (datums als ISO 'jjjj-mm-dd', 123.0 → '123')."""
if waarde is None:
return ''
if isinstance(waarde, datetime):
return waarde.date().isoformat()
if isinstance(waarde, date):
return waarde.isoformat()
if isinstance(waarde, float) and waarde.is_integer():
return str(int(waarde))
return str(waarde).strip()
def lees_excel(bestand):
"""Leest een .xlsx (bestandsobject of bytes). Geeft (rijen, onbekende_kolommen).
rijen = [{'rij': <rijnummer in Excel>, 'schooleigen_id': '...', ...}] (enkel niet-lege rijen)."""
from openpyxl import load_workbook
data = bestand if isinstance(bestand, (bytes, bytearray)) else bestand.read()
if not data:
raise ImportFout("Het bestand is leeg.")
try:
wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
except Exception:
raise ImportFout("Dit bestand kan niet gelezen worden. Gebruik een Excel-bestand (.xlsx), "
"bv. het sjabloon van deze pagina.")
try:
ws = wb['Toestellen'] if 'Toestellen' in wb.sheetnames else wb.worksheets[0]
rijen_iter = ws.iter_rows(values_only=True)
# Kopregel zoeken in de eerste 10 rijen: de eerste rij met een herkende kolom
kop_index, onbekend, rijnr = None, [], 0
for rij in rijen_iter:
rijnr += 1
gevonden = {i: _HERKEN[_norm(v)] for i, v in enumerate(rij or ()) if _norm(v) in _HERKEN}
if gevonden:
kop_index = {}
for i, sleutel in gevonden.items():
kop_index.setdefault(sleutel, i) # dubbele kolom: de eerste telt
onbekend = [_cel(v) for i, v in enumerate(rij) if _cel(v) and i not in gevonden]
break
if rijnr >= 10:
break
if not kop_index:
raise ImportFout("Geen kolomkoppen gevonden. Rij 1 moet de kolomnamen van het sjabloon bevatten "
f"({', '.join(KOPPEN.values())}).")
if 'schooleigen_id' not in kop_index and 'serienummer' not in kop_index:
raise ImportFout("De kolom Toestelnummer of Serienummer ontbreekt.")
uit = []
for rij in rijen_iter:
rijnr += 1
waarden = {s: _cel(rij[i]) if rij and i < len(rij) else '' for s, i in kop_index.items()}
if not any(waarden.values()):
continue
if len(uit) >= MAX_RIJEN:
raise ImportFout(f"Het bestand bevat meer dan {MAX_RIJEN} rijen. Splits het op in kleinere bestanden.")
uit.append({'rij': rijnr, **{s: waarden.get(s, '') for s in SLEUTELS}})
return uit, onbekend
finally:
wb.close()
# ---------------------------------------------------------------------------
# Plan maken (niets bewaren)
# ---------------------------------------------------------------------------
def _datum_tekst(w):
"""'2030-08-31' / '31-08-2030' / '31/08/2030' → date, of ValueError."""
w = (w or '').strip()
if not w:
return None
try:
return date.fromisoformat(w[:10]) if len(w) >= 10 and w[4] == '-' else tst.lees_datum(w)
except ValueError:
return tst.lees_datum(w)
def _toon(w):
if isinstance(w, date):
return w.strftime('%d-%m-%Y')
return '' if w is None else str(w)
def maak_plan(rijen, modus='aanvullen'):
"""Bepaalt per rij wat er zou gebeuren. modus: 'aanvullen' (enkel lege velden) of 'overschrijven'.
Geeft {'rijen': [...], 'tellers': {...}}; elke rij heeft 'actie', 'meldingen' en 'waarden' (voor de database)."""
overschrijven = modus == 'overschrijven'
bestaand = execute_query("SELECT Toestel_ID, Schooleigen_ID, Serienummer, Type, Status, Afschrijfdatum, Lokaal_ID "
"FROM Toestellen") or []
op_nummer = {(r['Schooleigen_ID'] or '').strip().upper(): r for r in bestaand if (r['Schooleigen_ID'] or '').strip()}
op_serie = {}
for r in bestaand:
s = (r['Serienummer'] or '').strip().upper()
if s:
op_serie.setdefault(s, r)
lok = {}
for l in lokalen():
lok.setdefault((l['Naam'] or '').strip().upper(), l)
lok_naam = {l['Lokaal_ID']: l['Naam'] for l in lok.values()}
gezien_nummer, gezien_serie = {}, {}
uit = []
tellers = {'nieuw': 0, 'bijwerken': 0, 'ongewijzigd': 0, 'fout': 0}
for invoer in rijen:
r = {'rij': invoer.get('rij'), 'invoer': {s: str(invoer.get(s) or '').strip() for s in SLEUTELS},
'meldingen': [], 'wijzigingen': [], 'actie': None, 'toestel_id': None, 'waarden': {}}
v = r['invoer']
fouten = []
# --- waarden controleren ---
nummer, serie = v['schooleigen_id'], v['serienummer']
if not nummer and not serie:
fouten.append("Toestelnummer of serienummer is verplicht.")
for s in ('schooleigen_id', 'serienummer', 'type'):
maxlen = next(k[2] for k in KOLOMMEN if k[0] == s)
if len(v[s]) > maxlen:
fouten.append(f"{KOPPEN[s]} is te lang (max. {maxlen} tekens).")
status = None
if v['status']:
status = next((x for x in STATUSSEN if x.lower() == v['status'].lower()), None)
if not status:
fouten.append(f"Status '{v['status']}' is ongeldig (kies {', '.join(STATUSSEN)}).")
afschrijf = None
if v['afschrijfdatum']:
try:
afschrijf = _datum_tekst(v['afschrijfdatum'])
except ValueError:
fouten.append(f"Afschrijfdatum '{v['afschrijfdatum']}' is geen geldige datum (gebruik dd-mm-jjjj).")
lokaal_id = None
if v['lokaal']:
l = lok.get(v['lokaal'].upper())
if l:
lokaal_id = l['Lokaal_ID']
else:
fouten.append(f"Lokaal '{v['lokaal']}' bestaat niet in PIVOT.")
# --- dubbels in het bestand (enkel t.o.v. eerdere rijen zonder fout) ---
if nummer and gezien_nummer.get(nummer.upper()):
fouten.append(f"Toestelnummer {nummer} staat al op rij {gezien_nummer[nummer.upper()]}.")
if serie and gezien_serie.get(serie.upper()):
fouten.append(f"Serienummer {serie} staat al op rij {gezien_serie[serie.upper()]}.")
# --- bestaand toestel? ---
t_nummer = op_nummer.get(nummer.upper()) if nummer else None
t_serie = op_serie.get(serie.upper()) if serie else None
toestel = t_nummer or t_serie
if t_nummer and t_serie and t_nummer['Toestel_ID'] != t_serie['Toestel_ID']:
fouten.append(f"Toestelnummer {nummer} en serienummer {serie} horen in PIVOT bij twee verschillende "
f"toestellen. Pas dit eerst aan in 💻 Alle toestellen.")
elif toestel:
db_nummer = (toestel['Schooleigen_ID'] or '').strip()
db_serie = (toestel['Serienummer'] or '').strip()
if t_nummer and serie and db_serie and db_serie.upper() != serie.upper():
fouten.append(f"Toestel {nummer} heeft in PIVOT serienummer {db_serie}, niet {serie}.")
if not t_nummer and nummer and db_nummer and db_nummer.upper() != nummer.upper():
fouten.append(f"Serienummer {serie} hoort in PIVOT bij toestel {db_nummer}, niet bij {nummer}.")
if fouten:
r['actie'] = 'fout'
r['meldingen'] = fouten
tellers['fout'] += 1
uit.append(r)
continue
if nummer:
gezien_nummer[nummer.upper()] = r['rij']
if serie:
gezien_serie[serie.upper()] = r['rij']
nieuw = {'schooleigen_id': nummer or None, 'serienummer': serie or None, 'type': v['type'] or None,
'status': status, 'afschrijfdatum': afschrijf, 'lokaal': lokaal_id}
if not toestel:
nieuw['status'] = status or 'Beschikbaar'
r['actie'] = 'nieuw'
r['waarden'] = {DB_KOLOM[s]: w for s, w in nieuw.items() if w is not None}
tellers['nieuw'] += 1
uit.append(r)
continue
# --- bijwerken ---
r['toestel_id'] = toestel['Toestel_ID']
r['label'] = toestel['Schooleigen_ID'] or toestel['Serienummer'] or str(toestel['Toestel_ID'])
overgeslagen = []
for s, w in nieuw.items():
if w is None:
continue
kolom = DB_KOLOM[s]
huidig = toestel[kolom]
if isinstance(huidig, datetime):
huidig = huidig.date()
gelijk = (str(huidig).strip().upper() == str(w).strip().upper()) if huidig not in (None, '') else False
if gelijk:
continue
leeg = huidig is None or (isinstance(huidig, str) and not huidig.strip())
if s == 'status' and not overschrijven:
leeg = False # status heeft altijd een waarde: enkel wijzigen bij overschrijven
if leeg or overschrijven:
r['waarden'][kolom] = w
oud = lok_naam.get(huidig, huidig) if s == 'lokaal' else huidig
nw = lok_naam.get(w, w) if s == 'lokaal' else w
r['wijzigingen'].append(f"{KOPPEN[s]}: {_toon(oud) or '–'} → {_toon(nw)}")
else:
oud = lok_naam.get(huidig, huidig) if s == 'lokaal' else huidig
overgeslagen.append(f"{KOPPEN[s]} ({_toon(oud)})")
if overgeslagen:
r['meldingen'].append("Niet overschreven: " + ', '.join(overgeslagen) + '.')
if r['waarden']:
r['actie'] = 'bijwerken'
tellers['bijwerken'] += 1
else:
r['actie'] = 'ongewijzigd'
tellers['ongewijzigd'] += 1
uit.append(r)
return {'rijen': uit, 'tellers': tellers, 'modus': 'overschrijven' if overschrijven else 'aanvullen'}
# ---------------------------------------------------------------------------
# Uitvoeren
# ---------------------------------------------------------------------------
def voer_uit(rijen, modus, gebruiker=None):
"""Maakt het plan opnieuw en voert 'nieuw' en 'bijwerken' uit in één transactie.
Geeft (plan, aantal_nieuw, aantal_bijgewerkt). Bij een databasefout wordt niets bewaard."""
plan = maak_plan(rijen, modus)
conn = get_db_connection()
cur = conn.cursor()
nieuw = bijgewerkt = 0
try:
conn.start_transaction()
for r in plan['rijen']:
w = r['waarden']
if r['actie'] == 'nieuw':
kolommen = list(w.keys())
cur.execute(f"INSERT INTO Toestellen ({', '.join(kolommen)}) "
f"VALUES ({', '.join(['%s'] * len(kolommen))})", tuple(w[k] for k in kolommen))
nieuw += 1
elif r['actie'] == 'bijwerken':
kolommen = list(w.keys())
cur.execute(f"UPDATE Toestellen SET {', '.join(k + ' = %s' for k in kolommen)} WHERE Toestel_ID = %s",
tuple(w[k] for k in kolommen) + (r['toestel_id'],))
bijgewerkt += 1
conn.commit()
except Exception:
conn.rollback()
raise
finally:
cur.close()
conn.close()
return plan, nieuw, bijgewerkt
+2 -1
View File
@@ -221,7 +221,8 @@ CREATE TABLE IF NOT EXISTS GoPro_verzameltabel (
Verzonden DATETIME NULL,
Verzonden_door VARCHAR(255) NULL,
UNIQUE KEY uq_gopro_verz (School_code, Person_ID),
KEY idx_gopro_verz_email (Email)
KEY idx_gopro_verz_email (Email),
KEY idx_gopro_verz_soort (Soort, School_naam)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS Koppeling_tests (
+2 -1
View File
@@ -230,7 +230,8 @@ CREATE TABLE IF NOT EXISTS GoPro_verzameltabel (
Verzonden DATETIME NULL,
Verzonden_door VARCHAR(255) NULL,
UNIQUE KEY uq_gopro_verz (School_code, Person_ID),
KEY idx_gopro_verz_email (Email)
KEY idx_gopro_verz_email (Email),
KEY idx_gopro_verz_soort (Soort, School_naam)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS Koppeling_tests (
+4
View File
@@ -513,6 +513,10 @@ td.center, th.center { text-align: center; }
.tile-links a::after { content: "›"; margin-left: auto; color: var(--border-strong); font-size: 18px; transition: transform .12s; }
.tile-links a:hover { background: var(--primary-light); color: var(--primary-color); }
.tile-links a:hover::after { color: var(--primary-color); transform: translateX(2px); }
/* Links-blok: icoontjes even breed (favicon of emoji), lange adressen afgekort met … */
.links-lijst .link-ico { flex: 0 0 18px; width: 18px; text-align: center; line-height: 1; font-size: 15px; }
.links-lijst .link-ico img { display: block; width: 16px; height: 16px; margin: 0 auto; border-radius: 3px; }
.links-lijst .link-tekst { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tile.tile-bug { border-top-color: var(--bug); }
.tile.tile-bug .tile-icon { background: var(--success-bg); }
+18 -7
View File
@@ -192,23 +192,25 @@
{% if school %}
{% set web_domein = (school.domein or '').strip().lower() %}
{% set ss_url = ((school.waarden.get('SS_API_PLATFORM') or '').replace('https://', '').replace('http://', '').strip().strip('/')) %}
{% if web_domein or ss_url %}
<section class="tile">
<div class="tile-head"><span class="tile-icon">🏫</span><h3>Links</h3></div>
<ul class="tile-links">
<ul class="tile-links links-lijst">
{% macro ico(url, reserve) -%}
<span class="link-ico"><img src="{{ url }}" alt="" width="16" height="16" onerror="this.replaceWith(document.createTextNode({{ reserve|tojson|forceescape }}))"></span>
{%- endmacro %}
{% if web_domein %}
{% set web = web_domein if web_domein.startswith('www.') else 'www.' ~ web_domein %}
<li><a href="https://{{ web }}" target="_blank" rel="noopener">🌐 {{ web_domein }}</a></li>
<li><a href="https://{{ web }}" target="_blank" rel="noopener"><span class="link-ico">🌐</span><span class="link-tekst">{{ web_domein }}</span></a></li>
{% endif %}
{% if ss_url %}<li><a href="https://{{ ss_url }}" target="_blank" rel="noopener"><img src="https://{{ ss_url }}/favicon.ico" alt="" width="16" height="16"
style="vertical-align:-3px;margin-right:6px;border-radius:3px" onerror="this.replaceWith(document.createTextNode('📘 '))">Smartschool</a></li>{% endif %}
{% if ss_url %}<li><a href="https://{{ ss_url }}" target="_blank" rel="noopener" title="{{ ss_url }}">{{ ico('https://' ~ ss_url ~ '/favicon.ico', '📘') }}<span class="link-tekst">{{ ss_url }}</span></a></li>{% endif %}
<li><a href="https://start.informatsoftware.be/informat/informat.web/login.aspx?" target="_blank" rel="noopener">{{ ico('https://start.informatsoftware.be/favicon.ico', '📗') }}<span class="link-tekst">Informat</span></a></li>
<li><a href="https://pb-antwerpen.rvl.be/index.php" target="_blank" rel="noopener">{{ ico('https://pb-antwerpen.rvl.be/favicon.ico', '📙') }}<span class="link-tekst">Rivo</span></a></li>
</ul>
</section>
{% endif %}
{% endif %}
</div>
{% if mag('co2', 'koppelingen', 'gebruikersbeheer', 'nieuw_schooljaar', 'ip_whitelist') or is_admin or is_superbeheerder %}
{% if mag('co2', 'koppelingen', 'gebruikersbeheer', 'nieuw_schooljaar', 'ip_whitelist', 'database_toestellen') or is_admin or is_superbeheerder %}
<div class="dash-section-title admin">⚙️ Beheer</div>
<div class="tile-grid">
@@ -252,6 +254,15 @@
</ul>
</section>
{% endif %}
{% if mag('database_toestellen') %}
<section class="tile tile-admin">
<div class="tile-head"><span class="tile-icon">🗄️</span><h3>Database</h3></div>
<ul class="tile-links">
<li><a href="{{ url_for('database_beheer.toestellen') }}">💻 Toestellen importeren (Excel)</a></li>
</ul>
</section>
{% endif %}
</div>
{% endif %}
{% endblock %}
+161
View File
@@ -0,0 +1,161 @@
{% extends "base.html" %}
{% block title %}Toestellen importeren{% endblock %}
{% block page_title %}Database › Toestellen{% endblock %}
{% block loader_text %}Bestand controleren...{% endblock %}
{% block extra_css %}
<style>
.stappen { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start; }
.stappen > .card + .card { margin-top: 0; }
@media (max-width: 860px) { .stappen { grid-template-columns: 1fr; } }
.stap-nr { display: inline-grid; place-items: center; width: 26px; height: 26px; border-radius: 50%;
background: var(--primary-color); color: #fff; font-size: 14px; margin-right: 6px; }
.kolom-lijst { margin: 10px 0 0; padding: 0; list-style: none; font-size: 14px; }
.kolom-lijst li { padding: 6px 0; border-bottom: 1px solid var(--border); }
.kolom-lijst li:last-child { border-bottom: 0; }
.kolom-lijst strong { display: inline-block; min-width: 120px; }
.modus-keuze { display: grid; gap: 8px; margin: 4px 0 16px; }
.modus-keuze label { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--border);
border-radius: var(--radius-sm); font-weight: 500; cursor: pointer; background: var(--surface-muted); }
.modus-keuze label:has(input:checked) { border-color: var(--primary-color); background: var(--primary-light); }
.modus-keuze input { margin-top: 2px; flex-shrink: 0; }
.modus-keuze small { display: block; color: var(--text-muted); font-weight: 400; }
.tellers { display: flex; flex-wrap: wrap; gap: 10px; margin: 6px 0 14px; }
.teller-knop { all: unset; cursor: pointer; display: inline-flex; gap: 8px; align-items: center; padding: 8px 14px;
border-radius: var(--radius); border: 1px solid var(--border); background: #fff; font-weight: 600; font-size: 14px; }
.teller-knop.actief { border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(30,111,165,.18); }
.teller-knop .n { font-size: 18px; }
tr.rij-fout td { background: #fff7f7; }
td.melding { font-size: 13px; color: var(--text-muted); min-width: 220px; }
td.melding .fout { color: var(--danger-text); }
td.melding .wijz { color: var(--text); }
.bevestig { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; justify-content: space-between; margin-top: 16px; }
</style>
{% endblock %}
{% block content %}
{% if not plan %}
<div class="stappen">
<div class="card">
<h2 class="card-title"><span class="stap-nr">1</span>Sjabloon downloaden</h2>
<p class="muted">Vul het sjabloon in met één toestel per rij. De kolom <em>Lokaal</em> heeft een keuzelijst met
de lokalen van {{ school.naam if school else 'deze school' }}.</p>
<a class="btn" href="{{ url_for('database_beheer.toestellen_sjabloon') }}">⬇️ Sjabloon downloaden (.xlsx)</a>
<ul class="kolom-lijst">
{% for k in kolommen %}
<li><strong>{{ k[1] }}</strong> <span class="muted">{{ k[3] }}</span></li>
{% endfor %}
</ul>
</div>
<div class="card">
<h2 class="card-title"><span class="stap-nr">2</span>Excel uploaden</h2>
<p class="muted">Je krijgt eerst een voorbeeld te zien. Er wordt pas iets bewaard als je bevestigt.
{% if aantal is not none %}Er staan nu <strong>{{ aantal }}</strong> toestellen in de database.{% endif %}</p>
<form method="POST" action="{{ url_for('database_beheer.toestellen_controleren') }}" enctype="multipart/form-data"
onsubmit="showLoader('Bestand controleren...')">
<div class="field">
<label for="bestand">Excel-bestand (.xlsx)</label>
<input type="file" id="bestand" name="bestand" accept=".xlsx,.xlsm,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" required>
<div class="field-hint">Max. {{ max_rijen }} rijen. Het tabblad <em>Toestellen</em> wordt gelezen (of anders het eerste tabblad).</div>
</div>
<label>Toestellen die al bestaan</label>
<div class="modus-keuze">
<label><input type="radio" name="modus" value="aanvullen" checked>
<span>Enkel lege velden aanvullen <small>Wat al ingevuld is in PIVOT blijft staan. (Aanbevolen)</small></span></label>
<label><input type="radio" name="modus" value="overschrijven">
<span>Ingevulde cellen overschrijven <small>Wat in Excel staat, vervangt de waarde in PIVOT (ook de status). Lege cellen wissen niets.</small></span></label>
</div>
<button type="submit">🔎 Controleren</button>
</form>
</div>
</div>
{% else %}
{% set t = plan.tellers %}
<div class="card">
<div class="page-head">
<h2>🔎 Voorbeeld: {{ bestandsnaam }}</h2>
<a class="btn btn-light" href="{{ url_for('database_beheer.toestellen') }}">↩️ Ander bestand</a>
</div>
<p class="muted" style="margin-top:-8px">Nog niets bewaard. Bestaande toestellen:
<strong>{{ 'ingevulde cellen overschrijven' if plan.modus == 'overschrijven' else 'enkel lege velden aanvullen' }}</strong>.</p>
{% if onbekend %}
<div class="alert alert-warning">Deze kolommen worden genegeerd: {{ onbekend|join(', ') }}.</div>
{% endif %}
<div class="tellers" role="group" aria-label="Filter">
<button type="button" class="teller-knop actief" data-filter="">Alle <span class="n">{{ plan.rijen|length }}</span></button>
<button type="button" class="teller-knop" data-filter="nieuw"><span class="badge badge-success">➕ Nieuw</span> <span class="n">{{ t.nieuw }}</span></button>
<button type="button" class="teller-knop" data-filter="bijwerken"><span class="badge badge-info">✏️ Bijwerken</span> <span class="n">{{ t.bijwerken }}</span></button>
<button type="button" class="teller-knop" data-filter="ongewijzigd"><span class="badge">= Ongewijzigd</span> <span class="n">{{ t.ongewijzigd }}</span></button>
<button type="button" class="teller-knop" data-filter="fout"><span class="badge badge-danger">⚠️ Fout</span> <span class="n">{{ t.fout }}</span></button>
</div>
<div class="table-wrap">
<table id="voorbeeld">
<thead>
<tr>
<th>Rij</th><th>Actie</th>
{% for k in kolommen %}<th>{{ k[1] }}</th>{% endfor %}
<th>Wat gebeurt er</th>
</tr>
</thead>
<tbody>
{% for r in plan.rijen %}
<tr data-actie="{{ r.actie }}" class="{{ 'rij-fout' if r.actie == 'fout' }}">
<td class="muted">{{ r.rij }}</td>
<td>
{% if r.actie == 'nieuw' %}<span class="badge badge-success">➕ Nieuw</span>
{% elif r.actie == 'bijwerken' %}<span class="badge badge-info">✏️ Bijwerken</span>
{% elif r.actie == 'ongewijzigd' %}<span class="badge">= Ongewijzigd</span>
{% else %}<span class="badge badge-danger">⚠️ Fout</span>{% endif %}
</td>
{% for k in kolommen %}<td class="{{ 'mono' if k[0] in ('schooleigen_id', 'serienummer') }}">{{ r.invoer[k[0]] }}</td>{% endfor %}
<td class="melding">
{% if r.actie == 'fout' %}{% for m in r.meldingen %}<div class="fout">{{ m }}</div>{% endfor %}
{% else %}
{% if r.actie == 'nieuw' %}<div class="wijz">Wordt toegevoegd{% if not r.invoer.status %} (status Beschikbaar){% endif %}.</div>{% endif %}
{% if r.label %}<div>Toestel {{ r.label }}</div>{% endif %}
{% for w in r.wijzigingen %}<div class="wijz">{{ w }}</div>{% endfor %}
{% for m in r.meldingen %}<div>{{ m }}</div>{% endfor %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% set te_doen = t.nieuw + t.bijwerken %}
<form method="POST" action="{{ url_for('database_beheer.toestellen_importeren') }}" class="bevestig"
onsubmit="return confirm({{ ('Nu ' ~ t.nieuw ~ ' toestel(len) toevoegen en ' ~ t.bijwerken ~ ' bijwerken?' ~ (' De ' ~ t.fout ~ ' rij(en) met een fout worden overgeslagen.' if t.fout else ''))|tojson|forceescape }}) && (showLoader('Importeren...'), true)">
<input type="hidden" name="modus" value="{{ plan.modus }}">
<input type="hidden" name="rijen" value="{{ rijen_json }}">
<span class="muted">
{% if te_doen %}{{ t.nieuw }} nieuw en {{ t.bijwerken }} bij te werken toestel(len).
{% if t.fout %}Rijen met een fout worden overgeslagen; verbeter ze in Excel en upload opnieuw.{% endif %}
{% else %}Er is niets te importeren.{% endif %}
</span>
<span style="display:flex;gap:8px">
<a class="btn btn-light" href="{{ url_for('database_beheer.toestellen') }}">Annuleren</a>
<button type="submit" class="btn-success" {{ 'disabled' if not te_doen }}>✅ Importeren</button>
</span>
</form>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
// Voorbeeld filteren op actie (nieuw / bijwerken / ongewijzigd / fout)
document.querySelectorAll('.teller-knop').forEach(knop => knop.addEventListener('click', () => {
document.querySelectorAll('.teller-knop').forEach(k => k.classList.toggle('actief', k === knop));
const f = knop.dataset.filter;
document.querySelectorAll('#voorbeeld tbody tr').forEach(tr => {
tr.style.display = !f || tr.dataset.actie === f ? '' : 'none';
});
}));
</script>
{% endblock %}
+1 -1
View File
@@ -77,7 +77,7 @@
{% if scholen %}
<div class="per-school">
{% for code, naam in scholen %}
<button type="button" class="chip" data-school="{{ code }}" aria-pressed="false" title="Enkel {{ naam }} tonen (nog eens klikken = alle scholen)">{{ naam }} <b>{{ rijen|selectattr('School_code', 'equalto', code)|list|length }}</b></button>
<button type="button" class="chip" data-school="{{ code }}" aria-pressed="false" title="Enkel {{ naam }} tonen (nog eens klikken = alle scholen)">{{ naam }} <b>{{ per_school[code] }}</b></button>
{% endfor %}
</div>
{% endif %}
+49
View File
@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}Informat-velden{% endblock %}
{% block page_title %}Informat-velden{% endblock %}
{% block loader_text %}Leerlingenlijst ophalen bij Informat...{% endblock %}
{% block content %}
<a href="{{ url_for('leerlingenaantal.leerlingenaantal_page') }}" style="display:inline-block;margin-bottom:14px">← Leerlingenaantallen</a>
<div class="card">
<div class="page-head"><h2>🔎 Welke velden geeft Informat terug?</h2></div>
<p class="muted">Haalt de leerlingenlijst op zoals bij het tellen en toont per veld hoeveel leerlingen een waarde hebben.
Bij velden met weinig verschillende waarden zie je die waarden en hun aantal: zo vind je het veld dat de campus aangeeft.
Namen en andere persoonsgegevens worden niet getoond.</p>
<form method="GET" class="search-bar" onsubmit="showLoader()">
<select name="school" required>
<option value="">Kies een school…</option>
{% for s in scholen %}<option value="{{ s.code }}" {% if school and s.code == school.code %}selected{% endif %}>{{ s.naam }}</option>{% endfor %}
</select>
<button type="submit">🔎 Velden ophalen</button>
</form>
{% if fout %}<div class="alert alert-danger">{{ fout }}</div>{% endif %}
{% if velden is not none %}
<p><strong>{{ totaal }}</strong> leerlingen ontvangen van Informat voor {{ school.naam }}.</p>
<div class="table-wrap">
<table>
<thead><tr><th>Veld</th><th class="text-right">Gevuld bij</th><th class="text-right">Verschillende waarden</th><th>Waarden (aantal leerlingen)</th></tr></thead>
<tbody>
{% for v in velden %}
<tr {% if v.waarden|length > 1 %}style="background:var(--primary-light)"{% endif %}>
<td class="mono">{{ v.pad }}</td>
<td class="text-right">{{ v.gevuld }}</td>
<td class="text-right">{{ v.verschillend }}</td>
<td class="small">
{% if v.gevoelig %}<span class="muted">persoonsgegeven – niet getoond</span>
{% elif v.waarden %}{% for w, n in v.waarden %}<code>{{ w }}</code>&nbsp;{{ n }}{% if not loop.last %} · {% endif %}{% endfor %}
{% else %}<span class="muted">te veel verschillende waarden</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="field-hint">Gevonden? Vul het veld in bij 🔌 Koppelingen › Informat › <em>Campusveld in de leerlingenlijst</em>
en bij <em>Campussen</em> de waarden met een naam, bv. <code>1=Campus Kapellen, 3=Campus Stabroek</code>.</p>
{% endif %}
</div>
{% endblock %}