From f1867d463c387fec85adec61e8b7c4448e9b6671 Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Tue, 7 Apr 2026 23:00:39 +0200 Subject: [PATCH] Bundesland filter & transparency: stringent split + visible source (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the Bundesland-Dropdown from a cosmetic header widget to a real filter that propagates through every layer (Listing, internal search, statistics, party/tag filters, upload mode), and at the same time makes the source parliament visible in every place where assessments from multiple bundesländer can be mixed. Backend - database.get_all_assessments(bundesland=None) — new optional filter, "ALL" treated as None. - database.search_assessments — bug fix: previous `if bundesland:` branch incorrectly added a `WHERE bundesland='ALL'` clause; now guarded with `bundesland and bundesland != "ALL"`. - main.list_assessments — accepts ?bundesland= query param, includes the bundesland field in the response so the frontend can render badges. - main.get_single_assessment — also includes bundesland in the response so the detail header can show the source parlament. - main.search_landtag — early HTTP 400 when bundesland is missing or "ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit request. - main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit" entry prepended to the bundesländer list (kept out of bundeslaender.py on purpose — ALL is not a real state). Both endpoints additionally expose a parlament_names map so the frontend can render the source parliament without an extra round-trip. Report (PDF + HTML) - generate_html_report / generate_pdf_report — new optional bundesland parameter. When set, the report header carries the parliament name ("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …) beside the title. Three call sites updated: run_analysis, run_drucksache_analysis, download_assessment_pdf. Frontend (templates/index.html) - Header dropdown gets the synthetic ALL entry as first option; initial currentBundesland is now 'ALL' (was 'NRW'). - localStorage persistence: changeBundesland writes, DOMContentLoaded reads and validates against the visible options. - changeBundesland resets the score / party / tag filter state, syncs the upload-mode bundesland select, disables the Landtag-Suche button + tooltip when ALL, and toggles a data-mode attribute on .list-content (used by CSS to show/hide the per-item bundesland badge). - loadAssessments now sends ?bundesland=… so the API does the actual filtering. updateStats renders an additional per-bundesland average block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list spans more than one bundesland. - renderList prepends a small "bl-badge" beside the Drucksachen-Nummer. Hidden in single-bundesland mode via CSS selector to avoid clutter. - showDetail header now shows the parliament name as its own line (.detail-parlament). - searchLandtag has an early-out alert if currentBundesland === 'ALL', saving a network round-trip. - Upload-Mode bundesland select now starts with a "— Bundesland wählen —" placeholder (no auto-default), and startAnalysis validates that a concrete bundesland was chosen. CSS - .bl-badge plus the .list-content[data-mode="single"] hide rule. - .detail-parlament for the detail header line. - .header-parlament for the PDF report header line. Resolves #8. Co-Authored-By: Claude Sonnet 4.6 --- app/database.py | 22 ++++-- app/main.py | 87 +++++++++++++++------ app/report.py | 49 ++++++++++-- app/templates/index.html | 158 +++++++++++++++++++++++++++++++++++---- 4 files changed, 267 insertions(+), 49 deletions(-) diff --git a/app/database.py b/app/database.py index 757fd19..ef590c1 100644 --- a/app/database.py +++ b/app/database.py @@ -200,14 +200,24 @@ async def get_assessment(drucksache: str) -> Optional[dict]: return None -async def get_all_assessments() -> list[dict]: - """Get all assessments from database.""" +async def get_all_assessments(bundesland: str = None) -> list[dict]: + """Get all assessments from database, optionally filtered by Bundesland. + + The special value ``"ALL"`` and ``None`` mean no filter — both behave + identically and return every row. Any other value becomes a strict + ``WHERE bundesland = ?`` match. + """ import json + sql = "SELECT * FROM assessments" + params: list = [] + if bundesland and bundesland != "ALL": + sql += " WHERE bundesland = ?" + params.append(bundesland) + sql += " ORDER BY gwoe_score DESC" + async with aiosqlite.connect(settings.db_path) as db: db.row_factory = aiosqlite.Row - cursor = await db.execute( - "SELECT * FROM assessments ORDER BY gwoe_score DESC" - ) + cursor = await db.execute(sql, params) rows = await cursor.fetchall() results = [] for row in rows: @@ -293,7 +303,7 @@ async def search_assessments(query: str, bundesland: str = None, limit: int = 50 """ params = [f"%{first_term}%"] * 4 - if bundesland: + if bundesland and bundesland != "ALL": sql += " AND bundesland = ?" params.append(bundesland) diff --git a/app/main.py b/app/main.py index 4ec0d4a..6f01b95 100644 --- a/app/main.py +++ b/app/main.py @@ -84,13 +84,24 @@ async def startup(): @app.get("/", response_class=HTMLResponse) async def index(request: Request): """Landing page with upload form.""" + # Frontend-Liste: synthetischer "ALL"-Eintrag (Bundesweit) zuerst, dann + # die echten Bundesländer aus der Konfig. Der "ALL"-Code ist eine reine + # Frontend/API-Konvention, kein Eintrag in bundeslaender.py. + bl_list = [{"code": "ALL", "name": "🌍 Bundesweit", "active": True}] + bl_list.extend( + {"code": bl.code, "name": bl.name, "active": bl.aktiv} + for bl in alle_bundeslaender() + ) + # Map code → parlament_name, damit das Frontend ohne extra Backend-Call + # für jeden Antrag den Parlamentsnamen anzeigen kann. + parlament_names = { + bl.code: bl.parlament_name for bl in alle_bundeslaender() + } return templates.TemplateResponse("index.html", { "request": request, "app_name": settings.app_name, - "bundeslaender": [ - {"code": bl.code, "name": bl.name, "active": bl.aktiv} - for bl in alle_bundeslaender() - ], + "bundeslaender": bl_list, + "parlament_names": parlament_names, }) @@ -138,9 +149,9 @@ async def run_analysis(job_id: str, text: str, bundesland: str, model: str): html_path = settings.reports_dir / f"{job_id}.html" pdf_path = settings.reports_dir / f"{job_id}.pdf" - await generate_html_report(assessment, html_path) - await generate_pdf_report(assessment, pdf_path) - + await generate_html_report(assessment, html_path, bundesland=bundesland) + await generate_pdf_report(assessment, pdf_path, bundesland=bundesland) + await update_job( job_id, status="completed", @@ -203,10 +214,13 @@ async def get_pdf(job_id: str): # API: Load assessments from database @app.get("/api/assessments") -async def list_assessments(): - """Return all assessments from database.""" - rows = await get_all_assessments() - +async def list_assessments(bundesland: Optional[str] = None): + """Return assessments from database, optionally filtered by Bundesland. + + ``bundesland="ALL"`` and missing parameter both mean "no filter". + """ + rows = await get_all_assessments(bundesland) + # Convert DB format to frontend format assessments = [] for row in rows: @@ -216,6 +230,7 @@ async def list_assessments(): "fraktionen": row.get("fraktionen", []), "datum": row.get("datum"), "link": row.get("link"), + "bundesland": row.get("bundesland"), "gwoeScore": row.get("gwoe_score"), "gwoeBegründung": row.get("gwoe_begruendung"), "gwoeMatrix": row.get("gwoe_matrix", []), @@ -231,7 +246,7 @@ async def list_assessments(): "antragZusammenfassung": row.get("antrag_zusammenfassung"), "antragKernpunkte": row.get("antrag_kernpunkte", []), }) - + return assessments @@ -249,6 +264,7 @@ async def get_single_assessment(drucksache: str): "fraktionen": row.get("fraktionen", []), "datum": row.get("datum"), "link": row.get("link"), + "bundesland": row.get("bundesland"), "gwoeScore": row.get("gwoe_score"), "gwoeBegründung": row.get("gwoe_begruendung"), "gwoeMatrix": row.get("gwoe_matrix", []), @@ -306,7 +322,11 @@ async def download_assessment_pdf(drucksache: str): try: assessment = Assessment(**assessment_data) - await generate_pdf_report(assessment, pdf_path) + await generate_pdf_report( + assessment, + pdf_path, + bundesland=row.get("bundesland"), + ) except Exception as e: raise HTTPException(status_code=500, detail=f"PDF-Generierung fehlgeschlagen: {e}") @@ -356,7 +376,15 @@ async def search_landtag( """ Search external parliament portal (e.g., NRW OPAL). Returns results that can be analyzed with "Jetzt prüfen". + + Requires a concrete Bundesland — the special "ALL" / Bundesweit mode + cannot pick a single Landtag adapter and is rejected with HTTP 400. """ + if not bundesland or bundesland == "ALL": + raise HTTPException( + status_code=400, + detail="Landtag-Suche benötigt ein konkretes Bundesland", + ) adapter = get_adapter(bundesland) if not adapter: return {"error": f"Bundesland {bundesland} noch nicht unterstützt"} @@ -471,10 +499,10 @@ async def run_drucksache_analysis( # Generate reports html_path = settings.reports_dir / f"{job_id}.html" pdf_path = settings.reports_dir / f"{job_id}.pdf" - - await generate_html_report(assessment, html_path) - await generate_pdf_report(assessment, pdf_path) - + + await generate_html_report(assessment, html_path, bundesland=bundesland) + await generate_pdf_report(assessment, pdf_path, bundesland=bundesland) + await update_job( job_id, status="completed", @@ -492,11 +520,26 @@ async def run_drucksache_analysis( # API: List available Bundesländer @app.get("/api/bundeslaender") async def list_bundeslaender(): - """List available bundesländer with their status.""" - return [ - {"code": bl.code, "name": bl.name, "active": bl.aktiv} - for bl in alle_bundeslaender() - ] + """List available bundesländer with their status. + + Includes the synthetic "ALL" / Bundesweit entry as the first item so + that the frontend can render it directly. ``parlament_name`` is added + so the detail view can show the source parliament without an extra + backend round-trip. + """ + out = [{ + "code": "ALL", + "name": "🌍 Bundesweit", + "parlament_name": None, + "active": True, + }] + out.extend({ + "code": bl.code, + "name": bl.name, + "parlament_name": bl.parlament_name, + "active": bl.aktiv, + } for bl in alle_bundeslaender()) + return out # === Quellen / Programme === diff --git a/app/report.py b/app/report.py index 7652d70..b125cdb 100644 --- a/app/report.py +++ b/app/report.py @@ -7,6 +7,7 @@ from typing import Optional from jinja2 import Environment, FileSystemLoader from .models import Assessment, MATRIX_LABELS, EMPFEHLUNG_CONFIG +from .bundeslaender import BUNDESLAENDER # ECOnGOOD Colors COLORS = { @@ -93,10 +94,25 @@ def build_matrix_html(assessment: Assessment) -> str: return '\n'.join(html) -async def generate_html_report(assessment: Assessment, output_path: Path) -> None: - """Generate HTML report.""" - +async def generate_html_report( + assessment: Assessment, + output_path: Path, + bundesland: Optional[str] = None, +) -> None: + """Generate HTML report. + + ``bundesland`` is the optional state code (e.g. ``"NRW"``, ``"LSA"``). + When set and known in ``BUNDESLAENDER``, the resulting report carries + the parlament name in its header so the source parliament is always + visible — important since assessments from multiple bundesländer share + the same Drucksachen-ID space. + """ + empf_config = EMPFEHLUNG_CONFIG.get(assessment.empfehlung.value, {}) + + parlament_name = "" + if bundesland and bundesland in BUNDESLAENDER: + parlament_name = BUNDESLAENDER[bundesland].parlament_name html = f""" @@ -141,6 +157,14 @@ async def generate_html_report(assessment: Assessment, output_path: Path) -> Non color: var(--color-blue); margin-bottom: 0.5rem; }} + + .header-parlament {{ + font-size: 9pt; + color: var(--color-blue); + font-weight: bold; + margin-top: 0.4rem; + letter-spacing: 0.3px; + }} h1 {{ color: var(--color-darkgray); @@ -327,8 +351,9 @@ async def generate_html_report(assessment: Assessment, output_path: Path) -> Non
GEMEINWOHL-ÖKONOMIE | ANTRAGSBEWERTUNG

{assessment.title}

+ {f'
{parlament_name}
' if parlament_name else ''}
- +
Drucksache: {assessment.drucksache}  |  Datum: {assessment.datum}  |  @@ -414,12 +439,20 @@ async def generate_html_report(assessment: Assessment, output_path: Path) -> Non output_path.write_text(html) -async def generate_pdf_report(assessment: Assessment, output_path: Path) -> None: - """Generate PDF report using WeasyPrint.""" +async def generate_pdf_report( + assessment: Assessment, + output_path: Path, + bundesland: Optional[str] = None, +) -> None: + """Generate PDF report using WeasyPrint. + + ``bundesland`` is forwarded to ``generate_html_report`` so the source + parlament name appears in the report header. + """ # First generate HTML html_path = output_path.with_suffix('.tmp.html') - await generate_html_report(assessment, html_path) - + await generate_html_report(assessment, html_path, bundesland=bundesland) + try: from weasyprint import HTML HTML(filename=str(html_path)).write_pdf(str(output_path)) diff --git a/app/templates/index.html b/app/templates/index.html index f028a20..1048dd5 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -170,6 +170,31 @@ font-weight: bold; color: var(--color-blue); } + + /* Bundesland-Badge: Im Listen-Item links neben der Drucksachen-Nummer. + Im Bundesland-spezifischen Modus per data-mode="single" am Container + ausgeblendet (redundant, da alle Einträge demselben Land zugehören). */ + .bl-badge { + display: inline-block; + padding: 1px 6px; + margin-right: 0.4rem; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--color-blue); + border: 1px solid var(--color-blue); + border-radius: 3px; + vertical-align: middle; + } + .list-content[data-mode="single"] .bl-badge { display: none; } + + /* Detail-Header: Parlament-Name unter dem Titel, vor der Drucksache-Zeile */ + .detail-parlament { + font-size: 0.85rem; + color: var(--color-blue); + font-weight: 600; + margin: 0.2rem 0 0.1rem; + } .list-item-score { font-size: 0.9rem; @@ -698,6 +723,7 @@
0 geprüft · 0 vorbildlich · Ø 0 + |
@@ -770,8 +796,9 @@