From 553e99d14eaff68f9fa202907843af618538d8c6 Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Sat, 25 Apr 2026 21:50:36 +0200 Subject: [PATCH] feat(v2): globaler BL-Selector im Header + Auth-gated Sidebar + Queue-Widget Bundesland-Auswahl: - Topbar: einziger BL-Selektor mit localStorage.gwoe.bl-Persistenz - BL-Felder entfernt aus durchsuchen.html, landtag_suche.html, neu.html, auswertungen.html - Screens hoeren auf v2-bl-changed CustomEvent + initial via window.v2GetGlobalBl() Sichtbarkeit (Sidebar): - Durchsuchen + Tags: immer - Merkliste / Neuer Antrag / Landtag-Suche / Auswertungen / Export / Feed: nur eingeloggt - Cluster + Batch-Analyse + Administration: nur Admin Server-Side Schutz: - _v2_template_context()-Helper liefert is_authenticated, is_admin, v2_bundeslaender - HTML-Routen mit Depends(require_auth) bzw. require_admin - 401/403-Browser-Requests redirecten auf /?login=1 statt JSON-Error Queue-Widget (#149): - Neues Component-Partial v2/components/queue_widget.html - Statusbar unten links + Hover-Tooltip mit den letzten 20 Jobs - 5s-Polling auf /api/queue/status, blendet sich aus wenn keine Jobs Smoke-Test angepasst an neue Auth-Erwartungen (302 fuer auth-protected Routen). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/main.py | 102 ++++++++++++++---- app/templates/v2/base.html | 53 +++++++-- app/templates/v2/components/queue_widget.html | 93 ++++++++++++++++ app/templates/v2/screens/auswertungen.html | 33 +++--- app/templates/v2/screens/durchsuchen.html | 26 ++--- app/templates/v2/screens/landtag_suche.html | 30 ++++-- app/templates/v2/screens/neu.html | 29 +++-- scripts/smoke-test.sh | 17 +-- 8 files changed, 297 insertions(+), 86 deletions(-) create mode 100644 app/templates/v2/components/queue_widget.html diff --git a/app/main.py b/app/main.py index b62a390..1544d20 100644 --- a/app/main.py +++ b/app/main.py @@ -127,6 +127,28 @@ app.mount("/static", StaticFiles(directory=static_dir), name="static") templates = Jinja2Templates(directory=str(templates_dir)) +# ─── Auth-Fehler bei HTML-Seiten: Redirect statt JSON-401/403 ───────────────── + +@app.exception_handler(401) +async def auth_required_redirect(request: Request, exc: HTTPException): + """Bei 401 auf HTML-Routes: Redirect zu /?login=1 (öffnet Auth-Modal).""" + accept = request.headers.get("accept", "") + if "text/html" in accept: + from fastapi.responses import RedirectResponse + return RedirectResponse("/?login=1", status_code=302) + return JSONResponse({"detail": exc.detail}, status_code=401) + + +@app.exception_handler(403) +async def admin_required_redirect(request: Request, exc: HTTPException): + """Bei 403 auf HTML-Routes: Redirect zu /?login=1 (öffnet Auth-Modal).""" + accept = request.headers.get("accept", "") + if "text/html" in accept: + from fastapi.responses import RedirectResponse + return RedirectResponse("/?login=1", status_code=302) + return JSONResponse({"detail": exc.detail}, status_code=403) + + @app.on_event("startup") async def startup(): import asyncio @@ -209,7 +231,7 @@ async def classic_index(request: Request): # ─── Default: / → v2 (Default-Flip #139 Phase 2) ──────────────────────────── @app.get("/", response_class=HTMLResponse) -async def index(request: Request): +async def index(request: Request, current_user: Optional[dict] = Depends(get_current_user)): """Startseite — rendert v2-Listenansicht (Default-Flip Phase 2). Alte /?drucksache=XX-YYYY Deep-Links werden per 301-Redirect auf @@ -230,11 +252,12 @@ async def index(request: Request): "assessments": assessments, "bl_codes": bl_codes, "assessment_count": len(assessments), + **_v2_template_context(current_user), }) @app.get("/antrag/{drucksache:path}", response_class=HTMLResponse) -async def antrag_detail(request: Request, drucksache: str): +async def antrag_detail(request: Request, drucksache: str, current_user: Optional[dict] = Depends(get_current_user)): """v2-Antragsdetail-Route — Server-Side-Rendering mit echten DB-Daten.""" try: drucksache = validate_drucksache(drucksache) @@ -243,6 +266,7 @@ async def antrag_detail(request: Request, drucksache: str): "request": request, "v2_active_nav": "durchsuchen", "error": f"Ungültige Drucksachen-ID: {drucksache}", + **_v2_template_context(current_user), }, status_code=400) row = await get_assessment(drucksache) @@ -251,6 +275,7 @@ async def antrag_detail(request: Request, drucksache: str): "request": request, "v2_active_nav": "durchsuchen", "error": f"Antrag {drucksache} wurde nicht gefunden.", + **_v2_template_context(current_user), }, status_code=404) antrag = _row_to_detail(row) @@ -294,9 +319,33 @@ async def antrag_detail(request: Request, drucksache: str): "E5": "Setzt sich Ihre Kommune für mehr Demokratie ein — auch auf Landes- und Bundesebene? Werden internationale Abkommen unterstützt?", }, "matrix_labels": MATRIX_LABELS, + **_v2_template_context(current_user), }) +def _v2_template_context(current_user=None) -> dict: + """Gemeinsame v2-Template-Variablen: is_admin, is_authenticated, v2_bundeslaender. + + Wird in jeder v2-Route aufgerufen und per **-Spread in den Template-Context gemischt. + """ + is_authenticated = bool(current_user and current_user.get("authenticated", False)) + # require_auth liefert keinen "authenticated"-Key, aber ein sub-Feld — beides prüfen + if current_user and current_user.get("sub"): + is_authenticated = True + roles = (current_user or {}).get("roles", []) + is_admin = "admin" in roles or "gwoe-admin" in roles + v2_bls = [ + {"code": bl.code, "name": bl.name} + for bl in alle_bundeslaender() + if bl.aktiv + ] + return { + "is_authenticated": is_authenticated, + "is_admin": is_admin, + "v2_bundeslaender": v2_bls, + } + + def _rows_to_list(rows): """Konvertiert DB-Rows in das flache Dict-Format für die v2-Listenansicht.""" result = [] @@ -1588,25 +1637,27 @@ async def list_bundeslaender(): # === Impressum / Datenschutz === @app.get("/impressum", response_class=HTMLResponse) -async def impressum_page(request: Request): +async def impressum_page(request: Request, current_user: Optional[dict] = Depends(get_current_user)): return templates.TemplateResponse("v2/screens/legal.html", { "request": request, "app_name": settings.app_name, "title": "Impressum", "section": "impressum", + **_v2_template_context(current_user), }) @app.get("/datenschutz", response_class=HTMLResponse) -async def datenschutz_page(request: Request): +async def datenschutz_page(request: Request, current_user: Optional[dict] = Depends(get_current_user)): return templates.TemplateResponse("v2/screens/legal.html", { "request": request, "app_name": settings.app_name, "title": "Datenschutzerklärung", "section": "datenschutz", + **_v2_template_context(current_user), }) # === Quellen / Programme === @app.get("/methodik", response_class=HTMLResponse) -async def methodik_page(request: Request): +async def methodik_page(request: Request, current_user: Optional[dict] = Depends(get_current_user)): """Transparenz-/Methodik-Seite (#96).""" from .bundeslaender import aktive_bundeslaender, BUNDESLAENDER from .embeddings import get_indexing_status @@ -1630,11 +1681,12 @@ async def methodik_page(request: Request): "programme_count": status.get("total", 0), "chunk_count": sum(p.get("chunks", 0) for p in status.get("programmes", [])), "bundeslaender": sorted(bl_list, key=lambda x: x["name"]), + **_v2_template_context(current_user), }) @app.get("/quellen", response_class=HTMLResponse) -async def quellen_page(request: Request): +async def quellen_page(request: Request, current_user: Optional[dict] = Depends(get_current_user)): """Quellen-Seite mit allen Wahl- und Parteiprogrammen, nach BL gruppiert.""" from .bundeslaender import BUNDESLAENDER programmes = get_programme_info() @@ -1661,6 +1713,7 @@ async def quellen_page(request: Request): "wahlprogramme_grouped": wahlprogramme_grouped, "grundsatzprogramme": grundsatz, "status": status, + **_v2_template_context(current_user), }) @@ -1850,7 +1903,7 @@ async def index_programme( @app.get("/auswertungen", response_class=HTMLResponse) -async def auswertungen_page(request: Request): +async def auswertungen_page(request: Request, current_user: Optional[dict] = Depends(get_current_user)): """Auswertungs-Dashboard in v2 (Phase 3 Migration aus Classic).""" from .auswertungen import get_wahlperioden from .bundeslaender import alle_bundeslaender @@ -1864,6 +1917,7 @@ async def auswertungen_page(request: Request): "v2_active_nav": "auswertungen", "wahlperioden": wahlperioden, "bl_codes": bl_codes, + **_v2_template_context(current_user), }) @@ -2171,26 +2225,28 @@ async def v2_antrag_redirect(request: Request, drucksache: str): @app.get("/v2/merkliste", response_class=HTMLResponse) -async def v2_merkliste(request: Request): - """Merkliste (Bookmarks) — lädt Daten via /api/bookmarks client-seitig.""" +async def v2_merkliste(request: Request, current_user: dict = Depends(require_auth)): + """Merkliste (Bookmarks) — nur für eingeloggte User; lädt Daten via /api/bookmarks client-seitig.""" return templates.TemplateResponse("v2/screens/merkliste.html", { "request": request, "v2_active_nav": "merkliste", + **_v2_template_context(current_user), }) @app.get("/v2/tags", response_class=HTMLResponse) -async def v2_tags(request: Request): +async def v2_tags(request: Request, current_user: Optional[dict] = Depends(get_current_user)): """Tag-Cloud-Seite — Themen-Filter über alle Assessments.""" return templates.TemplateResponse("v2/screens/tags.html", { "request": request, "v2_active_nav": "tags", + **_v2_template_context(current_user), }) @app.get("/v2/cluster", response_class=HTMLResponse) -async def v2_cluster(request: Request): - """Cluster-Liste — Top-10 Cluster als redaktionelle Liste.""" +async def v2_cluster(request: Request, current_user: dict = Depends(require_admin)): + """Cluster-Liste — nur für Admins.""" rows = await get_all_assessments(None) assessments = _rows_to_list(rows) bl_codes = sorted({a["bundesland"] for a in assessments if a.get("bundesland")}) @@ -2198,12 +2254,13 @@ async def v2_cluster(request: Request): "request": request, "v2_active_nav": "cluster", "bl_codes": bl_codes, + **_v2_template_context(current_user), }) @app.get("/v2/neu", response_class=HTMLResponse) -async def v2_neu(request: Request): - """Neuer-Antrag-Form — startet Analyse via /api/analyze-drucksache.""" +async def v2_neu(request: Request, current_user: dict = Depends(require_auth)): + """Neuer-Antrag-Form — nur für eingeloggte User; startet Analyse via /api/analyze-drucksache.""" from .bundeslaender import alle_bundeslaender bl_list = [ {"code": bl.code, "name": bl.name} @@ -2215,12 +2272,13 @@ async def v2_neu(request: Request): "v2_active_nav": "neu", "bundeslaender": sorted(bl_list, key=lambda x: x["name"]), "default_model": settings.llm_model_default, + **_v2_template_context(current_user), }) @app.get("/v2/landtag-suche", response_class=HTMLResponse) -async def v2_landtag_suche(request: Request): - """Landtag-Suche — sucht Drucksachen live im Landtags-Portal (nicht nur DB).""" +async def v2_landtag_suche(request: Request, current_user: dict = Depends(require_auth)): + """Landtag-Suche — nur für eingeloggte User; sucht Drucksachen live im Landtags-Portal.""" from .bundeslaender import alle_bundeslaender bl_list = [ {"code": bl.code, "name": bl.name} @@ -2231,12 +2289,13 @@ async def v2_landtag_suche(request: Request): "request": request, "v2_active_nav": "landtag_suche", "bundeslaender": sorted(bl_list, key=lambda x: x["name"]), + **_v2_template_context(current_user), }) @app.get("/v2/batch", response_class=HTMLResponse) -async def v2_batch(request: Request): - """Batch-Analyse-Form (Admin) — enqueued ungeprüfte Drucksachen eines BL.""" +async def v2_batch(request: Request, current_user: dict = Depends(require_admin)): + """Batch-Analyse-Form — nur für Admins; enqueued ungeprüfte Drucksachen eines BL.""" from .bundeslaender import alle_bundeslaender bl_list = [ {"code": bl.code, "name": bl.name} @@ -2247,6 +2306,7 @@ async def v2_batch(request: Request): "request": request, "v2_active_nav": "batch", "bundeslaender": sorted(bl_list, key=lambda x: x["name"]), + **_v2_template_context(current_user), }) @@ -2258,7 +2318,7 @@ async def v2_admin_freischaltungen(request: Request, user: dict = Depends(requir return templates.TemplateResponse("v2/screens/admin_freischaltungen.html", { "request": request, "v2_active_nav": "admin_freischaltungen", - "is_admin": True, + **_v2_template_context(user), }) @@ -2268,7 +2328,7 @@ async def v2_admin_queue(request: Request, user: dict = Depends(require_admin)): return templates.TemplateResponse("v2/screens/admin_queue.html", { "request": request, "v2_active_nav": "admin_queue", - "is_admin": True, + **_v2_template_context(user), }) @@ -2278,7 +2338,7 @@ async def v2_admin_abos(request: Request, user: dict = Depends(require_admin)): return templates.TemplateResponse("v2/screens/admin_abos.html", { "request": request, "v2_active_nav": "admin_abos", - "is_admin": True, + **_v2_template_context(user), }) diff --git a/app/templates/v2/base.html b/app/templates/v2/base.html index 49aa0a0..3e9dd3a 100644 --- a/app/templates/v2/base.html +++ b/app/templates/v2/base.html @@ -40,26 +40,26 @@ {{ assessment_count }} {% endif %} - {{ icon("bookmark-simple", 14) }} Merkliste - {{ icon("tag", 14) }} Tags - {{ icon("graph", 14) }} Cluster - {{ icon("magnifying-glass-plus", 14) }} Landtag-Suche + {% if is_authenticated %}{{ icon("bookmark-simple", 14) }} Merkliste{% endif %} + {{ icon("tag", 14) }} Tags + {% if is_admin %}{{ icon("graph", 14) }} Cluster{% endif %} + {% if is_authenticated %}{{ icon("magnifying-glass-plus", 14) }} Landtag-Suche{% endif %} - {% if is_admin is defined and is_admin %} + {% if is_admin %}
— Administration
{{ icon("user-check", 14) }} Freischaltungen @@ -77,6 +77,15 @@ {{ icon("info", 13) }} Methodik {{ icon("book-open", 13) }} Quellen + {# ── Globaler Bundesland-Selector ─────────────────────────────────── #} + + {# ── Auth-Control — wird per JS nach /api/auth/me-Aufruf umgeschaltet ── #}
{# Platzhalter, bis initV2Auth() den Zustand kennt — unsichtbar #} @@ -204,6 +213,27 @@ {% block body_scripts %}{% endblock %} +{# ── Globaler BL-Selector — Persistenz + Event ───────────────────────────── #} + + {# ── Auth Modal (global, einmal pro Seite) ────────────────────────────── #} {% include "v2/components/auth_modal.html" %} @@ -278,5 +308,8 @@ })(); + +{# Queue-Statusbar mit Hover-Tooltip — analog zu classic-UI (#149) #} +{% include "v2/components/queue_widget.html" %} diff --git a/app/templates/v2/components/queue_widget.html b/app/templates/v2/components/queue_widget.html new file mode 100644 index 0000000..28e6d2d --- /dev/null +++ b/app/templates/v2/components/queue_widget.html @@ -0,0 +1,93 @@ +{# + queue_widget.html — Queue-Statusbar mit Hover-Tooltip (#149). + + Wird am Ende von base.html eingebunden via {% include %}. Self-contained: + Eigenes
+
+ + Polling-Script. Pollt alle 5 s `/api/queue/status` und blendet sich aus, + wenn keine Jobs aktiv/fertig/fehlgeschlagen sind. + + Portiert aus classic-UI (#99). Nutzt v2-Tokens statt classic-Variablen. +#} + + + + + + diff --git a/app/templates/v2/screens/auswertungen.html b/app/templates/v2/screens/auswertungen.html index c33df45..3bf90c0 100644 --- a/app/templates/v2/screens/auswertungen.html +++ b/app/templates/v2/screens/auswertungen.html @@ -181,13 +181,6 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; } {% endfor %} - -
@@ -200,14 +193,6 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
- - -
Wählen Sie den Tab „Thema × Fraktion".
@@ -258,6 +243,16 @@ function switchTab(id, btn) { } } +// Bei BL-Wechsel aktive Panels neu laden +window.addEventListener('v2-bl-changed', function () { + var activePanel = document.querySelector('.auswert-panel.active'); + if (!activePanel) return; + if (activePanel.id === 'panel-bl-partei') loadBlMatrix(); + if (activePanel.id === 'panel-themen') loadThemenMatrix(); +}); + +document.addEventListener('DOMContentLoaded', function () { loadBlMatrix(); }); + function scoreClass(avg) { if (avg == null) return ''; if (avg >= 6) return 's-high'; @@ -269,7 +264,8 @@ async function loadBlMatrix() { const wrap = document.getElementById('bl-matrix-wrap'); const metaEl = document.getElementById('bl-matrix-meta'); const wp = document.getElementById('wp-filter').value; - const bl = document.getElementById('bl-filter').value; + const blRaw = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL'; + const bl = (blRaw === 'ALL') ? '' : blRaw; wrap.innerHTML = '
Lade Matrix …
'; metaEl.textContent = ''; @@ -316,8 +312,9 @@ async function loadBlMatrix() { } async function loadThemenMatrix() { - const wrap = document.getElementById('themen-matrix-wrap'); - const bl = document.getElementById('themen-bl-filter').value; + const wrap = document.getElementById('themen-matrix-wrap'); + const blRaw = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL'; + const bl = (blRaw === 'ALL') ? '' : blRaw; wrap.innerHTML = '
Lade Themen-Matrix …
'; let url = '/api/auswertungen/themen-matrix'; diff --git a/app/templates/v2/screens/durchsuchen.html b/app/templates/v2/screens/durchsuchen.html index c839cb8..9860101 100644 --- a/app/templates/v2/screens/durchsuchen.html +++ b/app/templates/v2/screens/durchsuchen.html @@ -9,13 +9,9 @@ {% block main %} -{# ── Toolbar: Bundesland-Filter + Suche ─────────────────────────── #} +{# ── Toolbar: Suche ──────────────────────────────────────────────── #} +{# BL-Filter läuft jetzt über den globalen Selector in der Topbar. #} +
@@ -158,14 +161,6 @@ required onkeydown="if(event.key==='Enter'){event.preventDefault();lsSearch(event);}">
-
- - -
@@ -202,7 +197,14 @@ async function lsSearch(e) { if (e && e.preventDefault) e.preventDefault(); var q = (document.getElementById('ls-q-input').value || '').trim(); - var bl = document.getElementById('ls-bl-select').value; + var bl = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL'; + + if (bl === 'ALL') { + document.getElementById('ls-bl-hint').style.display = ''; + document.getElementById('ls-status').textContent = ''; + return; + } + document.getElementById('ls-bl-hint').style.display = 'none'; if (q.length < 2) { document.getElementById('ls-status').textContent = 'Bitte mindestens 2 Zeichen eingeben.'; @@ -344,5 +346,15 @@ function escHtml(s) { function escAttr(s) { return String(s).replace(/'/g, "\\'"); } + +document.addEventListener('DOMContentLoaded', function () { + var hint = document.getElementById('ls-bl-hint'); + function updateHint() { + var bl = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL'; + if (hint) hint.style.display = (bl === 'ALL') ? '' : 'none'; + } + updateHint(); + window.addEventListener('v2-bl-changed', updateHint); +}); {% endblock %} diff --git a/app/templates/v2/screens/neu.html b/app/templates/v2/screens/neu.html index 4435ed7..8eaf8b1 100644 --- a/app/templates/v2/screens/neu.html +++ b/app/templates/v2/screens/neu.html @@ -92,18 +92,15 @@ + + - - -