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=[])
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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;">
|
<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>
|
||||||
|
|||||||
@@ -98,22 +98,72 @@
|
|||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>⬆️ Nieuwe bestanden uploaden</h2>
|
<h2>⬆️ Nieuwe bestanden uploaden</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="section-hint">
|
|
||||||
Sleep de JSON bestanden van GO! hierheen of klik om te bladeren.
|
<!-- Format tabs -->
|
||||||
Meerdere bestanden tegelijk zijn mogelijk. Bij een update gewoon opnieuw uploaden —
|
<div style="display:flex;gap:.5rem;margin-bottom:1rem;">
|
||||||
bestaande bestanden worden automatisch overschreven en de index wordt bijgewerkt.
|
<button class="btn btn-primary" id="tabXlsx" onclick="switchUploadTab('xlsx')"
|
||||||
</p>
|
style="font-size:.85rem;">
|
||||||
<div class="drop-zone" id="dropZone"
|
📊 Excel (.xlsx) — aanbevolen
|
||||||
onclick="document.getElementById('fileInput').click()"
|
</button>
|
||||||
ondragover="event.preventDefault();this.classList.add('over')"
|
<button class="btn btn-secondary" id="tabJson" onclick="switchUploadTab('json')"
|
||||||
ondragleave="this.classList.remove('over')"
|
style="font-size:.85rem;">
|
||||||
ondrop="this.classList.remove('over');handleDrop(event)">
|
📄 JSON
|
||||||
<div class="drop-icon">📄</div>
|
</button>
|
||||||
<strong>Klik of sleep JSON bestanden hier</strong>
|
|
||||||
<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"
|
|
||||||
onchange="uploadDoelen(this.files)">
|
<!-- XLSX upload -->
|
||||||
|
<div id="panelXlsx">
|
||||||
|
<p class="section-hint">
|
||||||
|
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="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');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="fileInputJson" accept=".json" multiple style="display:none"
|
||||||
|
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) {
|
||||||
|
const el = document.getElementById('uploadResults');
|
||||||
|
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) 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.style.display = 'block';
|
||||||
|
loadDoelen();
|
||||||
|
setTimeout(() => { el.style.display = 'none'; }, 15000);
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadDoelen(files) {
|
async function doUpload(endpoint, files) {
|
||||||
if (!files?.length) return;
|
if (!files?.length) return;
|
||||||
const el = document.getElementById('uploadResults');
|
const el = document.getElementById('uploadResults');
|
||||||
el.style.display = 'block';
|
el.style.display = 'block';
|
||||||
el.innerHTML = `<p style="color:var(--gray-500);font-size:.85rem;">⏳ Uploaden van ${files.length} bestand(en)...</p>`;
|
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();
|
const fd = new FormData();
|
||||||
for (const f of files) fd.append('files', f);
|
for (const f of files) fd.append('files', f);
|
||||||
const res = await fetch('/admin/doelen/upload', { method: 'POST', body: fd });
|
try {
|
||||||
const data = await res.json();
|
const res = await fetch(endpoint, { method: 'POST', body: fd });
|
||||||
const ok = data.results.filter(r => r.ok);
|
const data = await res.json();
|
||||||
const err = data.results.filter(r => !r.ok);
|
toonResultaten(data);
|
||||||
let html = '';
|
} catch (e) {
|
||||||
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>`;
|
el.innerHTML = `<div class="upload-err"><strong>✗ Netwerkfout:</strong> ${e.message}</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>`;
|
}
|
||||||
el.innerHTML = html;
|
|
||||||
await loadDoelen();
|
|
||||||
setTimeout(() => { el.style.display='none'; }, 12000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user