diff --git a/validatie_samenwijzer/app/pages/9_beheer.py b/validatie_samenwijzer/app/pages/9_beheer.py index a3e32af..34b5ba7 100644 --- a/validatie_samenwijzer/app/pages/9_beheer.py +++ b/validatie_samenwijzer/app/pages/9_beheer.py @@ -10,6 +10,8 @@ import os import shlex import subprocess +import time +from collections import deque from collections.abc import Iterator from pathlib import Path @@ -25,9 +27,10 @@ init_db, laatste_ingest_run, ) -from validatie_samenwijzer.styles import CSS, render_footer # noqa: E402 +from validatie_samenwijzer.styles import CSS, render_footer, render_nav # noqa: E402 st.markdown(CSS, unsafe_allow_html=True) +render_nav() # ── Toegangscontrole ─────────────────────────────────────────────────────────── if os.environ.get("BEHEER_ENABLED", "").lower() != "true": @@ -42,8 +45,12 @@ _INSTELLING_KEYS = ["aeres", "davinci", "rijn_ijssel", "talland", "utrecht"] -def _stream_lines(cmd: list[str], cwd: Path) -> Iterator[str]: - """Run cmd en yield stdout-regels live.""" +_BUFFER_REGELS = 300 +_PAINT_INTERVAL_S = 0.2 + + +def _stream_lines(cmd: list[str], cwd: Path) -> Iterator[tuple[str, int | None]]: + """Run cmd en yield (regel, returncode); returncode is None tot het proces eindigt.""" proc = subprocess.Popen( # noqa: S603 — input is uit hardcoded keuzes, niet user-text cmd, cwd=str(cwd), @@ -52,10 +59,12 @@ def _stream_lines(cmd: list[str], cwd: Path) -> Iterator[str]: text=True, bufsize=1, ) - assert proc.stdout is not None - yield from iter(proc.stdout.readline, "") + if proc.stdout is None: + raise RuntimeError("Popen leverde geen stdout op — kan output niet streamen.") + for regel in iter(proc.stdout.readline, ""): + yield regel, None proc.wait() - yield f"\n[exit={proc.returncode}]\n" + yield f"\n[exit={proc.returncode}]\n", proc.returncode def _run_in_placeholder(cmd: list[str], cwd: Path | None = None) -> None: @@ -63,10 +72,25 @@ def _run_in_placeholder(cmd: list[str], cwd: Path | None = None) -> None: cwd = cwd or _PROJECT_ROOT st.caption(f"$ {shlex.join(cmd)} (cwd={cwd})") placeholder = st.empty() - buffer: list[str] = [] - for regel in _stream_lines(cmd, cwd): + # deque voorkomt onbegrensde groei bij langlopende subprocesses (bv. een 30-min + # bootstrap kan tienduizenden regels rclone-progress produceren). + buffer: deque[str] = deque(maxlen=_BUFFER_REGELS) + laatste_paint = 0.0 + returncode: int | None = None + for regel, rc in _stream_lines(cmd, cwd): buffer.append(regel) - placeholder.code("".join(buffer[-300:]), language="bash") + if rc is not None: + returncode = rc + # Throttle: bij snel-producerende subprocessen zou per-regel re-rendering de + # Streamlit-WebSocket overbelasten en de browser laten haperen. + nu = time.monotonic() + if nu - laatste_paint > _PAINT_INTERVAL_S or rc is not None: + placeholder.code("".join(buffer), language="bash") + laatste_paint = nu + if returncode == 0: + st.success(f"✅ Klaar (exit=0): `{shlex.join(cmd)}`") + else: + st.error(f"❌ Subprocess gefaald (exit={returncode}): `{shlex.join(cmd)}`") # ── Pagina-header ────────────────────────────────────────────────────────────── @@ -107,9 +131,16 @@ def _run_in_placeholder(cmd: list[str], cwd: Path | None = None) -> None: ).fetchall() if rijen: st.dataframe( - [{"Instelling": r["display_naam"], "Totaal OERs": r["totaal"], - "Geïndexeerd": r["geindexeerd"]} for r in rijen], - hide_index=True, use_container_width=False, + [ + { + "Instelling": r["display_naam"], + "Totaal OERs": r["totaal"], + "Geïndexeerd": r["geindexeerd"], + } + for r in rijen + ], + hide_index=True, + use_container_width=False, ) else: st.info("Geen OERs in DB. Draai eerst een ingest.") @@ -129,8 +160,12 @@ def _run_in_placeholder(cmd: list[str], cwd: Path | None = None) -> None: if not oeren_pad.is_absolute(): oeren_pad = (_PROJECT_ROOT / oeren_pad).resolve() if oeren_pad.exists(): - n_pdf = sum(1 for _ in oeren_pad.rglob("*.pdf")) - n_md = sum(1 for _ in oeren_pad.rglob("*.md")) + n_pdf = n_md = 0 + for pad in oeren_pad.rglob("*"): + if pad.suffix == ".pdf": + n_pdf += 1 + elif pad.suffix == ".md": + n_md += 1 st.write(f"📁 `{oeren_pad}` — {n_pdf} PDFs, {n_md} markdown-bestanden") else: st.warning(f"Map `{oeren_pad}` niet gevonden — draai eerst sync.") diff --git a/validatie_samenwijzer/src/validatie_samenwijzer/ingest.py b/validatie_samenwijzer/src/validatie_samenwijzer/ingest.py index 0504948..68c1cd6 100644 --- a/validatie_samenwijzer/src/validatie_samenwijzer/ingest.py +++ b/validatie_samenwijzer/src/validatie_samenwijzer/ingest.py @@ -6,6 +6,7 @@ import os import re import sqlite3 +import time from pathlib import Path log = logging.getLogger(__name__) @@ -36,7 +37,14 @@ # Generieke woorden die niet als opleidingsnaam tellen — alleen op deze # overhouden geldt de stem als oninformatief. _GENERIEKE_OPLEIDINGSWOORDEN = { - "examenplan", "examenplannen", "oer", "addendum", "bol", "bbl", "mbo", "roc", + "examenplan", + "examenplannen", + "oer", + "addendum", + "bol", + "bbl", + "mbo", + "roc", } @@ -45,8 +53,7 @@ def _stem_heeft_opleidingsnaam(stem: str) -> bool: rest = _PREFIX_PATROON.sub("", stem).lower() tokens = [w for w in re.split(r"[_\W]+", rest) if w] return any( - len(w) >= 3 and not w.isdigit() and w not in _GENERIEKE_OPLEIDINGSWOORDEN - for w in tokens + len(w) >= 3 and not w.isdigit() and w not in _GENERIEKE_OPLEIDINGSWOORDEN for w in tokens ) @@ -355,8 +362,10 @@ def _resolveer_oer( # Werk de opleidingsnaam bij als de nieuwe variant informatiever is # dan wat eerder is opgeslagen (bv. PDF-titelpagina vs. generieke # filename als "Examenplan - 25698"). - if opleiding != oer["opleiding"] and _stem_heeft_opleidingsnaam(opleiding) and not ( - _stem_heeft_opleidingsnaam(oer["opleiding"]) + if ( + opleiding != oer["opleiding"] + and _stem_heeft_opleidingsnaam(opleiding) + and not (_stem_heeft_opleidingsnaam(oer["opleiding"])) ): log.info("Opleiding bijgewerkt: '%s' → '%s'.", oer["opleiding"], opleiding) update_oer_opleiding(conn, oer_id, opleiding) @@ -466,8 +475,6 @@ def main() -> None: for naam, display in _INSTELLINGEN.items(): voeg_instelling_toe(conn, naam, display) - import time - start = time.monotonic() scope: str | None = None if args.bestand: