implement leerdoelen_converter and bugfixes with storage_uri
All checks were successful
Build & Push / Build & Push image (push) Successful in 1m1s
All checks were successful
Build & Push / Build & Push image (push) Successful in 1m1s
This commit is contained in:
@@ -17,6 +17,19 @@ migrate = Migrate()
|
||||
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():
|
||||
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)
|
||||
|
||||
# ── 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', '')
|
||||
if not redis_url:
|
||||
logger.warning(
|
||||
@@ -65,12 +82,8 @@ def create_app():
|
||||
"Stel REDIS_URL in voor productie."
|
||||
)
|
||||
redis_url = 'memory://'
|
||||
limiter.init_app(
|
||||
app,
|
||||
storage_uri=redis_url,
|
||||
strategy='fixed-window-elastic-expiry', # robuuster dan fixed-window
|
||||
on_breach=_rate_limit_handler,
|
||||
)
|
||||
limiter = _make_limiter(redis_url)
|
||||
limiter.init_app(app)
|
||||
|
||||
# ── Security headers via Talisman ─────────────────────────────────────────
|
||||
# CSP: strikte whitelist — geen inline scripts, geen externe resources buiten cdnjs
|
||||
|
||||
@@ -12,3 +12,5 @@ sqlalchemy==2.0.41
|
||||
authlib==1.4.1
|
||||
requests==2.32.3
|
||||
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
|
||||
|
||||
@@ -417,6 +417,97 @@ def delete_doelen(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) ─────────────────────────────────────────
|
||||
|
||||
@admin_bp.route('/stats')
|
||||
|
||||
@@ -180,6 +180,8 @@ def rebuild_index():
|
||||
'naam': vak_naam(vak_id),
|
||||
'aantalDoelzinnen': len(doelzinnen),
|
||||
'versie': data.get('versie', '?'),
|
||||
'bronDatum': data.get('bronDatum'), # 'gewijzigd' uit Excel metadata
|
||||
'bronBestand': data.get('bronBestand'), # originele bestandsnaam
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
336
backend/services/xlsx_converter.py
Normal file
336
backend/services/xlsx_converter.py
Normal 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
|
||||
@@ -304,7 +304,7 @@
|
||||
<div class="filter-group" style="min-width:unset;">
|
||||
<label>Leeftijd</label>
|
||||
<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="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>
|
||||
|
||||
@@ -98,22 +98,72 @@
|
||||
<div class="section-header">
|
||||
<h2>⬆️ Nieuwe bestanden uploaden</h2>
|
||||
</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">
|
||||
Sleep de JSON bestanden van GO! hierheen of klik om te bladeren.
|
||||
Meerdere bestanden tegelijk zijn mogelijk. Bij een update gewoon opnieuw uploaden —
|
||||
bestaande bestanden worden automatisch overschreven en de index wordt bijgewerkt.
|
||||
Upload de originele Excel bestanden van GO!
|
||||
(<code>Doelenset_BaO_*.xlsx</code>). De conversie naar JSON
|
||||
gebeurt automatisch op de server — geen tussentijdse stap nodig.
|
||||
Bij een update gewoon opnieuw uploaden.
|
||||
</p>
|
||||
<div class="drop-zone" id="dropZone"
|
||||
onclick="document.getElementById('fileInput').click()"
|
||||
<div class="info-box" style="background:var(--gray-50);border:1px solid var(--gray-200);
|
||||
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')"
|
||||
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>
|
||||
<strong>Klik of sleep JSON bestanden hier</strong>
|
||||
<p>Meerdere bestanden tegelijk · Enkel .json · Max. 10 MB per bestand</p>
|
||||
</div>
|
||||
<input type="file" id="fileInput" accept=".json" multiple style="display:none"
|
||||
onchange="uploadDoelen(this.files)">
|
||||
<input type="file" id="fileInputJson" accept=".json" multiple style="display:none"
|
||||
onchange="uploadJson(this.files)">
|
||||
</div>
|
||||
|
||||
<div id="uploadResults" style="margin-top:.85rem;display:none;"></div>
|
||||
</div>
|
||||
|
||||
@@ -130,6 +180,7 @@
|
||||
<th>Bestand ID</th>
|
||||
<th>Doelzinnen</th>
|
||||
<th>Versie</th>
|
||||
<th>Gewijzigd door GO!</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -142,70 +193,105 @@
|
||||
</div>
|
||||
<div class="notification" id="notification"></div>
|
||||
<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() {
|
||||
const res = await fetch('/admin/doelen');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
|
||||
// Versie badge
|
||||
if (data.versie) {
|
||||
document.getElementById('doelenVersie').innerHTML =
|
||||
`<span class="versie-badge">index versie ${data.versie}</span>`;
|
||||
document.getElementById('statVersie').textContent = data.versie;
|
||||
}
|
||||
|
||||
// Stats
|
||||
const vakken = data.vakken || [];
|
||||
document.getElementById('statVakken').textContent = vakken.length;
|
||||
document.getElementById('statDoelen').textContent =
|
||||
vakken.reduce((s, v) => s + (v.aantalDoelzinnen || 0), 0).toLocaleString('nl-BE');
|
||||
|
||||
// Tabel
|
||||
const tbody = document.getElementById('doelenBody');
|
||||
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;
|
||||
}
|
||||
tbody.innerHTML = vakken.map(v => `
|
||||
<tr>
|
||||
tbody.innerHTML = vakken.map(v => {
|
||||
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 style="font-family:monospace;font-size:.78rem;color:var(--gray-500);">${v.id}</td>
|
||||
<td>${(v.aantalDoelzinnen||0).toLocaleString('nl-BE')}</td>
|
||||
<td><span class="versie-badge">${v.versie || '?'}</span></td>
|
||||
<td>
|
||||
<button class="btn btn-danger btn-sm"
|
||||
onclick="deleteDoelen('${v.id}','${v.naam.replace(/'/g,"\\'")}')">
|
||||
Verwijderen
|
||||
</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
<td style="font-size:.83rem;">${datum}</td>
|
||||
<td><button class="btn btn-danger btn-sm"
|
||||
onclick="deleteDoelen('${v.id}','${vn}')">Verwijderen</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function handleDrop(e) { e.preventDefault(); uploadDoelen(e.dataTransfer.files); }
|
||||
|
||||
async function uploadDoelen(files) {
|
||||
if (!files?.length) return;
|
||||
// ── Upload helpers ────────────────────────────────────────────────────────────
|
||||
function toonResultaten(data) {
|
||||
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 err = data.results.filter(r => !r.ok);
|
||||
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 (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>`;
|
||||
if (ok.length)
|
||||
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;
|
||||
await loadDoelen();
|
||||
setTimeout(() => { el.style.display='none'; }, 12000);
|
||||
el.style.display = 'block';
|
||||
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) {
|
||||
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' });
|
||||
if (!res.ok) { notify('Verwijderen mislukt', 'error'); return; }
|
||||
notify(`${vakNaam} verwijderd`, 'success');
|
||||
|
||||
@@ -305,7 +305,7 @@
|
||||
<div class="filter-group" style="grid-column: span 2;">
|
||||
<label>Leeftijd</label>
|
||||
<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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user