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) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-25 21:50:36 +02:00
parent 38bffb23fa
commit 553e99d14e
8 changed files with 297 additions and 86 deletions

View File

@ -127,6 +127,28 @@ app.mount("/static", StaticFiles(directory=static_dir), name="static")
templates = Jinja2Templates(directory=str(templates_dir)) 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") @app.on_event("startup")
async def startup(): async def startup():
import asyncio import asyncio
@ -209,7 +231,7 @@ async def classic_index(request: Request):
# ─── Default: / → v2 (Default-Flip #139 Phase 2) ──────────────────────────── # ─── Default: / → v2 (Default-Flip #139 Phase 2) ────────────────────────────
@app.get("/", response_class=HTMLResponse) @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). """Startseite — rendert v2-Listenansicht (Default-Flip Phase 2).
Alte /?drucksache=XX-YYYY Deep-Links werden per 301-Redirect auf Alte /?drucksache=XX-YYYY Deep-Links werden per 301-Redirect auf
@ -230,11 +252,12 @@ async def index(request: Request):
"assessments": assessments, "assessments": assessments,
"bl_codes": bl_codes, "bl_codes": bl_codes,
"assessment_count": len(assessments), "assessment_count": len(assessments),
**_v2_template_context(current_user),
}) })
@app.get("/antrag/{drucksache:path}", response_class=HTMLResponse) @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.""" """v2-Antragsdetail-Route — Server-Side-Rendering mit echten DB-Daten."""
try: try:
drucksache = validate_drucksache(drucksache) drucksache = validate_drucksache(drucksache)
@ -243,6 +266,7 @@ async def antrag_detail(request: Request, drucksache: str):
"request": request, "request": request,
"v2_active_nav": "durchsuchen", "v2_active_nav": "durchsuchen",
"error": f"Ungültige Drucksachen-ID: {drucksache}", "error": f"Ungültige Drucksachen-ID: {drucksache}",
**_v2_template_context(current_user),
}, status_code=400) }, status_code=400)
row = await get_assessment(drucksache) row = await get_assessment(drucksache)
@ -251,6 +275,7 @@ async def antrag_detail(request: Request, drucksache: str):
"request": request, "request": request,
"v2_active_nav": "durchsuchen", "v2_active_nav": "durchsuchen",
"error": f"Antrag {drucksache} wurde nicht gefunden.", "error": f"Antrag {drucksache} wurde nicht gefunden.",
**_v2_template_context(current_user),
}, status_code=404) }, status_code=404)
antrag = _row_to_detail(row) 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?", "E5": "Setzt sich Ihre Kommune für mehr Demokratie ein — auch auf Landes- und Bundesebene? Werden internationale Abkommen unterstützt?",
}, },
"matrix_labels": MATRIX_LABELS, "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): def _rows_to_list(rows):
"""Konvertiert DB-Rows in das flache Dict-Format für die v2-Listenansicht.""" """Konvertiert DB-Rows in das flache Dict-Format für die v2-Listenansicht."""
result = [] result = []
@ -1588,25 +1637,27 @@ async def list_bundeslaender():
# === Impressum / Datenschutz === # === Impressum / Datenschutz ===
@app.get("/impressum", response_class=HTMLResponse) @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", { return templates.TemplateResponse("v2/screens/legal.html", {
"request": request, "app_name": settings.app_name, "request": request, "app_name": settings.app_name,
"title": "Impressum", "section": "impressum", "title": "Impressum", "section": "impressum",
**_v2_template_context(current_user),
}) })
@app.get("/datenschutz", response_class=HTMLResponse) @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", { return templates.TemplateResponse("v2/screens/legal.html", {
"request": request, "app_name": settings.app_name, "request": request, "app_name": settings.app_name,
"title": "Datenschutzerklärung", "section": "datenschutz", "title": "Datenschutzerklärung", "section": "datenschutz",
**_v2_template_context(current_user),
}) })
# === Quellen / Programme === # === Quellen / Programme ===
@app.get("/methodik", response_class=HTMLResponse) @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).""" """Transparenz-/Methodik-Seite (#96)."""
from .bundeslaender import aktive_bundeslaender, BUNDESLAENDER from .bundeslaender import aktive_bundeslaender, BUNDESLAENDER
from .embeddings import get_indexing_status from .embeddings import get_indexing_status
@ -1630,11 +1681,12 @@ async def methodik_page(request: Request):
"programme_count": status.get("total", 0), "programme_count": status.get("total", 0),
"chunk_count": sum(p.get("chunks", 0) for p in status.get("programmes", [])), "chunk_count": sum(p.get("chunks", 0) for p in status.get("programmes", [])),
"bundeslaender": sorted(bl_list, key=lambda x: x["name"]), "bundeslaender": sorted(bl_list, key=lambda x: x["name"]),
**_v2_template_context(current_user),
}) })
@app.get("/quellen", response_class=HTMLResponse) @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.""" """Quellen-Seite mit allen Wahl- und Parteiprogrammen, nach BL gruppiert."""
from .bundeslaender import BUNDESLAENDER from .bundeslaender import BUNDESLAENDER
programmes = get_programme_info() programmes = get_programme_info()
@ -1661,6 +1713,7 @@ async def quellen_page(request: Request):
"wahlprogramme_grouped": wahlprogramme_grouped, "wahlprogramme_grouped": wahlprogramme_grouped,
"grundsatzprogramme": grundsatz, "grundsatzprogramme": grundsatz,
"status": status, "status": status,
**_v2_template_context(current_user),
}) })
@ -1850,7 +1903,7 @@ async def index_programme(
@app.get("/auswertungen", response_class=HTMLResponse) @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).""" """Auswertungs-Dashboard in v2 (Phase 3 Migration aus Classic)."""
from .auswertungen import get_wahlperioden from .auswertungen import get_wahlperioden
from .bundeslaender import alle_bundeslaender from .bundeslaender import alle_bundeslaender
@ -1864,6 +1917,7 @@ async def auswertungen_page(request: Request):
"v2_active_nav": "auswertungen", "v2_active_nav": "auswertungen",
"wahlperioden": wahlperioden, "wahlperioden": wahlperioden,
"bl_codes": bl_codes, "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) @app.get("/v2/merkliste", response_class=HTMLResponse)
async def v2_merkliste(request: Request): async def v2_merkliste(request: Request, current_user: dict = Depends(require_auth)):
"""Merkliste (Bookmarks) — lädt Daten via /api/bookmarks client-seitig.""" """Merkliste (Bookmarks) — nur für eingeloggte User; lädt Daten via /api/bookmarks client-seitig."""
return templates.TemplateResponse("v2/screens/merkliste.html", { return templates.TemplateResponse("v2/screens/merkliste.html", {
"request": request, "request": request,
"v2_active_nav": "merkliste", "v2_active_nav": "merkliste",
**_v2_template_context(current_user),
}) })
@app.get("/v2/tags", response_class=HTMLResponse) @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.""" """Tag-Cloud-Seite — Themen-Filter über alle Assessments."""
return templates.TemplateResponse("v2/screens/tags.html", { return templates.TemplateResponse("v2/screens/tags.html", {
"request": request, "request": request,
"v2_active_nav": "tags", "v2_active_nav": "tags",
**_v2_template_context(current_user),
}) })
@app.get("/v2/cluster", response_class=HTMLResponse) @app.get("/v2/cluster", response_class=HTMLResponse)
async def v2_cluster(request: Request): async def v2_cluster(request: Request, current_user: dict = Depends(require_admin)):
"""Cluster-Liste — Top-10 Cluster als redaktionelle Liste.""" """Cluster-Liste — nur für Admins."""
rows = await get_all_assessments(None) rows = await get_all_assessments(None)
assessments = _rows_to_list(rows) assessments = _rows_to_list(rows)
bl_codes = sorted({a["bundesland"] for a in assessments if a.get("bundesland")}) 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, "request": request,
"v2_active_nav": "cluster", "v2_active_nav": "cluster",
"bl_codes": bl_codes, "bl_codes": bl_codes,
**_v2_template_context(current_user),
}) })
@app.get("/v2/neu", response_class=HTMLResponse) @app.get("/v2/neu", response_class=HTMLResponse)
async def v2_neu(request: Request): async def v2_neu(request: Request, current_user: dict = Depends(require_auth)):
"""Neuer-Antrag-Form — startet Analyse via /api/analyze-drucksache.""" """Neuer-Antrag-Form — nur für eingeloggte User; startet Analyse via /api/analyze-drucksache."""
from .bundeslaender import alle_bundeslaender from .bundeslaender import alle_bundeslaender
bl_list = [ bl_list = [
{"code": bl.code, "name": bl.name} {"code": bl.code, "name": bl.name}
@ -2215,12 +2272,13 @@ async def v2_neu(request: Request):
"v2_active_nav": "neu", "v2_active_nav": "neu",
"bundeslaender": sorted(bl_list, key=lambda x: x["name"]), "bundeslaender": sorted(bl_list, key=lambda x: x["name"]),
"default_model": settings.llm_model_default, "default_model": settings.llm_model_default,
**_v2_template_context(current_user),
}) })
@app.get("/v2/landtag-suche", response_class=HTMLResponse) @app.get("/v2/landtag-suche", response_class=HTMLResponse)
async def v2_landtag_suche(request: Request): async def v2_landtag_suche(request: Request, current_user: dict = Depends(require_auth)):
"""Landtag-Suche — sucht Drucksachen live im Landtags-Portal (nicht nur DB).""" """Landtag-Suche — nur für eingeloggte User; sucht Drucksachen live im Landtags-Portal."""
from .bundeslaender import alle_bundeslaender from .bundeslaender import alle_bundeslaender
bl_list = [ bl_list = [
{"code": bl.code, "name": bl.name} {"code": bl.code, "name": bl.name}
@ -2231,12 +2289,13 @@ async def v2_landtag_suche(request: Request):
"request": request, "request": request,
"v2_active_nav": "landtag_suche", "v2_active_nav": "landtag_suche",
"bundeslaender": sorted(bl_list, key=lambda x: x["name"]), "bundeslaender": sorted(bl_list, key=lambda x: x["name"]),
**_v2_template_context(current_user),
}) })
@app.get("/v2/batch", response_class=HTMLResponse) @app.get("/v2/batch", response_class=HTMLResponse)
async def v2_batch(request: Request): async def v2_batch(request: Request, current_user: dict = Depends(require_admin)):
"""Batch-Analyse-Form (Admin) — enqueued ungeprüfte Drucksachen eines BL.""" """Batch-Analyse-Form — nur für Admins; enqueued ungeprüfte Drucksachen eines BL."""
from .bundeslaender import alle_bundeslaender from .bundeslaender import alle_bundeslaender
bl_list = [ bl_list = [
{"code": bl.code, "name": bl.name} {"code": bl.code, "name": bl.name}
@ -2247,6 +2306,7 @@ async def v2_batch(request: Request):
"request": request, "request": request,
"v2_active_nav": "batch", "v2_active_nav": "batch",
"bundeslaender": sorted(bl_list, key=lambda x: x["name"]), "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", { return templates.TemplateResponse("v2/screens/admin_freischaltungen.html", {
"request": request, "request": request,
"v2_active_nav": "admin_freischaltungen", "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", { return templates.TemplateResponse("v2/screens/admin_queue.html", {
"request": request, "request": request,
"v2_active_nav": "admin_queue", "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", { return templates.TemplateResponse("v2/screens/admin_abos.html", {
"request": request, "request": request,
"v2_active_nav": "admin_abos", "v2_active_nav": "admin_abos",
"is_admin": True, **_v2_template_context(user),
}) })

View File

@ -40,26 +40,26 @@
<span class="v2-nav-count">{{ assessment_count }}</span> <span class="v2-nav-count">{{ assessment_count }}</span>
{% endif %} {% endif %}
</a> </a>
<a href="/v2/merkliste" class="v2-nav-item {% if v2_active_nav == 'merkliste' %}active{% endif %}">{{ icon("bookmark-simple", 14) }} Merkliste</a> {% if is_authenticated %}<a href="/v2/merkliste" class="v2-nav-item {% if v2_active_nav == 'merkliste' %}active{% endif %}">{{ icon("bookmark-simple", 14) }} Merkliste</a>{% endif %}
<a href="/v2/tags" class="v2-nav-item {% if v2_active_nav == 'tags' %}active{% endif %}">{{ icon("tag", 14) }} Tags</a> <a href="/v2/tags" class="v2-nav-item {% if v2_active_nav == 'tags' %}active{% endif %}">{{ icon("tag", 14) }} Tags</a>
<a href="/v2/cluster" class="v2-nav-item {% if v2_active_nav == 'cluster' %}active{% endif %}">{{ icon("graph", 14) }} Cluster</a> {% if is_admin %}<a href="/v2/cluster" class="v2-nav-item {% if v2_active_nav == 'cluster' %}active{% endif %}">{{ icon("graph", 14) }} Cluster</a>{% endif %}
<a href="/v2/landtag-suche" class="v2-nav-item {% if v2_active_nav == 'landtag_suche' %}active{% endif %}">{{ icon("magnifying-glass-plus", 14) }} Landtag-Suche</a> {% if is_authenticated %}<a href="/v2/landtag-suche" class="v2-nav-item {% if v2_active_nav == 'landtag_suche' %}active{% endif %}">{{ icon("magnifying-glass-plus", 14) }} Landtag-Suche</a>{% endif %}
</div> </div>
<div class="v2-nav-group"> <div class="v2-nav-group">
<div class="v2-nav-label">— Prüfen</div> <div class="v2-nav-label">— Prüfen</div>
<a href="/v2/neu" class="v2-nav-item {% if v2_active_nav == 'neu' %}active{% endif %}">{{ icon("file-plus", 14) }} Neuer Antrag</a> {% if is_authenticated %}<a href="/v2/neu" class="v2-nav-item {% if v2_active_nav == 'neu' %}active{% endif %}">{{ icon("file-plus", 14) }} Neuer Antrag</a>{% endif %}
<a href="/v2/batch" class="v2-nav-item {% if v2_active_nav == 'batch' %}active{% endif %}">{{ icon("stack", 14) }} Batch-Analyse</a> {% if is_admin %}<a href="/v2/batch" class="v2-nav-item {% if v2_active_nav == 'batch' %}active{% endif %}">{{ icon("stack", 14) }} Batch-Analyse</a>{% endif %}
</div> </div>
<div class="v2-nav-group"> <div class="v2-nav-group">
<div class="v2-nav-label">— Daten</div> <div class="v2-nav-label">— Daten</div>
<a href="/auswertungen" class="v2-nav-item {% if v2_active_nav == 'auswertungen' %}active{% endif %}">{{ icon("chart-bar", 14) }} Auswertungen</a> {% if is_authenticated %}<a href="/auswertungen" class="v2-nav-item {% if v2_active_nav == 'auswertungen' %}active{% endif %}">{{ icon("chart-bar", 14) }} Auswertungen</a>{% endif %}
<a href="/api/auswertungen/export.csv" class="v2-nav-item">{{ icon("file-csv", 14) }} Export · API</a> {% if is_authenticated %}<a href="/api/auswertungen/export.csv" class="v2-nav-item">{{ icon("file-csv", 14) }} Export · API</a>{% endif %}
<a href="/api/feed.xml" class="v2-nav-item">{{ icon("rss", 14) }} Atom-Feed</a> {% if is_authenticated %}<a href="/api/feed.xml" class="v2-nav-item">{{ icon("rss", 14) }} Atom-Feed</a>{% endif %}
</div> </div>
{% if is_admin is defined and is_admin %} {% if is_admin %}
<div class="v2-nav-group"> <div class="v2-nav-group">
<div class="v2-nav-label">— Administration</div> <div class="v2-nav-label">— Administration</div>
<a href="/v2/admin/freischaltungen" class="v2-nav-item">{{ icon("user-check", 14) }} Freischaltungen</a> <a href="/v2/admin/freischaltungen" class="v2-nav-item">{{ icon("user-check", 14) }} Freischaltungen</a>
@ -77,6 +77,15 @@
<a href="/methodik">{{ icon("info", 13) }} Methodik</a> <a href="/methodik">{{ icon("info", 13) }} Methodik</a>
<a href="/quellen">{{ icon("book-open", 13) }} Quellen</a> <a href="/quellen">{{ icon("book-open", 13) }} Quellen</a>
{# ── Globaler Bundesland-Selector ─────────────────────────────────── #}
<select id="v2-global-bl"
onchange="v2SetGlobalBl(this.value)"
aria-label="Bundesland wählen"
style="font-family:var(--font-mono);font-size:11px;padding:3px 6px;border:1px solid var(--ecg-light, var(--ecg-border));background:var(--ecg-card-bg);color:var(--ecg-dark);text-transform:uppercase;border-radius:3px;cursor:pointer;">
<option value="ALL">Bundesweit</option>
{% for bl in v2_bundeslaender %}<option value="{{ bl.code }}">{{ bl.code }}</option>{% endfor %}
</select>
{# ── Auth-Control — wird per JS nach /api/auth/me-Aufruf umgeschaltet ── #} {# ── Auth-Control — wird per JS nach /api/auth/me-Aufruf umgeschaltet ── #}
<div id="v2-auth-control" style="display:inline-flex;align-items:center;"> <div id="v2-auth-control" style="display:inline-flex;align-items:center;">
{# Platzhalter, bis initV2Auth() den Zustand kennt — unsichtbar #} {# Platzhalter, bis initV2Auth() den Zustand kennt — unsichtbar #}
@ -204,6 +213,27 @@
{% block body_scripts %}{% endblock %} {% block body_scripts %}{% endblock %}
{# ── Globaler BL-Selector — Persistenz + Event ───────────────────────────── #}
<script>
(function () {
var BL_KEY = 'gwoe.bl';
window.v2SetGlobalBl = function (code) {
try { localStorage.setItem(BL_KEY, code); } catch (_) {}
window.dispatchEvent(new CustomEvent('v2-bl-changed', { detail: { bl: code } }));
};
window.v2GetGlobalBl = function () {
try { return localStorage.getItem(BL_KEY) || 'ALL'; } catch (_) { return 'ALL'; }
};
document.addEventListener('DOMContentLoaded', function () {
var sel = document.getElementById('v2-global-bl');
if (sel) sel.value = window.v2GetGlobalBl();
});
})();
</script>
{# ── Auth Modal (global, einmal pro Seite) ────────────────────────────── #} {# ── Auth Modal (global, einmal pro Seite) ────────────────────────────── #}
{% include "v2/components/auth_modal.html" %} {% include "v2/components/auth_modal.html" %}
@ -278,5 +308,8 @@
})(); })();
</script> </script>
{# Queue-Statusbar mit Hover-Tooltip — analog zu classic-UI (#149) #}
{% include "v2/components/queue_widget.html" %}
</body> </body>
</html> </html>

View File

@ -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 <div id="v2-queue-statusbar"> + <div id="v2-queue-tooltip"> +
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.
#}
<div id="v2-queue-statusbar"
style="display:none;position:fixed;bottom:1rem;left:1rem;
background:var(--ecg-card-bg);border:1px solid var(--ecg-light);
border-radius:6px;padding:0.4rem 0.8rem;
font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);
box-shadow:0 2px 8px rgba(0,0,0,0.1);z-index:100;cursor:default;
transition:all 0.2s;"
onmouseenter="document.getElementById('v2-queue-tooltip').style.display='block'"
onmouseleave="document.getElementById('v2-queue-tooltip').style.display='none'"
aria-label="Analyse-Queue Status">
<span id="v2-queue-status-text"></span>
</div>
<div id="v2-queue-tooltip"
style="display:none;position:fixed;bottom:3.5rem;left:1rem;
background:var(--ecg-card-bg);border:1px solid var(--ecg-light);
border-radius:6px;padding:0.8rem 1rem;
font-family:var(--font-sans);font-size:12px;color:var(--ecg-dark);
box-shadow:0 4px 16px rgba(0,0,0,0.15);z-index:101;
max-width:420px;max-height:320px;overflow-y:auto;">
</div>
<script>
(function () {
function poll() {
fetch('/api/queue/status')
.then(function (r) { return r.json(); })
.then(function (qs) {
var jobs = (qs.jobs || []).filter(function (j) { return j.status !== 'stale'; });
var processing = jobs.filter(function (j) { return j.status === 'processing'; }).length;
var queued = jobs.filter(function (j) { return j.status === 'queued' || j.status === 'pending'; }).length;
var completed = jobs.filter(function (j) { return j.status === 'completed'; }).length;
var failed = jobs.filter(function (j) { return j.status === 'failed'; }).length;
var bar = document.getElementById('v2-queue-statusbar');
var text = document.getElementById('v2-queue-status-text');
if (!bar || !text) return;
if (processing + queued + completed + failed === 0) {
bar.style.display = 'none';
return;
}
bar.style.display = 'block';
var parts = [];
if (processing > 0) parts.push('⏳ ' + processing + ' in Bearbeitung');
if (queued > 0) parts.push('⏸ ' + queued + ' wartend');
if (completed > 0) parts.push('✓ ' + completed + ' fertig');
if (failed > 0) parts.push('✗ ' + failed + ' fehlgeschlagen');
text.textContent = parts.join(' · ');
var tip = document.getElementById('v2-queue-tooltip');
if (!tip) return;
var workers = qs.workers_running != null ? qs.workers_running : '?';
var rows = jobs.slice(0, 20).map(function (j) {
var icon = j.status === 'completed' ? '✓'
: j.status === 'processing' ? '⏳'
: j.status === 'failed' ? '✗'
: '⏸';
var dur = j.duration ? (' · ' + j.duration + 's') : '';
var bl = j.bundesland ? (' · ' + j.bundesland) : '';
var ds = j.drucksache || '?';
var dsLink = j.status === 'completed' && j.drucksache
? '<a href="/antrag/' + encodeURIComponent(j.drucksache) + '" style="color:var(--ecg-blue);">' + ds + '</a>'
: ds;
return '<div style="padding:0.25rem 0;border-bottom:1px solid var(--ecg-light);">'
+ '<span style="font-family:var(--font-mono);">' + icon + '</span> '
+ dsLink
+ '<span style="font-family:var(--font-mono);color:var(--ecg-text-muted);font-size:0.85em;">' + bl + dur + '</span>'
+ '</div>';
}).join('');
tip.innerHTML = '<div style="margin-bottom:0.5rem;font-weight:900;font-size:11px;letter-spacing:0.04em;text-transform:uppercase;color:var(--ecg-blue);">Queue · '
+ workers + ' Worker</div>'
+ (rows || '<div style="color:var(--ecg-text-muted);">leer</div>');
})
.catch(function () { /* still */ });
}
// erster Aufruf direkt + danach alle 5 s
poll();
setInterval(poll, 5000);
})();
</script>

View File

@ -181,13 +181,6 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
<option value="{{ wp }}">{{ wp }}</option> <option value="{{ wp }}">{{ wp }}</option>
{% endfor %} {% endfor %}
</select> </select>
<label for="bl-filter">Bundesland:</label>
<select id="bl-filter">
<option value="">Alle</option>
{% for code in bl_codes %}
<option value="{{ code }}">{{ code }}</option>
{% endfor %}
</select>
<button class="primary" onclick="loadBlMatrix()">Laden</button> <button class="primary" onclick="loadBlMatrix()">Laden</button>
<button onclick="window.location.href='/api/auswertungen/export.csv'">CSV-Export</button> <button onclick="window.location.href='/api/auswertungen/export.csv'">CSV-Export</button>
</div> </div>
@ -200,14 +193,6 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
<!-- Panel 2: Thema × Fraktion --> <!-- Panel 2: Thema × Fraktion -->
<div class="auswert-panel" id="panel-themen"> <div class="auswert-panel" id="panel-themen">
<div class="controls-bar"> <div class="controls-bar">
<label for="themen-bl-filter">Bundesland:</label>
<select id="themen-bl-filter" onchange="loadThemenMatrix()">
<option value="">Alle</option>
{% for code in bl_codes %}
<option value="{{ code }}">{{ code }}</option>
{% endfor %}
</select>
</div>
<div id="themen-matrix-wrap" class="matrix-wrap"> <div id="themen-matrix-wrap" class="matrix-wrap">
<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Wählen Sie den Tab „Thema × Fraktion".</div> <div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Wählen Sie den Tab „Thema × Fraktion".</div>
</div> </div>
@ -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) { function scoreClass(avg) {
if (avg == null) return ''; if (avg == null) return '';
if (avg >= 6) return 's-high'; if (avg >= 6) return 's-high';
@ -269,7 +264,8 @@ async function loadBlMatrix() {
const wrap = document.getElementById('bl-matrix-wrap'); const wrap = document.getElementById('bl-matrix-wrap');
const metaEl = document.getElementById('bl-matrix-meta'); const metaEl = document.getElementById('bl-matrix-meta');
const wp = document.getElementById('wp-filter').value; 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 = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Matrix …</div>'; wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Matrix …</div>';
metaEl.textContent = ''; metaEl.textContent = '';
@ -317,7 +313,8 @@ async function loadBlMatrix() {
async function loadThemenMatrix() { async function loadThemenMatrix() {
const wrap = document.getElementById('themen-matrix-wrap'); const wrap = document.getElementById('themen-matrix-wrap');
const bl = document.getElementById('themen-bl-filter').value; const blRaw = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
const bl = (blRaw === 'ALL') ? '' : blRaw;
wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Themen-Matrix …</div>'; wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Themen-Matrix …</div>';
let url = '/api/auswertungen/themen-matrix'; let url = '/api/auswertungen/themen-matrix';

View File

@ -9,13 +9,9 @@
{% block main %} {% block main %}
{# ── Toolbar: Bundesland-Filter + Suche ─────────────────────────── #} {# ── Toolbar: Suche ──────────────────────────────────────────────── #}
{# BL-Filter läuft jetzt über den globalen Selector in der Topbar. #}
<div class="v2-toolbar" id="v2-toolbar" role="toolbar" aria-label="Filter und Suche"> <div class="v2-toolbar" id="v2-toolbar" role="toolbar" aria-label="Filter und Suche">
<button class="v2-chip active" data-bl="ALL" onclick="v2SetBl(this,'ALL')">Bundesweit</button>
{% for code in bl_codes %}
<button class="v2-chip" data-bl="{{ code }}" onclick="v2SetBl(this,'{{ code }}')">{{ code }}</button>
{% endfor %}
<span class="v2-toolbar-sep"></span>
<input class="v2-search" <input class="v2-search"
type="search" type="search"
placeholder="Anträge durchsuchen …" placeholder="Anträge durchsuchen …"
@ -122,13 +118,11 @@
if (empty) empty.style.display = (visible === 0) ? 'block' : 'none'; if (empty) empty.style.display = (visible === 0) ? 'block' : 'none';
} }
window.v2SetBl = function (btn, code) { /* BL-Filter: globaler Selector in der Topbar */
activeBl = code; window.addEventListener('v2-bl-changed', function (e) {
document.querySelectorAll('[data-bl]').forEach(function (b) { activeBl = (e.detail && e.detail.bl) ? e.detail.bl : 'ALL';
b.classList.toggle('active', b.dataset.bl === code);
});
applyFilters(); applyFilters();
}; });
window.v2SetBand = function (btn, band) { window.v2SetBand = function (btn, band) {
activeBand = band; activeBand = band;
@ -140,13 +134,19 @@
window.v2ResetFilters = function () { window.v2ResetFilters = function () {
document.getElementById('v2-search-input').value = ''; document.getElementById('v2-search-input').value = '';
v2SetBl(null, 'ALL'); // BL auf ALL zurücksetzen: globalen Selector aktualisieren
var sel = document.getElementById('v2-global-bl');
if (sel) sel.value = 'ALL';
window.v2SetGlobalBl && window.v2SetGlobalBl('ALL');
v2SetBand(null, 'ALL'); v2SetBand(null, 'ALL');
}; };
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
var input = document.getElementById('v2-search-input'); var input = document.getElementById('v2-search-input');
if (input) input.addEventListener('input', applyFilters); if (input) input.addEventListener('input', applyFilters);
// Gespeicherten BL-Wert beim Laden anwenden
activeBl = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
applyFilters();
}); });
})(); })();

View File

@ -147,6 +147,9 @@
</p> </p>
</div> </div>
<div id="ls-bl-hint" style="display:none;margin-bottom:12px;padding:8px 12px;background:color-mix(in srgb,var(--ecg-teal) 10%,transparent);border:1px solid var(--ecg-teal);border-radius:4px;font-family:var(--font-mono);font-size:11px;color:var(--ecg-teal);">
Bitte zuerst ein Bundesland im Header wählen.
</div>
<form class="ls-form" onsubmit="lsSearch(event)"> <form class="ls-form" onsubmit="lsSearch(event)">
<div class="ls-q"> <div class="ls-q">
<label for="ls-q-input">Suchbegriff</label> <label for="ls-q-input">Suchbegriff</label>
@ -158,14 +161,6 @@
required required
onkeydown="if(event.key==='Enter'){event.preventDefault();lsSearch(event);}"> onkeydown="if(event.key==='Enter'){event.preventDefault();lsSearch(event);}">
</div> </div>
<div class="ls-bl">
<label for="ls-bl-select">Bundesland</label>
<select id="ls-bl-select" name="bundesland">
{% for bl in bundeslaender %}
<option value="{{ bl.code }}"{% if bl.code == 'NRW' %} selected{% endif %}>{{ bl.name }} ({{ bl.code }})</option>
{% endfor %}
</select>
</div>
<button type="submit" class="ls-submit" id="ls-btn"> <button type="submit" class="ls-submit" id="ls-btn">
{{ icon("magnifying-glass-plus", 14) }} Suchen {{ icon("magnifying-glass-plus", 14) }} Suchen
</button> </button>
@ -202,7 +197,14 @@ async function lsSearch(e) {
if (e && e.preventDefault) e.preventDefault(); if (e && e.preventDefault) e.preventDefault();
var q = (document.getElementById('ls-q-input').value || '').trim(); 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) { if (q.length < 2) {
document.getElementById('ls-status').textContent = 'Bitte mindestens 2 Zeichen eingeben.'; document.getElementById('ls-status').textContent = 'Bitte mindestens 2 Zeichen eingeben.';
@ -344,5 +346,15 @@ function escHtml(s) {
function escAttr(s) { function escAttr(s) {
return String(s).replace(/'/g, "\\'"); 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);
});
</script> </script>
{% endblock %} {% endblock %}

View File

@ -92,18 +92,15 @@
<form class="neu-form" onsubmit="startAnalyse(event)"> <form class="neu-form" onsubmit="startAnalyse(event)">
<div id="neu-bl-hint" style="display:none;margin-bottom:14px;padding:8px 12px;background:color-mix(in srgb,var(--ecg-teal) 10%,transparent);border:1px solid var(--ecg-teal);border-radius:4px;font-family:var(--font-mono);font-size:11px;color:var(--ecg-teal);">
Bitte zuerst ein Bundesland im Header wählen.
</div>
<label for="neu-drucksache">Drucksachen-Nummer</label> <label for="neu-drucksache">Drucksachen-Nummer</label>
<input type="text" id="neu-drucksache" name="drucksache" <input type="text" id="neu-drucksache" name="drucksache"
placeholder="z. B. 18/12345 oder NRW-18/12345" placeholder="z. B. 18/12345 oder NRW-18/12345"
required autocomplete="off"> required autocomplete="off">
<label for="neu-bl">Bundesland</label>
<select id="neu-bl" name="bundesland">
{% for bl in bundeslaender %}
<option value="{{ bl.code }}"{% if bl.code == 'NRW' %} selected{% endif %}>{{ bl.name }} ({{ bl.code }})</option>
{% endfor %}
</select>
<label for="neu-model">Modell</label> <label for="neu-model">Modell</label>
<select id="neu-model" name="model"> <select id="neu-model" name="model">
<option value="">Standard ({{ default_model }})</option> <option value="">Standard ({{ default_model }})</option>
@ -136,11 +133,17 @@ async function startAnalyse(e) {
const errEl = document.getElementById('neu-error'); const errEl = document.getElementById('neu-error');
const drucksache = document.getElementById('neu-drucksache').value.trim(); const drucksache = document.getElementById('neu-drucksache').value.trim();
const bundesland = document.getElementById('neu-bl').value; const bundesland = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
const model = document.getElementById('neu-model').value; const model = document.getElementById('neu-model').value;
if (!drucksache) return; if (!drucksache) return;
if (bundesland === 'ALL') {
errEl.style.display = '';
errEl.textContent = 'Bitte zuerst ein Bundesland im Header wählen.';
return;
}
btn.disabled = true; btn.disabled = true;
statusEl.style.display = ''; statusEl.style.display = '';
progEl.style.display = ''; progEl.style.display = '';
@ -212,5 +215,15 @@ async function startAnalyse(e) {
btn.disabled = false; btn.disabled = false;
} }
} }
document.addEventListener('DOMContentLoaded', function () {
var hint = document.getElementById('neu-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);
});
</script> </script>
{% endblock %} {% endblock %}

View File

@ -42,23 +42,26 @@ echo " $(date -Iseconds)"
echo "================================================================" echo "================================================================"
echo echo
echo "[1] Hauptseiten erreichbar (alle 200)" echo "[1] Public-Seiten (200 ohne Auth)"
check "v2 Default /" "200" "/" check "v2 Default /" "200" "/"
check "v2 Detail (echte DS)" "200" "/antrag/21/754S" check "v2 Detail (echte DS)" "200" "/antrag/21/754S"
check "Classic /classic" "200" "/classic" check "Classic /classic" "200" "/classic"
check "/auswertungen" "200" "/auswertungen"
check "/methodik" "200" "/methodik" check "/methodik" "200" "/methodik"
check "/quellen" "200" "/quellen" check "/quellen" "200" "/quellen"
check "/impressum" "200" "/impressum" check "/impressum" "200" "/impressum"
check "/datenschutz" "200" "/datenschutz" check "/datenschutz" "200" "/datenschutz"
check "/v2/merkliste" "200" "/v2/merkliste"
check "/v2/tags" "200" "/v2/tags" check "/v2/tags" "200" "/v2/tags"
check "/v2/cluster" "200" "/v2/cluster"
check "/v2/landtag-suche" "200" "/v2/landtag-suche"
check "/v2/neu" "200" "/v2/neu"
check "/v2/batch" "200" "/v2/batch"
check "/health" "200" "/health" check "/health" "200" "/health"
echo
echo "[1b] Auth-Routen (302/401 ohne Auth — Redirect zu Login)"
check "/auswertungen (auth)" "302" "/auswertungen"
check "/v2/merkliste (auth)" "302" "/v2/merkliste"
check "/v2/landtag-suche (auth)" "302" "/v2/landtag-suche"
check "/v2/neu (auth)" "302" "/v2/neu"
check "/v2/cluster (admin)" "302" "/v2/cluster"
check "/v2/batch (admin)" "302" "/v2/batch"
echo echo
echo "[2] API-Endpoints (öffentlich)" echo "[2] API-Endpoints (öffentlich)"
check "/api/assessments" "200" "/api/assessments" check "/api/assessments" "200" "/api/assessments"