diff --git a/backend/app.py b/backend/app.py index 363ae32..bc5df5e 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt index 05d31ea..8572b57 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/routes/admin.py b/backend/routes/admin.py index a5a811e..4abf08a 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -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_.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') diff --git a/backend/services/doelen.py b/backend/services/doelen.py index 68d4747..ad2d0a5 100644 --- a/backend/services/doelen.py +++ b/backend/services/doelen.py @@ -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 diff --git a/backend/services/xlsx_converter.py b/backend/services/xlsx_converter.py new file mode 100644 index 0000000..0cde77b --- /dev/null +++ b/backend/services/xlsx_converter.py @@ -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_.xlsx + Nieuw formaat: __.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 diff --git a/backend/templates/directeur.html b/backend/templates/directeur.html index a13bf8d..dcaf5fd 100644 --- a/backend/templates/directeur.html +++ b/backend/templates/directeur.html @@ -304,7 +304,7 @@
- + diff --git a/backend/templates/doelen_beheer.html b/backend/templates/doelen_beheer.html index ee5d2b0..e665455 100644 --- a/backend/templates/doelen_beheer.html +++ b/backend/templates/doelen_beheer.html @@ -98,22 +98,72 @@

⬆️ Nieuwe bestanden uploaden

-

- 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. -

-
-
📄
- Klik of sleep JSON bestanden hier -

Meerdere bestanden tegelijk · Enkel .json · Max. 10 MB per bestand

+ + +
+ +
- + + +
+

+ Upload de originele Excel bestanden van GO! + (Doelenset_BaO_*.xlsx). De conversie naar JSON + gebeurt automatisch op de server — geen tussentijdse stap nodig. + Bij een update gewoon opnieuw uploaden. +

+
+ 💡 Nieuwe versie van GO!? + De GO! publiceert updates van de doelensets op + + pro.g-o.be → Leerplannen → BaO + . + Er is geen automatische synchronisatie — download de nieuwe xlsx bestanden + manueel en upload ze hier. +
+
+
📊
+ Klik of sleep Excel bestanden hier +

Meerdere bestanden tegelijk · Enkel .xlsx · Max. 10 MB per bestand

+
+ +
+ + + +
@@ -130,6 +180,7 @@ Bestand ID Doelzinnen Versie + Gewijzigd door GO! @@ -142,70 +193,105 @@