Bundesland filter & transparency: stringent split + visible source (#8)

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 <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-07 23:00:39 +02:00
parent 87874a7a14
commit f1867d463c
4 changed files with 267 additions and 49 deletions

View File

@ -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)

View File

@ -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,8 +149,8 @@ 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,
@ -203,9 +214,12 @@ 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 = []
@ -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", []),
@ -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"}
@ -472,8 +500,8 @@ async def run_drucksache_analysis(
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,
@ -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 ===

View File

@ -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,11 +94,26 @@ 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"""<!DOCTYPE html>
<html lang="de">
<head>
@ -142,6 +158,14 @@ async def generate_html_report(assessment: Assessment, output_path: Path) -> Non
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);
font-size: 14pt;
@ -327,6 +351,7 @@ async def generate_html_report(assessment: Assessment, output_path: Path) -> Non
<div class="header">
<div class="header-label">GEMEINWOHL-ÖKONOMIE | ANTRAGSBEWERTUNG</div>
<h1>{assessment.title}</h1>
{f'<div class="header-parlament">{parlament_name}</div>' if parlament_name else ''}
</div>
<div class="meta-box">
@ -414,11 +439,19 @@ 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

View File

@ -171,6 +171,31 @@
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;
font-weight: bold;
@ -698,6 +723,7 @@
</div>
<div class="stats-bar" style="padding: 0.5rem 1rem; gap: 1rem; flex-wrap: wrap; align-items: center;">
<span style="font-size: 0.8rem;"><strong id="stat-total">0</strong> geprüft · <strong id="stat-high">0</strong> vorbildlich · Ø <strong id="stat-avg">0</strong></span>
<span id="bundesland-stats" style="font-size: 0.8rem; color: var(--color-darkgray); display: none; gap: 0.6rem; flex-wrap: wrap;"></span>
<span style="color: var(--color-lightgray);">|</span>
<span id="partei-stats" style="font-size: 0.8rem; display: flex; gap: 0.75rem; flex-wrap: wrap;"></span>
</div>
@ -770,8 +796,9 @@
<div style="margin-top: 1rem;">
<label>Bundesland:</label>
<select id="bundesland" style="padding: 0.5rem; margin-left: 0.5rem;">
{% for bl in bundeslaender %}
<option value="{{ bl.code }}" {% if bl.active %}selected{% endif %} {% if not bl.active %}disabled{% endif %}>
<option value="" disabled selected>— Bundesland wählen —</option>
{% for bl in bundeslaender if bl.code != 'ALL' %}
<option value="{{ bl.code }}" {% if not bl.active %}disabled{% endif %}>
{{ bl.name }}{% if not bl.active %} (bald){% endif %}
</option>
{% endfor %}
@ -798,14 +825,39 @@
let allAssessments = [];
let currentScoreFilter = 'all';
let currentParteiFilter = '';
let currentBundesland = 'NRW';
let currentBundesland = 'ALL';
let searchTimeout = null;
let isSearching = false;
let selectedTags = new Set();
let allTags = {};
// Load assessments on page load
// Map code → parlament_name, vom Backend mit dem Initial-Render geliefert.
// Wird im Detail-Header und im Listen-Item-Badge-Tooltip verwendet.
const PARLAMENT_NAMES = {{ parlament_names | tojson }};
// Load assessments on page load — localStorage-Auswahl wiederherstellen
document.addEventListener('DOMContentLoaded', () => {
const saved = localStorage.getItem('selectedBundesland');
const select = document.getElementById('bundesland-select');
if (saved) {
// Validieren: existiert die Option?
const exists = Array.from(select.options).some(
o => o.value === saved && !o.disabled
);
if (exists) {
currentBundesland = saved;
select.value = saved;
}
}
// Modus-Klasse für CSS (Badges aus/an)
document.getElementById('list-content').dataset.mode =
(currentBundesland === 'ALL') ? 'all' : 'single';
// Landtag-Button-State für Initial-Auswahl
const btnLandtag = document.getElementById('btn-landtag');
if (currentBundesland === 'ALL') {
btnLandtag.disabled = true;
btnLandtag.title = 'Landtag-Suche nur mit konkretem Bundesland möglich';
}
loadAssessments();
});
@ -944,7 +996,8 @@
async function loadAssessments() {
try {
const resp = await fetch('/api/assessments');
const url = `/api/assessments?bundesland=${encodeURIComponent(currentBundesland)}`;
const resp = await fetch(url);
allAssessments = await resp.json();
updateStats();
renderList(allAssessments);
@ -965,6 +1018,35 @@
document.getElementById('stat-high').textContent = high;
document.getElementById('stat-avg').textContent = avg;
// Pro-Bundesland-Aufschlüsselung — nur im Bundesweit-Modus, und nur
// wenn tatsächlich mehr als ein Bundesland in der Liste vorkommt.
const blContainer = document.getElementById('bundesland-stats');
if (currentBundesland === 'ALL') {
const blStats = {};
allAssessments.forEach(a => {
if (a.gwoeScore == null || !a.bundesland) return;
if (!blStats[a.bundesland]) blStats[a.bundesland] = { sum: 0, count: 0 };
blStats[a.bundesland].sum += a.gwoeScore;
blStats[a.bundesland].count += 1;
});
const codes = Object.keys(blStats);
if (codes.length > 1) {
const sortedBl = codes
.map(c => ({ code: c, avg: blStats[c].sum / blStats[c].count, count: blStats[c].count }))
.sort((a, b) => b.avg - a.avg);
blContainer.innerHTML = sortedBl.map(b =>
`<span title="${PARLAMENT_NAMES[b.code] || b.code}">Ø <strong>${b.code}</strong> ${b.avg.toFixed(1)} <span style="color:#888">(n=${b.count})</span></span>`
).join(' · ');
blContainer.style.display = 'inline-flex';
} else {
blContainer.style.display = 'none';
blContainer.innerHTML = '';
}
} else {
blContainer.style.display = 'none';
blContainer.innerHTML = '';
}
// Partei-Durchschnitte berechnen
const parteiStats = {};
allAssessments.forEach(a => {
@ -1006,10 +1088,13 @@
const themen = (item.themen || []).slice(0, 3);
const scoreText = isUnchecked ? '⏳' : `${item.gwoeScore}/10`;
const blBadge = item.bundesland
? `<span class="bl-badge" title="${PARLAMENT_NAMES[item.bundesland] || item.bundesland}">${item.bundesland}</span>`
: '';
return `
<div class="list-item ${isUnchecked ? 'unchecked' : ''}" data-drucksache="${item.drucksache}" onclick="${isUnchecked ? '' : `showDetail('${item.drucksache}')`}">
<div class="list-item-header">
<span class="list-item-id">${item.drucksache}</span>
<span class="list-item-id">${blBadge}${item.drucksache}</span>
<span class="list-item-score ${scoreClass}">${scoreText}</span>
</div>
<div class="list-item-title">${item.title || 'Ohne Titel'}</div>
@ -1133,11 +1218,53 @@
function changeBundesland(code) {
currentBundesland = code;
localStorage.setItem('selectedBundesland', code);
// Filter zurücksetzen — Parteien & Tags pro Bundesland unterschiedlich,
// ein "LINKE"-Filter aus LSA würde in NRW eine leere Liste zeigen.
currentScoreFilter = 'all';
currentParteiFilter = '';
selectedTags.clear();
document.getElementById('search-input').value = '';
document.querySelectorAll('.filter-btn').forEach(b => {
b.classList.toggle('active', b.dataset.filter === 'all');
});
const parteiSelect = document.getElementById('partei-filter');
if (parteiSelect) parteiSelect.value = '';
// Upload-Mode-Dropdown synchronisieren. Bei "ALL" KEIN automatischer
// Default — der User soll im Upload-Form bewusst ein Bundesland wählen.
const uploadDropdown = document.getElementById('bundesland');
if (uploadDropdown) {
if (code === 'ALL') {
uploadDropdown.value = '';
} else {
uploadDropdown.value = code;
}
}
// Landtag-Suche-Button im Bundesweit-Modus deaktivieren
const btnLandtag = document.getElementById('btn-landtag');
if (code === 'ALL') {
btnLandtag.disabled = true;
btnLandtag.title = 'Landtag-Suche nur mit konkretem Bundesland möglich';
} else {
btnLandtag.disabled = false;
btnLandtag.title = '';
}
// Modus-Klasse für CSS (Badges aus/an im Single-Modus)
document.getElementById('list-content').dataset.mode =
(code === 'ALL') ? 'all' : 'single';
loadAssessments();
}
async function searchLandtag() {
if (currentBundesland === 'ALL') {
alert('Landtag-Suche ist nur mit Auswahl eines konkreten Bundeslands möglich.\nBitte oben ein Bundesland auswählen.');
return;
}
const query = document.getElementById('search-input').value.trim();
if (query.length < 2) {
alert('Bitte mindestens 2 Zeichen eingeben');
@ -1378,6 +1505,7 @@
<div class="detail-header">
<div>
<div class="detail-title">${item.title || 'Ohne Titel'}</div>
${item.bundesland && PARLAMENT_NAMES[item.bundesland] ? `<div class="detail-parlament">${PARLAMENT_NAMES[item.bundesland]}</div>` : ''}
<div class="detail-id">${item.drucksache} · ${(item.fraktionen || []).join(', ')} · ${item.datum || ''}</div>
</div>
<div class="score-display">
@ -1502,6 +1630,10 @@
alert('Bitte Text eingeben oder PDF hochladen');
return;
}
if (!bundesland) {
alert('Bitte ein Bundesland wählen.');
return;
}
btn.disabled = true;
statusDiv.style.display = 'block';