"""GWÖ-Antragsprüfer — FastAPI Webapp."""
import logging
import uuid
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, File, Form, UploadFile, Request, BackgroundTasks, HTTPException, Depends
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, Response
from pydantic import BaseModel
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from .validators import (
MAX_SEARCH_QUERY_LEN,
validate_drucksache,
validate_search_query,
)
# Strukturiertes Logging für die ganze App. uvicorn registriert seinen
# eigenen Root-Handler erst beim Start; wir setzen ein neutrales Format
# für unsere Module früh, damit logger.exception() auch beim Boot greift.
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
from .config import settings
from .database import (
init_db, get_job, create_job, update_job,
get_all_assessments, get_assessment, delete_assessment,
upsert_assessment, import_json_assessments,
search_assessments,
toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment,
create_subscription, list_subscriptions, list_all_subscriptions, delete_subscription,
delete_subscription_by_id,
upsert_vote, get_votes,
get_assessment_history,
get_abstimmungsverhalten,
merkliste_add, merkliste_remove, merkliste_list, merkliste_bulk_add,
)
from .parlamente import get_adapter, ADAPTERS
from .bundeslaender import alle_bundeslaender
from .analyzer import analyze_antrag
from .auth import get_current_user, require_auth, require_admin, keycloak_login_url, keycloak_admin_token, _is_auth_enabled
def _pick_best_title(llm_title: str, doc_title: Optional[str], drucksache: str) -> str:
"""Wähle den besten Titel aus LLM-Output und Adapter-Metadata.
Priorität:
1. doc_title, wenn ein echter Titel (nicht "Drucksache XX")
2. llm_title, wenn nicht leer und nicht generisch
3. Generischer Fallback "Drucksache XX"
"""
generic_prefix = f"Drucksache {drucksache.split('/')[0]}"
# doc_title gut? (nicht generisch, nicht leer)
if doc_title and not doc_title.startswith("Drucksache ") and len(doc_title) > 5:
return doc_title
# LLM-Titel gut? (nicht generisch)
if llm_title and not llm_title.startswith("Drucksache ") and len(llm_title) > 5:
return llm_title
# doc_title als Fallback (auch wenn generisch)
return doc_title or llm_title or f"Drucksache {drucksache}"
from .report import generate_html_report, generate_pdf_report
from .embeddings import (
init_embeddings_db, get_programme_info, get_indexing_status,
index_programm, render_highlighted_page, PROGRAMME,
)
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
docs_url=None, # Disable /docs in production
redoc_url=None, # Disable /redoc in production
openapi_url=None, # Disable /openapi.json in production
)
# Rate-Limiter — fängt Resource-Exhaustion auf den teuren POST-Endpoints
# (LLM-Calls + Indexing). Issue #57 Befund #1 (HIGH). Default in-memory
# Storage; für mehrere Worker müsste man auf Redis umstellen, solange wir
# auf einem Container laufen reicht das Default-Storage.
limiter = Limiter(key_func=get_remote_address, default_limits=[])
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Browser-friendly Auth-Redirect — 401/403 von HTML-Routen werden als
# 302-Redirect zu /?login=1 ausgeliefert (Login-Modal öffnet sich automatisch).
# API-Calls (Accept: application/json) bleiben bei 401/403-JSON.
@app.exception_handler(HTTPException)
async def _auth_redirect_handler(request: Request, exc: HTTPException):
if exc.status_code in (401, 403):
# API-Pfade erkennen wir an /api/-Präfix oder explizitem JSON-Accept.
accept = request.headers.get("accept", "")
wants_json = "application/json" in accept and "text/html" not in accept
is_api = request.url.path.startswith("/api/")
is_browser = not is_api and not wants_json
if is_browser:
from fastapi.responses import RedirectResponse
target = f"/?login=1&next={request.url.path}"
return RedirectResponse(url=target, status_code=302)
# Default-Verhalten von FastAPI nachbauen
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code, headers=exc.headers or None)
# Security Headers Middleware
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
# CSP: Allow self, inline styles (for templates), and PDF viewer
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"frame-ancestors 'none';"
)
return response
app.add_middleware(SecurityHeadersMiddleware)
# Setup directories
settings.data_dir.mkdir(exist_ok=True)
settings.reports_dir.mkdir(exist_ok=True)
# Static files and templates
static_dir = Path(__file__).parent / "static"
templates_dir = Path(__file__).parent / "templates"
static_dir.mkdir(exist_ok=True)
templates_dir.mkdir(exist_ok=True)
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
await init_db()
init_embeddings_db()
# Job-Queue Worker starten (#95) — Worker ZUERST, dann Re-Enqueue
# im Hintergrund (damit die Webapp sofort erreichbar ist)
from .queue import start_worker, re_enqueue_pending
start_worker()
asyncio.create_task(re_enqueue_pending(analysis_callback=run_drucksache_analysis))
@app.on_event("shutdown")
async def shutdown():
"""Graceful Shutdown: warte auf laufende Queue-Jobs bevor der Container stirbt."""
from .queue import graceful_shutdown
await graceful_shutdown(timeout=900) # 15 min — passend zu stop_grace_period
# JSON import disabled - all assessments now live in SQLite DB only
# Legacy import would overwrite new v5 assessments with old format
# count = await import_json_assessments(settings.data_dir / "assessments")
# if count > 0:
# print(f"Imported {count} assessments from JSON files")
@app.get("/classic", response_class=HTMLResponse)
async def classic_index(request: Request):
"""Klassische Ansicht (v1) — jetzt unter /classic erreichbar."""
# 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()
}
from .models import MATRIX_LABELS
return templates.TemplateResponse("index.html", {
"request": request,
"app_name": settings.app_name,
"bundeslaender": bl_list,
"parlament_names": parlament_names,
"matrix_labels": MATRIX_LABELS,
"matrix_explanations": {
"A1": "Wenn Ihre Stadt Büromöbel kauft: Wurden die unter menschenwürdigen Bedingungen hergestellt? Oder in einer Fabrik, in der Arbeiter:innen ausgebeutet werden? Hier geht es darum, ob die öffentliche Hand beim Einkauf auf Menschenrechte achtet.",
"A2": "Beauftragt die Stadt den Handwerksbetrieb aus dem Ort — oder den billigsten Konzern aus dem Ausland? Bleibt das Geld in der Region und schafft Arbeitsplätze vor Ort?",
"A3": "Werden bei öffentlichen Aufträgen Klimastandards verlangt? Kommt das Schulessen von regionalen Bauernhöfen oder wird es quer durch Europa gekarrt?",
"A4": "Verdienen die Reinigungskräfte im Rathaus einen fairen Lohn? Haben Subunternehmer die gleichen Arbeitsbedingungen wie Festangestellte?",
"A5": "Können Sie als Bürger:in nachschauen, welche Firma den Auftrag für den Straßenbau bekommen hat — und warum? Oder passiert das alles hinter verschlossenen Türen?",
"B1": "Liegt das Geld Ihrer Stadt bei einer Bank, die auch Waffengeschäfte finanziert? Oder bei einer ethischen Bank, die in soziale Projekte investiert?",
"B2": "Fließen Ihre Steuergelder in einen neuen Radweg für alle — oder in eine Umgehungsstraße, die nur dem Gewerbegebiet nützt?",
"B3": "Investiert Ihre Kommune in Solaranlagen auf Schuldächern? Oder wird das Geld in klimaschädliche Projekte gesteckt?",
"B4": "Bekommen ärmere Stadtteile genauso viel Geld für Spielplätze und Schulen wie reiche? Oder konzentrieren sich die Investitionen dort, wo die Grundstückspreise schon hoch sind?",
"B5": "Gibt es einen Bürgerhaushalt, bei dem Sie mitbestimmen können, ob das Geld in die Bibliothek oder den Sportplatz fließt? Oder entscheidet das der Stadtrat allein?",
"C1": "Werden in Ihrer Stadtverwaltung Frauen gleich bezahlt? Haben Menschen mit Behinderung faire Chancen auf eine Stelle? Gibt es Schutz vor Mobbing?",
"C2": "Hat Ihre Stadt ein Klimaschutzkonzept, das alle Ämter gemeinsam umsetzen? Oder kocht jedes Amt sein eigenes Süppchen?",
"C3": "Fahren die Mitarbeiter:innen des Rathauses mit dem Dienstrad oder dem SUV? Gibt es vegetarisches Essen in der Kantine?",
"C4": "Können Eltern in der Verwaltung Teilzeit arbeiten, ohne Karrierenachteile? Gibt es flexible Arbeitszeiten für pflegende Angehörige?",
"C5": "Können Sie die Sitzungsprotokolle des Stadtrats online lesen? Verstehen Sie, warum Entscheidungen so und nicht anders gefallen sind?",
"D1": "Werden Sie auf dem Amt fair behandelt — egal ob Sie einen deutschen oder ausländischen Namen haben? Schützt die Polizei alle gleich?",
"D2": "Profitiert die ganze Stadt von dem Antrag — oder nur ein Stadtteil, eine Altersgruppe, eine Einkommensschicht?",
"D3": "Kommt der Strom für die Straßenbeleuchtung aus Erneuerbaren? Wird das Regenwasser im Park versickert statt in die Kanalisation geleitet?",
"D4": "Kann sich die alleinerziehende Mutter den Kitaplatz leisten? Bekommt der Rentner noch einen Arzttermin? Findet die Familie mit drei Kindern eine bezahlbare Wohnung?",
"D5": "Werden Sie gefragt, bevor die Straße vor Ihrem Haus umgebaut wird? Gibt es Bürgerversammlungen, Online-Beteiligung, Jugendparlamente?",
"E1": "Hinterlassen wir unseren Enkeln einen Schuldenberg und versiegelte Flächen? Oder investieren wir heute so, dass auch 2050 noch gute Lebensbedingungen herrschen?",
"E2": "Hilft der Antrag nur Ihrer Stadt — oder auch den Nachbargemeinden? Gibt es regionale Kooperationen, von denen alle profitieren?",
"E3": "Denkt Ihre Kommune beim Einkauf auch an den CO₂-Fußabdruck? An die Abholzung von Regenwäldern für billiges Papier? An den Wasserverbrauch in Dürregebieten?",
"E4": "Unterstützt Ihre Stadt strukturschwache Regionen? Gibt es Partnerschaften mit Kommunen im globalen Süden?",
"E5": "Setzt sich Ihre Kommune für mehr Demokratie ein — auch auf Landes- und Bundesebene? Werden internationale Abkommen unterstützt?",
},
})
# ─── Default: / → v2 (Default-Flip #139 Phase 2) ────────────────────────────
@app.get("/", response_class=HTMLResponse)
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
/antrag/XX-YYYY weitergeleitet, damit Bookmarks weiter funktionieren.
"""
from fastapi.responses import RedirectResponse
# Deep-Link-Kompatibilität: /?drucksache=18/12345 → /antrag/18/12345
drucksache_param = request.query_params.get("drucksache")
if drucksache_param:
return RedirectResponse(f"/antrag/{drucksache_param}", status_code=301)
rows = await get_all_assessments(None)
assessments = _rows_to_list(rows)
bl_codes = sorted({a["bundesland"] for a in assessments if a.get("bundesland")})
return templates.TemplateResponse("v2/screens/durchsuchen.html", {
"request": request,
"v2_active_nav": "durchsuchen",
"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, current_user: Optional[dict] = Depends(get_current_user)):
"""v2-Antragsdetail-Route — Server-Side-Rendering mit echten DB-Daten."""
try:
drucksache = validate_drucksache(drucksache)
except Exception:
return templates.TemplateResponse("v2/screens/antrag_detail.html", {
"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)
if not row:
return templates.TemplateResponse("v2/screens/antrag_detail.html", {
"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)
# #106 Phase 1: namentliche Abstimmungsdaten ergänzen (optional, kann None sein)
try:
antrag["abstimmungsverhalten"] = await get_abstimmungsverhalten(drucksache)
except Exception:
logger.exception("Fehler beim Laden von Abstimmungsverhalten für %s", drucksache)
antrag["abstimmungsverhalten"] = None
from .models import MATRIX_LABELS
return templates.TemplateResponse("v2/screens/antrag_detail.html", {
"request": request,
"v2_active_nav": "durchsuchen",
"antrag": antrag,
"assessment_count": None,
"matrix_explanations": {
"A1": "Wenn Ihre Stadt Büromöbel kauft: Wurden die unter menschenwürdigen Bedingungen hergestellt? Oder in einer Fabrik, in der Arbeiter:innen ausgebeutet werden? Hier geht es darum, ob die öffentliche Hand beim Einkauf auf Menschenrechte achtet.",
"A2": "Beauftragt die Stadt den Handwerksbetrieb aus dem Ort — oder den billigsten Konzern aus dem Ausland? Bleibt das Geld in der Region und schafft Arbeitsplätze vor Ort?",
"A3": "Werden bei öffentlichen Aufträgen Klimastandards verlangt? Kommt das Schulessen von regionalen Bauernhöfen oder wird es quer durch Europa gekarrt?",
"A4": "Verdienen die Reinigungskräfte im Rathaus einen fairen Lohn? Haben Subunternehmer die gleichen Arbeitsbedingungen wie Festangestellte?",
"A5": "Können Sie als Bürger:in nachschauen, welche Firma den Auftrag für den Straßenbau bekommen hat — und warum? Oder passiert das alles hinter verschlossenen Türen?",
"B1": "Liegt das Geld Ihrer Stadt bei einer Bank, die auch Waffengeschäfte finanziert? Oder bei einer ethischen Bank, die in soziale Projekte investiert?",
"B2": "Fließen Ihre Steuergelder in einen neuen Radweg für alle — oder in eine Umgehungsstraße, die nur dem Gewerbegebiet nützt?",
"B3": "Investiert Ihre Kommune in Solaranlagen auf Schuldächern? Oder wird das Geld in klimaschädliche Projekte gesteckt?",
"B4": "Bekommen ärmere Stadtteile genauso viel Geld für Spielplätze und Schulen wie reiche? Oder konzentrieren sich die Investitionen dort, wo die Grundstückspreise schon hoch sind?",
"B5": "Gibt es einen Bürgerhaushalt, bei dem Sie mitbestimmen können, ob das Geld in die Bibliothek oder den Sportplatz fließt? Oder entscheidet das der Stadtrat allein?",
"C1": "Werden in Ihrer Stadtverwaltung Frauen gleich bezahlt? Haben Menschen mit Behinderung faire Chancen auf eine Stelle? Gibt es Schutz vor Mobbing?",
"C2": "Hat Ihre Stadt ein Klimaschutzkonzept, das alle Ämter gemeinsam umsetzen? Oder kocht jedes Amt sein eigenes Süppchen?",
"C3": "Fahren die Mitarbeiter:innen des Rathauses mit dem Dienstrad oder dem SUV? Gibt es vegetarisches Essen in der Kantine?",
"C4": "Können Eltern in der Verwaltung Teilzeit arbeiten, ohne Karrierenachteile? Gibt es flexible Arbeitszeiten für pflegende Angehörige?",
"C5": "Können Sie die Sitzungsprotokolle des Stadtrats online lesen? Verstehen Sie, warum Entscheidungen so und nicht anders gefallen sind?",
"D1": "Werden Sie auf dem Amt fair behandelt — egal ob Sie einen deutschen oder ausländischen Namen haben? Schützt die Polizei alle gleich?",
"D2": "Profitiert die ganze Stadt von dem Antrag — oder nur ein Stadtteil, eine Altersgruppe, eine Einkommensschicht?",
"D3": "Kommt der Strom für die Straßenbeleuchtung aus Erneuerbaren? Wird das Regenwasser im Park versickert statt in die Kanalisation geleitet?",
"D4": "Kann sich die alleinerziehende Mutter den Kitaplatz leisten? Bekommt der Rentner noch einen Arzttermin? Findet die Familie mit drei Kindern eine bezahlbare Wohnung?",
"D5": "Werden Sie gefragt, bevor die Straße vor Ihrem Haus umgebaut wird? Gibt es Bürgerversammlungen, Online-Beteiligung, Jugendparlamente?",
"E1": "Hinterlassen wir unseren Enkeln einen Schuldenberg und versiegelte Flächen? Oder investieren wir heute so, dass auch 2050 noch gute Lebensbedingungen herrschen?",
"E2": "Hilft der Antrag nur Ihrer Stadt — oder auch den Nachbargemeinden? Gibt es regionale Kooperationen, von denen alle profitieren?",
"E3": "Denkt Ihre Kommune beim Einkauf auch an den CO₂-Fußabdruck? An die Abholzung von Regenwäldern für billiges Papier? An den Wasserverbrauch in Dürregebieten?",
"E4": "Unterstützt Ihre Stadt strukturschwache Regionen? Gibt es Partnerschaften mit Kommunen im globalen Süden?",
"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 = []
for row in rows:
result.append({
"drucksache": row.get("drucksache", ""),
"title": row.get("title", ""),
"score": row.get("gwoe_score") or 0.0,
"parteien": row.get("fraktionen", []),
"bundesland": row.get("bundesland", ""),
"tags": row.get("themen", []),
"datum": row.get("datum", ""),
})
return result
from .redline_utils import parse_redline_segments as _parse_redline_segments
from .redline_utils import build_pdf_href as _build_pdf_href
def _row_to_detail(row):
"""Konvertiert eine DB-Row in das antrag-Dict für den v2-Detailscreen."""
import datetime
fraktionen = row.get("fraktionen", [])
gwoe_matrix_raw = row.get("gwoe_matrix", [])
wahlprogramm_scores = row.get("wahlprogramm_scores", [])
verbesserungen = row.get("verbesserungen", [])
staerken = row.get("staerken", [])
schwaechen = row.get("schwaechen", [])
bundesland = row.get("bundesland", "")
# gwoe_matrix ist ein Array [{field, rating, symbol, …}] → Dict A1→{rating,symbol}
matrix_dict = {}
for cell in (gwoe_matrix_raw or []):
field = cell.get("field", "")
if field:
rating_raw = cell.get("rating", 0)
# DB speichert rating als 1-5 Skala (1=−−,2=−,3=○,4=+,5=++),
# matrix_mini erwartet -2..+2. Mapping: DB-3=0, DB-4=+1, DB-5=+2, DB-2=−1, DB-1=−2
rating_normalized = int(rating_raw) - 3
symbol = cell.get("symbol", "○")
matrix_dict[field] = {"rating": rating_normalized, "symbol": symbol}
# fraktions_scores: numerische Scores + Begründungen + leere Zitat-Listen (Fix 2+3)
fraktions_scores = []
for wp in (wahlprogramm_scores or []):
fraktion = wp.get("fraktion", "")
wp_src = wp.get("wahlprogramm") or {}
pp_src = wp.get("parteiprogramm") or {}
fraktions_scores.append({
"fraktion": fraktion,
"ist_antragsteller": wp.get("istAntragsteller", wp.get("ist_antragsteller")),
"ist_regierung": wp.get("istRegierung", wp.get("ist_regierung")),
"wahlprogramm": {
"score": wp_src.get("score", 0),
"begruendung": wp_src.get("begruendung", wp_src.get("begründung", "")),
"hat_zitate": bool(wp_src.get("zitate")),
},
"parteiprogramm": {
"score": pp_src.get("score", 0),
"begruendung": pp_src.get("begruendung", pp_src.get("begründung", "")),
"hat_zitate": bool(pp_src.get("zitate")),
},
})
# Zitate aus wahlprogramm_scores extrahieren (flache Liste über alle Fraktionen)
# PDF-Href wird bevorzugt aus url-Feld genommen, sonst aus quelle rekonstruiert
zitate = []
for wp in (wahlprogramm_scores or []):
fraktion = wp.get("fraktion", "")
for src_key in ("wahlprogramm", "parteiprogramm"):
src = wp.get(src_key) or {}
for zitat in (src.get("zitate") or []):
zitate.append({
"text": zitat.get("text", ""),
"source": zitat.get("quelle", ""),
"partei": fraktion,
"verified": True,
"contra": False,
"pdf_href": _build_pdf_href(zitat, bundesland),
})
# Stärkster/schwächster Wert aus staerken/schwaechen (erste Einträge)
staerkster = {"titel": "Stärken", "text": "; ".join(staerken[:2]) if staerken else ""}
schwaechster = {"titel": "Schwächen", "text": "; ".join(schwaechen[:2]) if schwaechen else ""}
# Verbesserungen: §INS§/§DEL§- und **/**-/~~Marker parsen (Fix 1)
verbesserungen_parsed = []
for v in (verbesserungen or []):
vorschlag_raw = v.get("vorschlag", "")
verbesserungen_parsed.append({
"original": v.get("original", ""),
"vorschlag": vorschlag_raw,
"begruendung": v.get("begruendung", ""),
"segments": _parse_redline_segments(vorschlag_raw),
})
# Redline-Fallback (wird nur genutzt wenn verbesserungen leer, für rückwärts-Compat)
redline_data = {"segments": []}
if not verbesserungen_parsed and verbesserungen:
v = verbesserungen[0]
redline_data["segments"] = _parse_redline_segments(v.get("vorschlag", ""))
# Datum normalisieren: ISO → lesbar
datum_raw = row.get("datum", "")
datum_display = datum_raw
try:
d = datetime.date.fromisoformat(datum_raw[:10])
datum_display = d.strftime("%d.%m.%Y")
except Exception:
pass
updated_at = row.get("updated_at", "")
analysiert_display = updated_at
try:
d2 = datetime.datetime.fromisoformat(updated_at[:19])
analysiert_display = d2.strftime("%d.%m.%Y")
except Exception:
pass
# gwoe_score als float (DB kann None liefern)
score = float(row.get("gwoe_score") or 0.0)
empfehlung = row.get("empfehlung") or ""
return {
"drucksache": row.get("drucksache", ""),
"bundesland": row.get("bundesland", ""),
"parlament": "Landtag",
"typ": "Antrag",
"datum": datum_display,
"analysiert": analysiert_display,
"modell": row.get("model", ""),
"parteien": fraktionen,
"zitate_count": len(zitate),
"title": row.get("title", ""),
"score": score,
"verdict_title": empfehlung,
"verdict_body": row.get("gwoe_begruendung", "") or "",
"zusammenfassung": row.get("antrag_zusammenfassung", "") or "",
"staerkster_wert": staerkster,
"schwaechster_wert": schwaechster,
"redline": redline_data,
"matrix": matrix_dict,
"zitate": zitate,
"fraktions_scores": fraktions_scores,
"verbesserungen": verbesserungen_parsed,
"staerken": staerken,
"schwaechen": schwaechen,
"share_threads": row.get("share_threads"),
"share_twitter": row.get("share_twitter"),
"share_mastodon": row.get("share_mastodon"),
# Roher ISO-Zeitstempel für OG-Cache-Key (#141)
"updated_at_raw": row.get("updated_at", ""),
}
@app.post("/analyze")
@limiter.limit("10/minute")
async def start_analysis(
request: Request,
background_tasks: BackgroundTasks,
text: Optional[str] = Form(None),
file: Optional[UploadFile] = File(None),
bundesland: str = Form("NRW"),
model: str = Form("qwen-plus"),
user: dict = Depends(require_auth),
):
"""Start analysis job."""
if not text and not file:
raise HTTPException(status_code=400, detail="Entweder Text oder PDF-Datei erforderlich")
# Extract text from PDF if uploaded
if file and file.filename:
import fitz # PyMuPDF
pdf_bytes = await file.read()
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
text = ""
for page in doc:
text += page.get_text()
doc.close()
# Create job
job_id = str(uuid.uuid4())
await create_job(job_id, text[:500], bundesland, model)
# Start background analysis
background_tasks.add_task(run_analysis, job_id, text, bundesland, model)
return JSONResponse({"job_id": job_id, "status": "queued"})
async def run_analysis(job_id: str, text: str, bundesland: str, model: str):
"""Background task for analysis."""
try:
await update_job(job_id, status="processing")
# Run LLM analysis
assessment = await analyze_antrag(text, bundesland, model)
# 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, bundesland=bundesland)
await generate_pdf_report(assessment, pdf_path, bundesland=bundesland)
await update_job(
job_id,
status="completed",
result=assessment.model_dump_json(),
html_path=str(html_path),
pdf_path=str(pdf_path),
)
except Exception as e:
await update_job(job_id, status="failed", error=str(e))
@app.get("/status/{job_id}")
async def get_status(job_id: str):
"""Get job status."""
job = await get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job nicht gefunden")
return JSONResponse({
"job_id": job_id,
"status": job["status"],
"created_at": job["created_at"],
})
@app.get("/result/{job_id}", response_class=HTMLResponse)
async def get_result(request: Request, job_id: str):
"""Get analysis result as HTML."""
job = await get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job nicht gefunden")
if job["status"] != "completed":
raise HTTPException(status_code=400, detail=f"Job noch nicht fertig: {job['status']}")
html_path = Path(job["html_path"])
if html_path.exists():
return HTMLResponse(html_path.read_text())
raise HTTPException(status_code=500, detail="Report nicht gefunden")
@app.get("/result/{job_id}/pdf")
async def get_pdf(job_id: str):
"""Download PDF report."""
job = await get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job nicht gefunden")
if job["status"] != "completed":
raise HTTPException(status_code=400, detail=f"Job noch nicht fertig: {job['status']}")
pdf_path = Path(job["pdf_path"])
if pdf_path.exists():
return FileResponse(
pdf_path,
media_type="application/pdf",
filename=f"gwoe-bericht-{job_id[:8]}.pdf"
)
raise HTTPException(status_code=500, detail="PDF nicht gefunden")
# ─── Queue-Status (#95) ─────────────────────────────────────────────────────
@app.get("/api/queue/status")
async def queue_status():
"""Aktueller Queue-Stand: wartende Jobs, geschätzte Wartezeit."""
from .queue import get_queue_status
return get_queue_status()
# ─── Auth-Endpoints (#43) ───────────────────────────────────────────────────
@app.get("/api/auth/me")
async def auth_me(user=Depends(get_current_user)):
"""User-Info oder null wenn nicht eingeloggt.
Das Frontend ruft diesen Endpoint beim Load auf, um zu entscheiden
ob "Bewerten" aktiv oder ausgegraut ist.
"""
if user:
return {"authenticated": True, **user}
return {"authenticated": False}
@app.get("/api/auth/callback")
async def auth_callback(request: Request, code: str = "", state: str = ""):
"""OIDC Authorization Code → Access Token Exchange.
Keycloak redirects hierher nach Login mit ?code=... Parameter.
Wir tauschen den Code gegen ein Access Token und setzen es als Cookie.
"""
if not _is_auth_enabled() or not code:
from fastapi.responses import RedirectResponse
return RedirectResponse("/")
from .auth import _keycloak_issuer
token_url = f"{_keycloak_issuer()}/protocol/openid-connect/token"
# Construct the same redirect_uri used for the auth request
base = str(request.base_url).rstrip("/").replace("http://", "https://")
redirect_uri = f"{base}/api/auth/callback"
import httpx as _httpx
async with _httpx.AsyncClient(timeout=10) as client:
resp = await client.post(token_url, data={
"grant_type": "authorization_code",
"client_id": settings.keycloak_client_id,
"code": code,
"redirect_uri": redirect_uri,
})
if resp.status_code != 200:
logger.error("Token exchange failed: %s %s", resp.status_code, resp.text[:200])
raise HTTPException(status_code=401, detail="Login fehlgeschlagen")
tokens = resp.json()
access_token = tokens.get("access_token", "")
expires_in = tokens.get("expires_in", 3600)
# HTML-Response statt RedirectResponse: setzt Cookie UND redirected.
# RedirectResponse mit Set-Cookie wird von manchen Browsern bei
# 307/302 ignoriert (insb. hinter Reverse-Proxies).
return HTMLResponse(
f"""
Anmeldung erfolgreich, Weiterleitung...
""",
headers={
"Set-Cookie": (
f"access_token={access_token}; Path=/; Secure; HttpOnly; "
f"SameSite=Lax; Max-Age={expires_in}"
)
},
)
@app.get("/api/auth/login-url")
async def auth_login_url(request: Request, redirect: str = "/"):
"""Keycloak-Login-URL für den Browser-Redirect."""
if not _is_auth_enabled():
return {"enabled": False, "url": ""}
base = str(request.base_url).rstrip("/").replace("http://", "https://")
url = keycloak_login_url(f"{base}/api/auth/callback")
return {"enabled": True, "url": url}
@app.get("/api/auth/forgot-password")
async def auth_forgot_password(request: Request):
"""Redirect zur Keycloak-Passwort-Reset-Seite (#143-Folge).
Keycloak bietet bei `resetPasswordAllowed=True` eine eigene Reset-Page,
die per Mail einen Link zum Passwort-Setzen schickt. Wir leiten direkt
dahin um statt eine eigene UI zu bauen.
"""
from fastapi.responses import RedirectResponse
base = str(request.base_url).rstrip("/").replace("http://", "https://")
issuer = f"{settings.keycloak_url}/realms/{settings.keycloak_realm}"
target = (
f"{issuer}/login-actions/reset-credentials"
f"?client_id={settings.keycloak_client_id}"
f"&redirect_uri={base}/"
)
return RedirectResponse(url=target, status_code=302)
@app.post("/api/auth/login")
async def auth_direct_login(
username: str = Form(...),
password: str = Form(...),
):
"""Direct Access Grant Login (#129) — kein Redirect zu Keycloak.
Ruft Keycloak per Resource Owner Password Credentials (Direct Access Grant) an.
Setzt access_token als HttpOnly-Cookie und refresh_token als separates rt-Cookie.
KEYCLOAK-VORAUSSETZUNG: Im Client muss "Direct Access Grants" aktiviert sein.
Keycloak Admin → Realm → Clients → gwoe-antragspruefer → Capability config →
"Direct access grants enabled" = ON.
Fehler-Mapping:
- 401 → {"error": "invalid_credentials", "msg": "..."}
- Keycloak nicht erreichbar / sonstiges → {"error": "unknown", "msg": "..."}
"""
from .auth import direct_login, _validate_token
try:
token_data = await direct_login(username, password)
except HTTPException as exc:
if exc.status_code == 401:
return JSONResponse(
status_code=401,
content={"error": "invalid_credentials", "msg": exc.detail},
)
return JSONResponse(
status_code=exc.status_code,
content={"error": "unknown", "msg": exc.detail},
)
access_token = token_data["access_token"]
expires_in = token_data.get("expires_in", 300)
refresh_token = token_data.get("refresh_token")
refresh_expires_in = token_data.get("refresh_expires_in", 1800)
# Validiere Token um User-Infos zu extrahieren.
# _validate_token gibt {sub, email, name, roles} zurück, wobei name
# bereits auf preferred_username normalisiert wurde (siehe auth.py L144).
user_payload = await _validate_token(access_token)
user_info = {}
if user_payload:
user_info = {
"sub": user_payload.get("sub"),
"preferred_username": user_payload.get("name", username),
"name": user_payload.get("name", username),
"email": user_payload.get("email", ""),
}
response = JSONResponse({
"authenticated": True,
"expires_in": expires_in,
"user": user_info,
})
response.set_cookie(
"access_token",
access_token,
max_age=expires_in,
httponly=True,
secure=True,
samesite="lax",
path="/",
)
if refresh_token:
response.set_cookie(
"rt",
refresh_token,
max_age=refresh_expires_in,
httponly=True,
secure=True,
samesite="lax",
path="/api/auth/refresh",
)
return response
@app.post("/api/auth/logout")
async def auth_logout():
"""Logout — löscht access_token + rt-Cookies (HttpOnly, daher server-seitig)."""
response = JSONResponse({"authenticated": False})
response.delete_cookie("access_token", path="/")
response.delete_cookie("rt", path="/api/auth/refresh")
return response
@app.post("/api/auth/refresh")
async def auth_refresh(request: Request):
"""Refresh-Token-Endpoint (#129) — holt neuen access_token via refresh_token-Cookie.
Setzt neuen access_token-Cookie bei Erfolg.
"""
from .auth import _keycloak_issuer
refresh_token = request.cookies.get("rt")
if not refresh_token:
raise HTTPException(status_code=401, detail="Kein Refresh-Token")
import httpx as _httpx
async with _httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
f"{_keycloak_issuer()}/protocol/openid-connect/token",
data={
"grant_type": "refresh_token",
"client_id": settings.keycloak_client_id,
"refresh_token": refresh_token,
},
)
if resp.status_code != 200:
raise HTTPException(status_code=401, detail="Refresh fehlgeschlagen")
token_data = resp.json()
access_token = token_data["access_token"]
expires_in = token_data.get("expires_in", 300)
response = JSONResponse({"authenticated": True, "expires_in": expires_in})
response.set_cookie(
"access_token",
access_token,
max_age=expires_in,
httponly=True,
secure=True,
samesite="lax",
path="/",
)
return response
# ─── Bookmarks + Comments (#94) ─────────────────────────────────────────────
@app.post("/api/bookmark")
async def bookmark_toggle(
drucksache: str = Form(...),
user: dict = Depends(require_auth),
):
"""Toggle bookmark für einen Antrag."""
is_bookmarked = await toggle_bookmark(user["sub"], drucksache)
return {"bookmarked": is_bookmarked, "drucksache": drucksache}
@app.get("/api/bookmarks")
async def bookmarks_list(user=Depends(get_current_user)):
"""Liste aller Bookmarks des aktuellen Users."""
if not user:
return []
return await get_bookmarks(user["sub"])
# ─── Merkliste — serverseitig (#140) ─────────────────────────────────────────
class _MerklisteAddBody(BaseModel):
antrag_id: str
notiz: Optional[str] = None
class _MerklisteBulkBody(BaseModel):
entries: list[dict]
@app.post("/api/me/merkliste")
async def merkliste_add_endpoint(
body: _MerklisteAddBody,
user: dict = Depends(require_auth),
):
"""Antrag zur Merkliste hinzufügen (Upsert). Erfordert Anmeldung."""
entry = await merkliste_add(user["sub"], body.antrag_id, body.notiz)
return entry
@app.delete("/api/me/merkliste/{antrag_id:path}")
async def merkliste_remove_endpoint(
antrag_id: str,
user: dict = Depends(require_auth),
):
"""Antrag aus der Merkliste entfernen. Erfordert Anmeldung."""
removed = await merkliste_remove(user["sub"], antrag_id)
return {"removed": removed, "antrag_id": antrag_id}
@app.get("/api/me/merkliste")
async def merkliste_list_endpoint(user: dict = Depends(require_auth)):
"""Alle Merklisten-Einträge des aktuellen Users. Erfordert Anmeldung."""
return await merkliste_list(user["sub"])
@app.post("/api/me/merkliste/bulk-import")
async def merkliste_bulk_import(
body: _MerklisteBulkBody,
user: dict = Depends(require_auth),
):
"""Mehrere Einträge auf einmal importieren (für localStorage-Migration).
Body: ``{"entries": [{"antrag_id": "18/1234"}, …]}``
"""
count = await merkliste_bulk_add(user["sub"], body.entries)
return {"imported": count}
# ─── E-Mail-Abonnements (#124) ───────────────────────────────────────────────
@app.post("/api/subscriptions")
async def subscription_create(
bundesland: Optional[str] = Form(None),
partei: Optional[str] = Form(None),
frequency: str = Form("daily"),
user: dict = Depends(require_auth),
):
"""Neues Abo für Benachrichtigungen anlegen."""
email = user.get("email")
if not email:
raise HTTPException(status_code=400, detail="Nutzer hat keine E-Mail-Adresse im Token")
if frequency not in ("daily",):
raise HTTPException(status_code=400, detail="Unsupported frequency")
sub_id = await create_subscription(
user_id=user["sub"],
email=email,
bundesland=bundesland or None,
partei=partei or None,
frequency=frequency,
)
return {"id": sub_id, "email": email, "bundesland": bundesland, "partei": partei, "frequency": frequency}
@app.get("/api/subscriptions")
async def subscription_list(user=Depends(get_current_user)):
"""Liste aller Abos. Admins erhalten alle Abos inkl. user_id-Feld; normale
User sehen nur ihre eigenen Abos."""
if not user:
return []
roles = user.get("roles") or []
if "admin" in roles:
return await list_all_subscriptions()
return await list_subscriptions(user["sub"])
@app.delete("/api/subscriptions/{sub_id}")
async def subscription_delete(sub_id: int, user: dict = Depends(require_auth)):
"""Abo löschen (nur eigenes)."""
ok = await delete_subscription(user["sub"], sub_id)
if not ok:
raise HTTPException(status_code=404, detail="Abo nicht gefunden")
return {"deleted": sub_id}
@app.get("/unsubscribe/{sub_id}/{token}", response_class=HTMLResponse)
async def unsubscribe(sub_id: int, token: str):
"""Unsubscribe-Link aus E-Mails — kein Login nötig (HMAC-Token verifiziert)."""
from .mail import verify_unsubscribe_token
if not verify_unsubscribe_token(sub_id, token):
return HTMLResponse(
""
"
Ungültiger Unsubscribe-Link
"
"
Der Link ist nicht mehr gültig oder wurde manipuliert.
"
"",
status_code=400,
)
ok = await delete_subscription_by_id(sub_id)
msg = "Abo wurde abbestellt." if ok else "Abo war bereits gelöscht."
return HTMLResponse(
f""
f"
{msg}
"
f"
Du bekommst keine weiteren Benachrichtigungen zu diesem Filter.