implement leerdoelen_converter and bugfixes with storage_uri
All checks were successful
Build & Push / Build & Push image (push) Successful in 1m1s

This commit is contained in:
2026-02-28 22:47:28 +01:00
parent f991cef71d
commit 5d0e4f9c91
8 changed files with 584 additions and 54 deletions

View File

@@ -17,6 +17,19 @@ migrate = Migrate()
limiter = Limiter(key_func=get_remote_address, default_limits=[]) limiter = Limiter(key_func=get_remote_address, default_limits=[])
def _make_limiter(redis_url: str) -> Limiter:
"""
Maak een nieuwe Limiter instantie met de correcte storage_uri.
In flask-limiter 3.x hoort storage_uri in __init__, NIET in init_app().
"""
return Limiter(
key_func=get_remote_address,
default_limits=[],
storage_uri=redis_url,
strategy='fixed-window-elastic-expiry',
)
def create_app(): def create_app():
app = Flask(__name__, template_folder='templates', static_folder='static') app = Flask(__name__, template_folder='templates', static_folder='static')
@@ -57,6 +70,10 @@ def create_app():
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
# ── Rate limiter ────────────────────────────────────────────────────────── # ── Rate limiter ──────────────────────────────────────────────────────────
# In flask-limiter 3.x hoort storage_uri in de constructor, niet in init_app().
# We vervangen de module-level limiter instantie zodat @limiter.limit decorators
# ook de juiste storage gebruiken.
global limiter
redis_url = os.environ.get('REDIS_URL', '') redis_url = os.environ.get('REDIS_URL', '')
if not redis_url: if not redis_url:
logger.warning( logger.warning(
@@ -65,12 +82,8 @@ def create_app():
"Stel REDIS_URL in voor productie." "Stel REDIS_URL in voor productie."
) )
redis_url = 'memory://' redis_url = 'memory://'
limiter.init_app( limiter = _make_limiter(redis_url)
app, limiter.init_app(app)
storage_uri=redis_url,
strategy='fixed-window-elastic-expiry', # robuuster dan fixed-window
on_breach=_rate_limit_handler,
)
# ── Security headers via Talisman ───────────────────────────────────────── # ── Security headers via Talisman ─────────────────────────────────────────
# CSP: strikte whitelist — geen inline scripts, geen externe resources buiten cdnjs # CSP: strikte whitelist — geen inline scripts, geen externe resources buiten cdnjs

View File

@@ -12,3 +12,5 @@ sqlalchemy==2.0.41
authlib==1.4.1 authlib==1.4.1
requests==2.32.3 requests==2.32.3
redis==5.2.1 # backend voor flask-limiter in productie redis==5.2.1 # backend voor flask-limiter in productie
pandas==2.2.3 # Excel verwerking (xlsx upload → JSON conversie)
openpyxl==3.1.5 # pandas Excel engine

View File

@@ -417,6 +417,97 @@ def delete_doelen(vak_id):
return jsonify({'deleted': True, 'vak_id': vak_id}) return jsonify({'deleted': True, 'vak_id': vak_id})
@admin_bp.route('/doelen/upload-xlsx', methods=['POST'])
@login_required
@scholengroep_ict_required
def upload_doelen_xlsx():
"""
Upload één of meerdere xlsx doelenset bestanden (veld: 'files').
Converteert automatisch naar JSON en slaat op — geen tussentijdse stap nodig.
Verwacht: multipart/form-data met veld 'files' (meerdere bestanden toegestaan).
Bestandsnaamconventie: Doelenset_BaO_<vak>.xlsx (GO! standaard)
"""
from services.doelen import validate_vak_json, save_vak, is_valid_vak_id
from services.xlsx_converter import converteer_xlsx_naar_json, valideer_xlsx_bestand
if 'files' not in request.files:
return jsonify({'error': 'Geen bestanden ontvangen (verwacht veld "files")'}), 400
results = []
for file in request.files.getlist('files'):
if not file.filename:
continue
result = {'filename': file.filename, 'ok': False}
# Bestandsnaam validatie
naam_fouten = valideer_xlsx_bestand(file.filename)
if naam_fouten:
result['error'] = '; '.join(naam_fouten)
results.append(result)
continue
# Lees bytes — pandas verwerkt vanuit memory, geen temp bestand
try:
inhoud = file.read()
except Exception as e:
result['error'] = f'Kon bestand niet lezen: {e}'
results.append(result)
continue
# Converteer xlsx → JSON
try:
data = converteer_xlsx_naar_json(file.filename, inhoud)
except (ValueError, ImportError) as e:
result['error'] = str(e)
results.append(result)
continue
except Exception as e:
result['error'] = f'Onverwachte fout bij conversie: {e}'
results.append(result)
continue
vak_id = data['vak']
# Extra structuurvalidatie via bestaande validator
fouten = validate_vak_json(data)
if fouten:
result['error'] = '; '.join(fouten)
results.append(result)
continue
save_vak(vak_id, data)
audit_log(
'doelen.upload_xlsx', 'doelen',
target_type='vak', target_id=vak_id,
detail={
'bronBestand': file.filename,
'aantalDoelzinnen': data['aantalDoelzinnen'],
'aantalRijen': data['aantalRijen'],
}
)
result.update({
'ok': True,
'vak_id': vak_id,
'aantalDoelzinnen': data['aantalDoelzinnen'],
'aantalRijen': data['aantalRijen'],
'versie': data.get('versie', '?'),
})
results.append(result)
ok_count = sum(1 for r in results if r['ok'])
err_count = len(results) - ok_count
return jsonify({
'ok': ok_count,
'errors': err_count,
'results': results,
}), (200 if ok_count > 0 else 400)
# ── Globale statistieken (superadmin) ───────────────────────────────────────── # ── Globale statistieken (superadmin) ─────────────────────────────────────────
@admin_bp.route('/stats') @admin_bp.route('/stats')

View File

@@ -180,6 +180,8 @@ def rebuild_index():
'naam': vak_naam(vak_id), 'naam': vak_naam(vak_id),
'aantalDoelzinnen': len(doelzinnen), 'aantalDoelzinnen': len(doelzinnen),
'versie': data.get('versie', '?'), 'versie': data.get('versie', '?'),
'bronDatum': data.get('bronDatum'), # 'gewijzigd' uit Excel metadata
'bronBestand': data.get('bronBestand'), # originele bestandsnaam
}) })
except Exception: except Exception:
pass pass

View File

@@ -0,0 +1,336 @@
"""
XLSX naar JSON converter voor GO! Basisonderwijs doelensets.
Converteert een Excel bestand naar het interne JSON formaat van de
Leerdoelen Tracker. Ondersteunt twee bestandsnaamconventies van GO!:
Oud formaat: Doelenset_BaO_<vak>.xlsx
Nieuw formaat: <nr>__<Vak>.xlsx (bv. 10__ICT.xlsx, 03__Nederlands.xlsx)
De vak ID wordt afgeleid van de sheetnaam (meest betrouwbaar) of
de bestandsnaam als fallback.
Versie-informatie: het GO! bestand bevat geen expliciete versie of datum
in de inhoud. De 'gewijzigd'-timestamp uit de Excel metadata (wb.properties.modified)
is de meest betrouwbare indicator voor updates. Deze wordt opgeslagen in het
JSON als 'bronDatum' zodat de beheerder kan zien wanneer het bestand voor het
laatste door GO! werd bijgewerkt.
Updates ophalen: https://pro.g-o.be/themas/leerplanning/basisonderwijs/nieuw-leerplan-basisonderwijs/
Er is geen publieke API — download de xlsx bestanden manueel en upload ze hier.
"""
import re
import io
import logging
logger = logging.getLogger(__name__)
# Leeftijdskolommen — meerdere varianten want GO! is niet consistent
# '2,5-4' en '3-4' zijn beide gezien voor de jongste groep
LEEFTIJD_KOLOMMEN = ['2,5-4', '3-4', '4-5', '5-6', '6-7', '7-8', '8-9', '9-10', '10-11', '11-12']
# Genormaliseerde naam voor '2,5-4' in de output (consistent met rest van de app)
LEEFTIJD_NORMALISATIE = {'2,5-4': '2,5-4'} # bewaren zoals het is, frontend toont het zo
# Hiërarchische structuurtypes (van hoog naar laag niveau)
HIERARCHIE_TYPES = ['onderwerp', 'rubriek', 'subrubriek', 'subthema']
# Types die gekoppeld worden aan de vorige doelzin
DOELZIN_KINDEREN = {
'MIA - titel', 'MIA - aanklikbaar', 'MIA - niet aanklikbaar',
'te hanteren begrippen', 'voorbeelden - titel', 'voorbeelden - bullet', 'asterisk',
}
# Vak naam mapping — dekt beide bestandsnaamconventies + sheetnamen
VAK_NAMEN = {
# Op vak ID (intern formaat)
'doelenset-bao-aardrijkskunde': 'Aardrijkskunde',
'doelenset-bao-burgerschap': 'Burgerschap',
'doelenset-bao-frans': 'Frans',
'doelenset-bao-geschiedenis': 'Geschiedenis',
'doelenset-bao-ict': 'ICT',
'doelenset-bao-leren-leren': 'Leren leren',
'doelenset-bao-lichamelijke-opvoeding': 'Lichamelijke opvoeding',
'doelenset-bao-muzische-vorming': 'Muzische vorming',
'doelenset-bao-nederlands': 'Nederlands',
'doelenset-bao-sociale-vaardigheden': 'Sociale vaardigheden',
'doelenset-bao-wetenschap-techniek': 'Wetenschap en techniek',
'doelenset-bao-wiskunde': 'Wiskunde',
# Op sheetnaam (lowercase) — nieuw GO! formaat
'ict': 'ICT',
'nederlands': 'Nederlands',
'wiskunde': 'Wiskunde',
'aardrijkskunde': 'Aardrijkskunde',
'burgerschap': 'Burgerschap',
'frans': 'Frans',
'geschiedenis': 'Geschiedenis',
'leren leren': 'Leren leren',
'lichamelijke opvoeding': 'Lichamelijke opvoeding',
'muzische vorming': 'Muzische vorming',
'sociale vaardigheden': 'Sociale vaardigheden',
'wetenschap en techniek': 'Wetenschap en techniek',
'wetenschap & techniek': 'Wetenschap en techniek',
}
# Mapping van sheetnaam → intern vak ID
SHEET_NAAR_VAK_ID = {
'ict': 'doelenset-bao-ict',
'nederlands': 'doelenset-bao-nederlands',
'wiskunde': 'doelenset-bao-wiskunde',
'aardrijkskunde': 'doelenset-bao-aardrijkskunde',
'burgerschap': 'doelenset-bao-burgerschap',
'frans': 'doelenset-bao-frans',
'geschiedenis': 'doelenset-bao-geschiedenis',
'leren leren': 'doelenset-bao-leren-leren',
'lichamelijke opvoeding': 'doelenset-bao-lichamelijke-opvoeding',
'muzische vorming': 'doelenset-bao-muzische-vorming',
'sociale vaardigheden': 'doelenset-bao-sociale-vaardigheden',
'wetenschap en techniek': 'doelenset-bao-wetenschap-techniek',
'wetenschap & techniek': 'doelenset-bao-wetenschap-techniek',
}
def vak_id_van_sheetnaam(sheetnaam: str) -> str | None:
"""Leidt vak ID af van de sheetnaam (meest betrouwbaar)."""
key = sheetnaam.strip().lower()
return SHEET_NAAR_VAK_ID.get(key)
def vak_id_van_bestandsnaam(bestandsnaam: str) -> str:
"""
Leidt vak ID af van de bestandsnaam als fallback.
Ondersteunt:
Oud: Doelenset_BaO_wiskunde.xlsx → doelenset-bao-wiskunde
Nieuw: 10__ICT.xlsx → doelenset-bao-ict
03__Nederlands.xlsx → doelenset-bao-nederlands
07__Muzische_vorming.xlsx → doelenset-bao-muzische-vorming
"""
naam = bestandsnaam
if naam.lower().endswith('.xlsx'):
naam = naam[:-5]
# Verwijder kopie-suffixen
naam = re.sub(r'[\s_]*-[\s_]*[Cc]opy.*$', '', naam)
naam = re.sub(r'\s*\(\d+\)\s*$', '', naam)
# Nieuw formaat: verwijder nummer-prefix (10__, 03__, etc.)
naam = re.sub(r'^\d+__', '', naam)
# Vervang underscores en spaties door streepjes, lowercase
naam = naam.replace('_', '-').replace(' ', '-').lower()
# Specifieke correcties
naam = naam.replace('-en-techniek', '-techniek')
# Opruimen
naam = re.sub(r'-+', '-', naam).strip('-')
# Probeer te mappen naar bekend vak ID via sheetnaam-logica
known = SHEET_NAAR_VAK_ID.get(naam.replace('-', ' '))
if known:
return known
# Fallback: voeg prefix toe als die er nog niet is
if not naam.startswith('doelenset-bao-'):
naam = f'doelenset-bao-{naam}'
return naam
def converteer_xlsx_naar_json(bestandsnaam: str, bestand_inhoud: bytes) -> dict:
"""
Converteert een Excel bestand (als bytes) naar het interne JSON formaat.
Args:
bestandsnaam: Originele bestandsnaam, bv. "10__ICT.xlsx"
bestand_inhoud: Raw bytes van het xlsx bestand
Returns:
Dict met het volledige vak JSON object, klaar om op te slaan.
Raises:
ValueError: Als het bestand niet gelezen kan worden of structuur klopt niet.
ImportError: Als pandas/openpyxl niet geïnstalleerd zijn.
"""
try:
import pandas as pd
import openpyxl
except ImportError:
raise ImportError(
"pandas en openpyxl zijn niet geïnstalleerd. "
"Voeg 'pandas' en 'openpyxl' toe aan requirements.txt"
)
buf = io.BytesIO(bestand_inhoud)
# Lees metadata voor versiedatum (beste versie-indicator beschikbaar)
bron_datum = None
sheet_naam = None
try:
wb = openpyxl.load_workbook(buf, read_only=True)
sheet_naam = wb.sheetnames[0] if wb.sheetnames else None
if wb.properties.modified:
bron_datum = wb.properties.modified.strftime('%Y-%m-%d')
wb.close()
except Exception as e:
logger.warning(f"Kon workbook metadata niet lezen: {e}")
# Bepaal vak ID: sheetnaam heeft prioriteit over bestandsnaam
vak_id = None
if sheet_naam:
vak_id = vak_id_van_sheetnaam(sheet_naam)
if vak_id:
logger.info(f"Vak ID bepaald via sheetnaam '{sheet_naam}': {vak_id}")
if not vak_id:
vak_id = vak_id_van_bestandsnaam(bestandsnaam)
logger.info(f"Vak ID bepaald via bestandsnaam '{bestandsnaam}': {vak_id}")
# Lees Excel data
buf.seek(0)
try:
df = pd.read_excel(buf, engine='openpyxl')
except Exception as e:
raise ValueError(f"Kon Excel bestand niet lezen: {e}")
# Minimale kolomcheck
verplichte_kolommen = {'TYPE', 'GO! NR.', 'INHOUD'}
ontbrekend = verplichte_kolommen - set(df.columns)
if ontbrekend:
raise ValueError(
f"Verplichte kolommen ontbreken: {', '.join(sorted(ontbrekend))}. "
f"Is dit een geldig GO! doelenset bestand?"
)
# KENNISVERWERKING: sommige bestanden (bv. Wiskunde) missen de kolomkop waardoor
# pandas de kolom 'Unnamed: 12' noemt. Detecteer op basis van inhoud als fallback.
kennis_kolom = 'KENNISVERWERKING'
if kennis_kolom not in df.columns:
ebg_waarden = {'engageren', 'begrijpen', 'gebruiken'}
for col in df.columns:
uniek = set(str(v).lower() for v in df[col].dropna().unique())
if uniek and uniek.issubset(ebg_waarden):
kennis_kolom = col
logger.warning(
f"KENNISVERWERKING kolom niet gevonden bij naam in '{bestandsnaam}'"
f"gebruik '{col}' op basis van inhoud (engageren/begrijpen/gebruiken)"
)
break
# Bepaal aanwezige leeftijdskolommen (volgorde bewaren)
aanwezige_leeftijden = [l for l in LEEFTIJD_KOLOMMEN if l in df.columns]
# Hiërarchie-tracker
huidige_parents = {niveau: None for niveau in HIERARCHIE_TYPES}
huidige_doelzin_id = None
rijen = []
aantal_doelzinnen = 0
for idx, row in df.iterrows():
rij_id = idx + 1
row_type = row.get('TYPE', '')
if pd.isna(row_type):
row_type = ''
row_type = str(row_type).strip()
# Leeftijden: True of 1 in de kolom
leeftijden = [
lft for lft in aanwezige_leeftijden
if row.get(lft) is True or row.get(lft) == 1
]
def cel(kolom):
val = row.get(kolom)
try:
if pd.isna(val):
return None
except (TypeError, ValueError):
pass
s = str(val).strip() if val is not None else None
return s if s else None
rij = {
'id': rij_id,
'type': row_type or None,
'goNr': cel('GO! NR.'),
'inhoud': cel('INHOUD'),
'koNummer': cel('KO nummer'),
'koMinimumdoel': cel('KO minimumdoel'),
'koOmschrijving': cel('KO omschrijving'),
'l4Nummer': cel('L4 nummer'),
'l4Minimumdoel': cel('L4 minimumdoel'),
'l4Omschrijving': cel('L4 omschrijving'),
'l6Nummer': cel('L6 nummer'),
'l6Minimumdoel': cel('L6 minimumdoel'),
'l6Omschrijving': cel('L6 omschrijving'),
'kennisverwerking': cel(kennis_kolom),
'leeftijden': leeftijden,
'vlaggen': cel('VLAGGEN'),
'schakels': cel('SCHAKELS'),
}
# Hiërarchie bijhouden
if row_type in HIERARCHIE_TYPES:
huidige_parents[row_type] = rij_id
reset = False
for niveau in HIERARCHIE_TYPES:
if reset:
huidige_parents[niveau] = None
if niveau == row_type:
reset = True
# minimumdoel: behandelen als structuurelement (onder subthema, boven doelzin)
# Bevat nuttige L4/L6 info maar geen eigen GO! nummer — koppelen aan subthema
if row_type == 'minimumdoel':
rij['parentId'] = huidige_parents.get('subthema') or huidige_parents.get('onderwerp')
# Parent koppelen voor doelzinnen
elif row_type == 'doelzin':
aantal_doelzinnen += 1
huidige_doelzin_id = rij_id
for niveau in reversed(HIERARCHIE_TYPES):
if huidige_parents[niveau]:
rij['parentId'] = huidige_parents[niveau]
break
# MIA en gerelateerde items koppelen aan laatste doelzin
elif row_type and (row_type.startswith('MIA') or row_type in DOELZIN_KINDEREN):
rij['parentDoelzinId'] = huidige_doelzin_id
rijen.append(rij)
if aantal_doelzinnen == 0:
raise ValueError(
"Geen doelzinnen gevonden (type='doelzin'). "
"Controleer of dit het juiste bestand is."
)
vak_naam = VAK_NAMEN.get(vak_id, VAK_NAMEN.get(
sheet_naam.lower() if sheet_naam else '',
vak_id.replace('doelenset-bao-', '').replace('-', ' ').title()
))
logger.info(
f"XLSX geconverteerd: {bestandsnaam}{vak_id} "
f"({len(rijen)} rijen, {aantal_doelzinnen} doelzinnen, bronDatum: {bron_datum})"
)
return {
'vak': vak_id,
'vakNaam': vak_naam,
'versie': '2025-01',
'bronBestand': bestandsnaam,
'bronDatum': bron_datum, # 'gewijzigd' timestamp uit Excel metadata
'aantalRijen': len(rijen),
'aantalDoelzinnen': aantal_doelzinnen,
'rijen': rijen,
}
def valideer_xlsx_bestand(bestandsnaam: str) -> list:
"""Snelle validatie van de bestandsnaam. Geeft lijst van foutmeldingen."""
fouten = []
if not bestandsnaam.lower().endswith('.xlsx'):
fouten.append("Alleen .xlsx bestanden zijn toegestaan (geen .xls of .csv)")
return fouten

View File

@@ -304,7 +304,7 @@
<div class="filter-group" style="min-width:unset;"> <div class="filter-group" style="min-width:unset;">
<label>Leeftijd</label> <label>Leeftijd</label>
<div style="display:flex;flex-wrap:wrap;gap:.3rem;"> <div style="display:flex;flex-wrap:wrap;gap:.3rem;">
<label class="leeftijd-checkbox"><input type="checkbox" value="3-4" onchange="applyFilters()"><span>3-4</span></label> <label class="leeftijd-checkbox"><input type="checkbox" value="2,5-4" onchange="applyFilters()"><span>2,5-4</span></label>
<label class="leeftijd-checkbox"><input type="checkbox" value="4-5" onchange="applyFilters()"><span>4-5</span></label> <label class="leeftijd-checkbox"><input type="checkbox" value="4-5" onchange="applyFilters()"><span>4-5</span></label>
<label class="leeftijd-checkbox"><input type="checkbox" value="5-6" onchange="applyFilters()"><span>5-6</span></label> <label class="leeftijd-checkbox"><input type="checkbox" value="5-6" onchange="applyFilters()"><span>5-6</span></label>
<label class="leeftijd-checkbox"><input type="checkbox" value="6-7" onchange="applyFilters()"><span>6-7</span></label> <label class="leeftijd-checkbox"><input type="checkbox" value="6-7" onchange="applyFilters()"><span>6-7</span></label>

View File

@@ -98,22 +98,72 @@
<div class="section-header"> <div class="section-header">
<h2>⬆️ Nieuwe bestanden uploaden</h2> <h2>⬆️ Nieuwe bestanden uploaden</h2>
</div> </div>
<!-- Format tabs -->
<div style="display:flex;gap:.5rem;margin-bottom:1rem;">
<button class="btn btn-primary" id="tabXlsx" onclick="switchUploadTab('xlsx')"
style="font-size:.85rem;">
📊 Excel (.xlsx) — aanbevolen
</button>
<button class="btn btn-secondary" id="tabJson" onclick="switchUploadTab('json')"
style="font-size:.85rem;">
📄 JSON
</button>
</div>
<!-- XLSX upload -->
<div id="panelXlsx">
<p class="section-hint"> <p class="section-hint">
Sleep de JSON bestanden van GO! hierheen of klik om te bladeren. Upload de originele Excel bestanden van GO!
Meerdere bestanden tegelijk zijn mogelijk. Bij een update gewoon opnieuw uploaden — (<code>Doelenset_BaO_*.xlsx</code>). De conversie naar JSON
bestaande bestanden worden automatisch overschreven en de index wordt bijgewerkt. gebeurt automatisch op de server — geen tussentijdse stap nodig.
Bij een update gewoon opnieuw uploaden.
</p> </p>
<div class="drop-zone" id="dropZone" <div class="info-box" style="background:var(--gray-50);border:1px solid var(--gray-200);
onclick="document.getElementById('fileInput').click()" border-left:4px solid var(--primary);border-radius:6px;padding:.85rem 1rem;
margin-bottom:1rem;font-size:.83rem;color:var(--gray-600);">
💡 <strong>Nieuwe versie van GO!?</strong>
De GO! publiceert updates van de doelensets op
<a href="https://pro.g-o.be/themas/leerplannen/basisonderwijs/nieuw-leerplan-basisonderwijs/" target="_blank"
rel="noopener noreferrer" style="color:var(--primary);">
pro.g-o.be → Leerplannen → BaO
</a>.
Er is geen automatische synchronisatie — download de nieuwe xlsx bestanden
manueel en upload ze hier.
</div>
<div class="drop-zone" id="dropZoneXlsx"
onclick="document.getElementById('fileInputXlsx').click()"
ondragover="event.preventDefault();this.classList.add('over')" ondragover="event.preventDefault();this.classList.add('over')"
ondragleave="this.classList.remove('over')" ondragleave="this.classList.remove('over')"
ondrop="this.classList.remove('over');handleDrop(event)"> ondrop="this.classList.remove('over');handleDropXlsx(event)">
<div class="drop-icon">📊</div>
<strong>Klik of sleep Excel bestanden hier</strong>
<p>Meerdere bestanden tegelijk · Enkel .xlsx · Max. 10 MB per bestand</p>
</div>
<input type="file" id="fileInputXlsx" accept=".xlsx" multiple style="display:none"
onchange="uploadXlsx(this.files)">
</div>
<!-- JSON upload -->
<div id="panelJson" style="display:none;">
<p class="section-hint">
Upload reeds geconverteerde JSON bestanden. Gebruik dit enkel als je
de Excel bestanden niet beschikbaar hebt of als je handmatig aangepaste
JSON wil laden.
</p>
<div class="drop-zone" id="dropZoneJson"
onclick="document.getElementById('fileInputJson').click()"
ondragover="event.preventDefault();this.classList.add('over')"
ondragleave="this.classList.remove('over')"
ondrop="this.classList.remove('over');handleDropJson(event)">
<div class="drop-icon">📄</div> <div class="drop-icon">📄</div>
<strong>Klik of sleep JSON bestanden hier</strong> <strong>Klik of sleep JSON bestanden hier</strong>
<p>Meerdere bestanden tegelijk · Enkel .json · Max. 10 MB per bestand</p> <p>Meerdere bestanden tegelijk · Enkel .json · Max. 10 MB per bestand</p>
</div> </div>
<input type="file" id="fileInput" accept=".json" multiple style="display:none" <input type="file" id="fileInputJson" accept=".json" multiple style="display:none"
onchange="uploadDoelen(this.files)"> onchange="uploadJson(this.files)">
</div>
<div id="uploadResults" style="margin-top:.85rem;display:none;"></div> <div id="uploadResults" style="margin-top:.85rem;display:none;"></div>
</div> </div>
@@ -130,6 +180,7 @@
<th>Bestand ID</th> <th>Bestand ID</th>
<th>Doelzinnen</th> <th>Doelzinnen</th>
<th>Versie</th> <th>Versie</th>
<th>Gewijzigd door GO!</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -142,70 +193,105 @@
</div> </div>
<div class="notification" id="notification"></div> <div class="notification" id="notification"></div>
<script> <script>
document.addEventListener('DOMContentLoaded', loadDoelen); document.addEventListener('DOMContentLoaded', () => {
loadDoelen();
switchUploadTab('xlsx');
});
// ── Tab wisselen ──────────────────────────────────────────────────────────────
function switchUploadTab(tab) {
document.getElementById('panelXlsx').style.display = tab === 'xlsx' ? '' : 'none';
document.getElementById('panelJson').style.display = tab === 'json' ? '' : 'none';
document.getElementById('tabXlsx').className = 'btn ' + (tab === 'xlsx' ? 'btn-primary' : 'btn-secondary');
document.getElementById('tabJson').className = 'btn ' + (tab === 'json' ? 'btn-primary' : 'btn-secondary');
document.getElementById('tabXlsx').style.fontSize = '.85rem';
document.getElementById('tabJson').style.fontSize = '.85rem';
}
// ── Doelen overzicht laden ────────────────────────────────────────────────────
async function loadDoelen() { async function loadDoelen() {
const res = await fetch('/admin/doelen'); const res = await fetch('/admin/doelen');
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
// Versie badge
if (data.versie) { if (data.versie) {
document.getElementById('doelenVersie').innerHTML = document.getElementById('doelenVersie').innerHTML =
`<span class="versie-badge">index versie ${data.versie}</span>`; `<span class="versie-badge">index versie ${data.versie}</span>`;
document.getElementById('statVersie').textContent = data.versie; document.getElementById('statVersie').textContent = data.versie;
} }
// Stats
const vakken = data.vakken || []; const vakken = data.vakken || [];
document.getElementById('statVakken').textContent = vakken.length; document.getElementById('statVakken').textContent = vakken.length;
document.getElementById('statDoelen').textContent = document.getElementById('statDoelen').textContent =
vakken.reduce((s, v) => s + (v.aantalDoelzinnen || 0), 0).toLocaleString('nl-BE'); vakken.reduce((s, v) => s + (v.aantalDoelzinnen || 0), 0).toLocaleString('nl-BE');
// Tabel
const tbody = document.getElementById('doelenBody'); const tbody = document.getElementById('doelenBody');
if (!vakken.length) { if (!vakken.length) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="5">Nog geen doelen geüpload</td></tr>'; tbody.innerHTML = '<tr class="empty-row"><td colspan="6">Nog geen doelen geüpload</td></tr>';
return; return;
} }
tbody.innerHTML = vakken.map(v => ` tbody.innerHTML = vakken.map(v => {
<tr> const vn = v.naam.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
const datum = v.bronDatum
? `<span title="'Gewijzigd op' datum uit Excel metadata van GO!">${v.bronDatum}</span>`
: `<span style="color:var(--gray-400)">onbekend</span>`;
return `<tr>
<td><strong>${v.naam}</strong></td> <td><strong>${v.naam}</strong></td>
<td style="font-family:monospace;font-size:.78rem;color:var(--gray-500);">${v.id}</td> <td style="font-family:monospace;font-size:.78rem;color:var(--gray-500);">${v.id}</td>
<td>${(v.aantalDoelzinnen||0).toLocaleString('nl-BE')}</td> <td>${(v.aantalDoelzinnen||0).toLocaleString('nl-BE')}</td>
<td><span class="versie-badge">${v.versie || '?'}</span></td> <td><span class="versie-badge">${v.versie || '?'}</span></td>
<td> <td style="font-size:.83rem;">${datum}</td>
<button class="btn btn-danger btn-sm" <td><button class="btn btn-danger btn-sm"
onclick="deleteDoelen('${v.id}','${v.naam.replace(/'/g,"\\'")}')"> onclick="deleteDoelen('${v.id}','${vn}')">Verwijderen</button></td>
Verwijderen </tr>`;
</button> }).join('');
</td>
</tr>`).join('');
} }
function handleDrop(e) { e.preventDefault(); uploadDoelen(e.dataTransfer.files); } // ── Upload helpers ────────────────────────────────────────────────────────────
function toonResultaten(data) {
async function uploadDoelen(files) {
if (!files?.length) return;
const el = document.getElementById('uploadResults'); const el = document.getElementById('uploadResults');
el.style.display = 'block';
el.innerHTML = `<p style="color:var(--gray-500);font-size:.85rem;">⏳ Uploaden van ${files.length} bestand(en)...</p>`;
const fd = new FormData();
for (const f of files) fd.append('files', f);
const res = await fetch('/admin/doelen/upload', { method: 'POST', body: fd });
const data = await res.json();
const ok = data.results.filter(r => r.ok); const ok = data.results.filter(r => r.ok);
const err = data.results.filter(r => !r.ok); const err = data.results.filter(r => !r.ok);
let html = ''; let html = '';
if (ok.length) html += `<div class="upload-ok"><strong>✓ ${ok.length} bestand(en) geüpload</strong><ul>${ok.map(r=>`<li>${r.vak_naam}${r.aantalDoelzinnen} doelzinnen (v${r.versie})</li>`).join('')}</ul></div>`; if (ok.length)
if (err.length) html += `<div class="upload-err"><strong> ${err.length} mislukt</strong><ul>${err.map(r=>`<li>${r.filename}: ${r.error}</li>`).join('')}</ul></div>`; html += `<div class="upload-ok"><strong> ${ok.length} bestand(en) verwerkt</strong><ul>`
+ ok.map(r => `<li><strong>${r.vak_id}</strong> — ${r.aantalDoelzinnen} doelzinnen (v${r.versie})</li>`).join('')
+ `</ul></div>`;
if (err.length)
html += `<div class="upload-err"><strong>✗ ${err.length} mislukt</strong><ul>`
+ err.map(r => `<li><strong>${r.filename}</strong>: ${r.error}</li>`).join('')
+ `</ul></div>`;
el.innerHTML = html; el.innerHTML = html;
await loadDoelen(); el.style.display = 'block';
setTimeout(() => { el.style.display='none'; }, 12000); loadDoelen();
setTimeout(() => { el.style.display = 'none'; }, 15000);
} }
async function doUpload(endpoint, files) {
if (!files?.length) return;
const el = document.getElementById('uploadResults');
el.style.display = 'block';
el.innerHTML = `<p style="color:var(--gray-500);font-size:.85rem;">⏳ Verwerken van ${files.length} bestand(en)…`
+ (endpoint.includes('xlsx') ? ' (Excel conversie kan even duren)' : '') + `</p>`;
const fd = new FormData();
for (const f of files) fd.append('files', f);
try {
const res = await fetch(endpoint, { method: 'POST', body: fd });
const data = await res.json();
toonResultaten(data);
} catch (e) {
el.innerHTML = `<div class="upload-err"><strong>✗ Netwerkfout:</strong> ${e.message}</div>`;
}
}
// ── XLSX upload ───────────────────────────────────────────────────────────────
function handleDropXlsx(e) { e.preventDefault(); uploadXlsx(e.dataTransfer.files); }
function uploadXlsx(files) { doUpload('/admin/doelen/upload-xlsx', files); }
// ── JSON upload ───────────────────────────────────────────────────────────────
function handleDropJson(e) { e.preventDefault(); uploadJson(e.dataTransfer.files); }
function uploadJson(files) { doUpload('/admin/doelen/upload', files); }
// ── Verwijderen ───────────────────────────────────────────────────────────────
async function deleteDoelen(vakId, vakNaam) { async function deleteDoelen(vakId, vakNaam) {
if (!confirm(`Doelen voor "${vakNaam}" verwijderen?`)) return; if (!confirm(`Doelen voor "${vakNaam}" verwijderen? Bestaande beoordelingen blijven bewaard.`)) return;
const res = await fetch(`/admin/doelen/${vakId}`, { method: 'DELETE' }); const res = await fetch(`/admin/doelen/${vakId}`, { method: 'DELETE' });
if (!res.ok) { notify('Verwijderen mislukt', 'error'); return; } if (!res.ok) { notify('Verwijderen mislukt', 'error'); return; }
notify(`${vakNaam} verwijderd`, 'success'); notify(`${vakNaam} verwijderd`, 'success');

View File

@@ -305,7 +305,7 @@
<div class="filter-group" style="grid-column: span 2;"> <div class="filter-group" style="grid-column: span 2;">
<label>Leeftijd</label> <label>Leeftijd</label>
<div class="leeftijd-checkboxes"> <div class="leeftijd-checkboxes">
{% for age in ['3-4','4-5','5-6','6-7','7-8','8-9','9-10','10-11','11-12'] %} {% for age in ['2,5-4','4-5','5-6','6-7','7-8','8-9','9-10','10-11','11-12'] %}
<label class="leeftijd-checkbox"><input type="checkbox" value="{{ age }}" onchange="applyFilters()"><span>{{ age }}</span></label> <label class="leeftijd-checkbox"><input type="checkbox" value="{{ age }}" onchange="applyFilters()"><span>{{ age }}</span></label>
{% endfor %} {% endfor %}
</div> </div>