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:
@@ -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
|
||||
Reference in New Issue
Block a user