2026-03-28 22:30:24 +01:00
""" GWÖ-Antragsprüfer — FastAPI Webapp. """
Phase A: Audit-Restbefunde #57.3/4/7 (Roadmap #59)
Drei verbleibende Audit-Befunde aus #57 in einem Patch:
- **#57.3 MEDIUM** Drucksache-Regex-Validation: neue
app/validators.py mit validate_drucksache() als gemeinsamer
Validation-Funnel. Pattern ^\d{1,3}/\d{1,7}([-(].{1,20})?$ deckt
alle 10 aktiven Bundesländer (8/6390, 18/12345, 8/6390(neu),
23/3700-A) ab und blockt Path-Traversal (../, /etc/passwd) plus
Standard-Injection (;, <, &). Drei Endpoints durchgeschleust:
/api/assessment, /api/assessment/pdf, /api/analyze-drucksache.
- **#57.4 MEDIUM** print() → logging.getLogger(__name__): main.py
und analyzer.py auf strukturiertes Logging umgestellt. LLM-Inhalte
werden NICHT mehr als Volltext geloggt — neue Helper
_content_fingerprint() liefert nur "len=N sha1=XXXX", reicht zur
Forensik ohne Antrag-Inhalte ins Container-Log zu leaken.
basicConfig() mit ISO-Format setzt strukturiertes Logging früh,
damit logger.exception() auch beim Boot greift.
- **#57.7 LOW-MED** Search-Query-Limit: validate_search_query() mit
MAX_SEARCH_QUERY_LEN=200 schützt /api/search und /api/search-landtag
vor 10-MB-Query-DoS. database._parse_search_query() loggt jetzt
shlex.ValueError-Fallback statt ihn zu verschlucken (deckt Memory-
Regel "stille excepts in Adaptern" ab).
Tests: neue tests/test_main_validators.py mit 22 Cases — Drucksache-
Whitelist-Roundtrip + Path-Traversal-Reject, Search-Query Längen-
Edge-Cases. 107 Unit-Tests grün (85 alt + 22 neu).
Validators in eigenem Modul (app/validators.py), damit Tests sie ohne
slowapi-Dependency direkt importieren können.
Refs: #57, #59 (Phase A)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:15:16 +02:00
import logging
2026-03-28 22:30:24 +01:00
import uuid
from pathlib import Path
from typing import Optional
#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
from fastapi import FastAPI , File , Form , UploadFile , Request , BackgroundTasks , HTTPException , Depends
2026-03-28 22:30:24 +01:00
from fastapi . responses import HTMLResponse , FileResponse , JSONResponse , Response
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
from pydantic import BaseModel
2026-03-28 22:30:24 +01:00
from starlette . middleware . base import BaseHTTPMiddleware
from fastapi . staticfiles import StaticFiles
from fastapi . templating import Jinja2Templates
Security hotfixes #1, #2, #6 from audit (#57)
Drei akute Befunde aus dem Live-System-Audit (Issue #57):
- **#1 HIGH** — Resource Exhaustion via öffentlichem POST: slowapi
Limiter (in-memory, IP-key) auf /analyze (10/min), /api/analyze-drucksache
(10/min) und /api/programme/index (3/min). Verhindert, dass ein
unauthentifizierter Client mit einer Schleife die DashScope-Quota oder
die CPU des Containers leerziehen kann. Default-Storage reicht solange
wir auf einem einzigen Worker laufen.
- **#2 MEDIUM** + **#6 MEDIUM** (selber Root-Cause) — XXE/Local-File-Read
via WeasyPrint und Stored XSS via Browser-Rendering: alle LLM-getragenen
Felder in app/report.py laufen jetzt durch html.escape() bevor sie in
die HTML-Template interpoliert werden. format_redline_html escape-first
und ersetzt dann die Markdown-Marker durch von uns kontrollierte
<span>-Tags. build_matrix_html escaped das aspect-Attribut, sodass ein
nacktes " den title="..."-Wert nicht mehr beenden und einen Event-
Handler injizieren kann. Toter jinja2-Import in report.py entfernt
(war never used, blockierte nur den lokalen Test).
- **Tests** — neue tests/test_report.py mit 8 Cases, die direkt die
Bug-Klasse verifizieren: <script>, file://-img, "-attribut-breakout
in Title und ein End-to-End-Render mit XSS-Payloads in jedem LLM-Feld.
Die Marker-Funktionalität (** und ~~) wird mit-getestet, damit der
Escape-First-Ansatz das nicht versehentlich kaputt macht.
77 alte Unit-Tests + 8 neue → 85 grün.
Rate-Limit-Verifikation per TestClient ist Integration-Scope und folgt
in tests/integration/test_main_security.py als separates Folge-Item.
Refs: #57
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:45:43 +02:00
from slowapi import Limiter , _rate_limit_exceeded_handler
from slowapi . util import get_remote_address
from slowapi . errors import RateLimitExceeded
2026-03-28 22:30:24 +01:00
Phase A: Audit-Restbefunde #57.3/4/7 (Roadmap #59)
Drei verbleibende Audit-Befunde aus #57 in einem Patch:
- **#57.3 MEDIUM** Drucksache-Regex-Validation: neue
app/validators.py mit validate_drucksache() als gemeinsamer
Validation-Funnel. Pattern ^\d{1,3}/\d{1,7}([-(].{1,20})?$ deckt
alle 10 aktiven Bundesländer (8/6390, 18/12345, 8/6390(neu),
23/3700-A) ab und blockt Path-Traversal (../, /etc/passwd) plus
Standard-Injection (;, <, &). Drei Endpoints durchgeschleust:
/api/assessment, /api/assessment/pdf, /api/analyze-drucksache.
- **#57.4 MEDIUM** print() → logging.getLogger(__name__): main.py
und analyzer.py auf strukturiertes Logging umgestellt. LLM-Inhalte
werden NICHT mehr als Volltext geloggt — neue Helper
_content_fingerprint() liefert nur "len=N sha1=XXXX", reicht zur
Forensik ohne Antrag-Inhalte ins Container-Log zu leaken.
basicConfig() mit ISO-Format setzt strukturiertes Logging früh,
damit logger.exception() auch beim Boot greift.
- **#57.7 LOW-MED** Search-Query-Limit: validate_search_query() mit
MAX_SEARCH_QUERY_LEN=200 schützt /api/search und /api/search-landtag
vor 10-MB-Query-DoS. database._parse_search_query() loggt jetzt
shlex.ValueError-Fallback statt ihn zu verschlucken (deckt Memory-
Regel "stille excepts in Adaptern" ab).
Tests: neue tests/test_main_validators.py mit 22 Cases — Drucksache-
Whitelist-Roundtrip + Path-Traversal-Reject, Search-Query Längen-
Edge-Cases. 107 Unit-Tests grün (85 alt + 22 neu).
Validators in eigenem Modul (app/validators.py), damit Tests sie ohne
slowapi-Dependency direkt importieren können.
Refs: #57, #59 (Phase A)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:15:16 +02:00
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__ )
2026-03-28 22:30:24 +01:00
from . config import settings
from . database import (
init_db , get_job , create_job , update_job ,
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
get_all_assessments , get_assessment , delete_assessment ,
upsert_assessment , import_json_assessments ,
search_assessments ,
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
toggle_bookmark , get_bookmarks , add_comment , get_comments , delete_comment ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
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 ,
2026-03-28 22:30:24 +01:00
)
from . parlamente import get_adapter , ADAPTERS
2026-04-07 14:17:54 +02:00
from . bundeslaender import alle_bundeslaender
2026-03-28 22:30:24 +01:00
from . analyzer import analyze_antrag
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
from . auth import get_current_user , require_auth , require_admin , keycloak_login_url , keycloak_admin_token , _is_auth_enabled
2026-04-10 16:05:57 +02:00
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 } "
2026-03-28 22:30:24 +01:00
from . report import generate_html_report , generate_pdf_report
from . embeddings import (
init_embeddings_db , get_programme_info , get_indexing_status ,
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render
Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF-
Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum
Page-Anchor zu springen und den Leser selbst suchen zu lassen.
Implementation:
embeddings.render_highlighted_page(programm_id, seite, query)
- Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz)
- Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die
angeforderte Seite in einen neuen Document → kleinere Response
- search_for(query[:200]) → Bounding-Boxes aller Treffer
- Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation,
identisch zu find_chunk_for_text/Sub-D-Logik)
- add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0)
- Returns serialisierte PDF-Bytes oder None
embeddings._chunk_pdf_url
- Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL
mit pid=, seite=, q=urlencoded(text[:200])
- Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47
rückwärts-kompatibel)
- text wird auf 200 Zeichen abgeschnitten, sonst blasen
500-Zeichen-Snippets jedes Assessment-JSON auf
main.py /api/wahlprogramm-cite Endpoint
- Validiert pid gegen PROGRAMME registry
- seite: 1 ≤ n ≤ 2000
- Response: application/pdf, Cache-Control max-age=86400
- 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range
Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch:
reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt
bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate
selbst nötig.
Tests: 194/194 grün (185 + 9 neue):
- TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate)
- TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid
render, empty query, query-not-found-falls-back-zu-leerem-Highlight)
- Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine
third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor)
Refs: #47
2026-04-10 01:09:45 +02:00
index_programm , render_highlighted_page , PROGRAMME ,
2026-03-28 22:30:24 +01:00
)
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
)
Security hotfixes #1, #2, #6 from audit (#57)
Drei akute Befunde aus dem Live-System-Audit (Issue #57):
- **#1 HIGH** — Resource Exhaustion via öffentlichem POST: slowapi
Limiter (in-memory, IP-key) auf /analyze (10/min), /api/analyze-drucksache
(10/min) und /api/programme/index (3/min). Verhindert, dass ein
unauthentifizierter Client mit einer Schleife die DashScope-Quota oder
die CPU des Containers leerziehen kann. Default-Storage reicht solange
wir auf einem einzigen Worker laufen.
- **#2 MEDIUM** + **#6 MEDIUM** (selber Root-Cause) — XXE/Local-File-Read
via WeasyPrint und Stored XSS via Browser-Rendering: alle LLM-getragenen
Felder in app/report.py laufen jetzt durch html.escape() bevor sie in
die HTML-Template interpoliert werden. format_redline_html escape-first
und ersetzt dann die Markdown-Marker durch von uns kontrollierte
<span>-Tags. build_matrix_html escaped das aspect-Attribut, sodass ein
nacktes " den title="..."-Wert nicht mehr beenden und einen Event-
Handler injizieren kann. Toter jinja2-Import in report.py entfernt
(war never used, blockierte nur den lokalen Test).
- **Tests** — neue tests/test_report.py mit 8 Cases, die direkt die
Bug-Klasse verifizieren: <script>, file://-img, "-attribut-breakout
in Title und ein End-to-End-Render mit XSS-Payloads in jedem LLM-Feld.
Die Marker-Funktionalität (** und ~~) wird mit-getestet, damit der
Escape-First-Ansatz das nicht versehentlich kaputt macht.
77 alte Unit-Tests + 8 neue → 85 grün.
Rate-Limit-Verifikation per TestClient ist Integration-Scope und folgt
in tests/integration/test_main_security.py als separates Folge-Item.
Refs: #57
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:45:43 +02:00
# 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 )
fix(v2): Topbar-Höhe runter, Share-Felder erweitert (Kopieren/LinkedIn/Email/Bild), Smoke-Test 401-Pattern
- Topbar padding 10px -> 4px, min-height 32px (User: 'Header weniger hoch')
- Share-Buttons im Antragsdetail erweitert auf 7 Plattformen analog v1:
Kopieren (Clipboard), Threads, X, Mastodon, LinkedIn, E-Mail (mailto), Bild (Freepik)
- v2DetailShareCopy/Email/Image-Helper, ANTRAG_TOPICS ans Template uebergeben
- Smoke-Test akzeptiert 401 fuer auth-protected Routen (curl ohne Accept-Header
bekommt 401-JSON, echte Browser bekommen 302-Redirect via _auth_redirect_handler)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:57:04 +02:00
# 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 )
2026-03-28 22:30:24 +01:00
# 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 ) )
2026-04-25 21:50:36 +02:00
# ─── 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 )
2026-03-28 22:30:24 +01:00
@app.on_event ( " startup " )
async def startup ( ) :
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
import asyncio
2026-03-28 22:30:24 +01:00
await init_db ( )
init_embeddings_db ( )
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
# Job-Queue Worker starten (#95) — Worker ZUERST, dann Re-Enqueue
# im Hintergrund (damit die Webapp sofort erreichbar ist)
2026-04-10 17:24:34 +02:00
from . queue import start_worker , re_enqueue_pending
start_worker ( )
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
asyncio . create_task ( re_enqueue_pending ( analysis_callback = run_drucksache_analysis ) )
2026-04-10 23:17:46 +02:00
@app.on_event ( " shutdown " )
async def shutdown ( ) :
""" Graceful Shutdown: warte auf laufende Queue-Jobs bevor der Container stirbt. """
from . queue import graceful_shutdown
2026-04-10 23:20:23 +02:00
await graceful_shutdown ( timeout = 900 ) # 15 min — passend zu stop_grace_period
2026-04-10 23:17:46 +02:00
2026-03-28 22:30:24 +01:00
# 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")
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
@app.get ( " /classic " , response_class = HTMLResponse )
async def classic_index ( request : Request ) :
""" Klassische Ansicht (v1) — jetzt unter /classic erreichbar. """
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>
2026-04-07 23:00:39 +02:00
# 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 ( )
}
2026-04-10 23:06:37 +02:00
from . models import MATRIX_LABELS
2026-03-28 22:30:24 +01:00
return templates . TemplateResponse ( " index.html " , {
" request " : request ,
" app_name " : settings . app_name ,
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>
2026-04-07 23:00:39 +02:00
" bundeslaender " : bl_list ,
" parlament_names " : parlament_names ,
2026-04-10 23:06:37 +02:00
" matrix_labels " : MATRIX_LABELS ,
2026-04-10 23:38:37 +02:00
" matrix_explanations " : {
2026-04-10 23:43:57 +02:00
" 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? " ,
2026-04-10 23:38:37 +02:00
} ,
2026-03-28 22:30:24 +01:00
} )
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
# ─── Default: / → v2 (Default-Flip #139 Phase 2) ────────────────────────────
@app.get ( " / " , response_class = HTMLResponse )
2026-04-25 21:50:36 +02:00
async def index ( request : Request , current_user : Optional [ dict ] = Depends ( get_current_user ) ) :
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
""" 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 ) ,
2026-04-25 21:50:36 +02:00
* * _v2_template_context ( current_user ) ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
} )
@app.get ( " /antrag/ { drucksache:path} " , response_class = HTMLResponse )
2026-04-25 21:50:36 +02:00
async def antrag_detail ( request : Request , drucksache : str , current_user : Optional [ dict ] = Depends ( get_current_user ) ) :
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
""" 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 } " ,
2026-04-25 21:50:36 +02:00
* * _v2_template_context ( current_user ) ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
} , 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. " ,
2026-04-25 21:50:36 +02:00
* * _v2_template_context ( current_user ) ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
} , 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 ,
2026-04-25 21:50:36 +02:00
* * _v2_template_context ( current_user ) ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
} )
2026-04-25 21:50:36 +02:00
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 ,
}
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
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 " , " " ) ,
}
2026-03-28 22:30:24 +01:00
@app.post ( " /analyze " )
Security hotfixes #1, #2, #6 from audit (#57)
Drei akute Befunde aus dem Live-System-Audit (Issue #57):
- **#1 HIGH** — Resource Exhaustion via öffentlichem POST: slowapi
Limiter (in-memory, IP-key) auf /analyze (10/min), /api/analyze-drucksache
(10/min) und /api/programme/index (3/min). Verhindert, dass ein
unauthentifizierter Client mit einer Schleife die DashScope-Quota oder
die CPU des Containers leerziehen kann. Default-Storage reicht solange
wir auf einem einzigen Worker laufen.
- **#2 MEDIUM** + **#6 MEDIUM** (selber Root-Cause) — XXE/Local-File-Read
via WeasyPrint und Stored XSS via Browser-Rendering: alle LLM-getragenen
Felder in app/report.py laufen jetzt durch html.escape() bevor sie in
die HTML-Template interpoliert werden. format_redline_html escape-first
und ersetzt dann die Markdown-Marker durch von uns kontrollierte
<span>-Tags. build_matrix_html escaped das aspect-Attribut, sodass ein
nacktes " den title="..."-Wert nicht mehr beenden und einen Event-
Handler injizieren kann. Toter jinja2-Import in report.py entfernt
(war never used, blockierte nur den lokalen Test).
- **Tests** — neue tests/test_report.py mit 8 Cases, die direkt die
Bug-Klasse verifizieren: <script>, file://-img, "-attribut-breakout
in Title und ein End-to-End-Render mit XSS-Payloads in jedem LLM-Feld.
Die Marker-Funktionalität (** und ~~) wird mit-getestet, damit der
Escape-First-Ansatz das nicht versehentlich kaputt macht.
77 alte Unit-Tests + 8 neue → 85 grün.
Rate-Limit-Verifikation per TestClient ist Integration-Scope und folgt
in tests/integration/test_main_security.py als separates Folge-Item.
Refs: #57
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:45:43 +02:00
@limiter.limit ( " 10/minute " )
2026-03-28 22:30:24 +01:00
async def start_analysis (
Security hotfixes #1, #2, #6 from audit (#57)
Drei akute Befunde aus dem Live-System-Audit (Issue #57):
- **#1 HIGH** — Resource Exhaustion via öffentlichem POST: slowapi
Limiter (in-memory, IP-key) auf /analyze (10/min), /api/analyze-drucksache
(10/min) und /api/programme/index (3/min). Verhindert, dass ein
unauthentifizierter Client mit einer Schleife die DashScope-Quota oder
die CPU des Containers leerziehen kann. Default-Storage reicht solange
wir auf einem einzigen Worker laufen.
- **#2 MEDIUM** + **#6 MEDIUM** (selber Root-Cause) — XXE/Local-File-Read
via WeasyPrint und Stored XSS via Browser-Rendering: alle LLM-getragenen
Felder in app/report.py laufen jetzt durch html.escape() bevor sie in
die HTML-Template interpoliert werden. format_redline_html escape-first
und ersetzt dann die Markdown-Marker durch von uns kontrollierte
<span>-Tags. build_matrix_html escaped das aspect-Attribut, sodass ein
nacktes " den title="..."-Wert nicht mehr beenden und einen Event-
Handler injizieren kann. Toter jinja2-Import in report.py entfernt
(war never used, blockierte nur den lokalen Test).
- **Tests** — neue tests/test_report.py mit 8 Cases, die direkt die
Bug-Klasse verifizieren: <script>, file://-img, "-attribut-breakout
in Title und ein End-to-End-Render mit XSS-Payloads in jedem LLM-Feld.
Die Marker-Funktionalität (** und ~~) wird mit-getestet, damit der
Escape-First-Ansatz das nicht versehentlich kaputt macht.
77 alte Unit-Tests + 8 neue → 85 grün.
Rate-Limit-Verifikation per TestClient ist Integration-Scope und folgt
in tests/integration/test_main_security.py als separates Folge-Item.
Refs: #57
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:45:43 +02:00
request : Request ,
2026-03-28 22:30:24 +01:00
background_tasks : BackgroundTasks ,
text : Optional [ str ] = Form ( None ) ,
file : Optional [ UploadFile ] = File ( None ) ,
bundesland : str = Form ( " NRW " ) ,
model : str = Form ( " qwen-plus " ) ,
2026-04-10 14:30:54 +02:00
user : dict = Depends ( require_auth ) ,
2026-03-28 22:30:24 +01:00
) :
""" 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 "
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>
2026-04-07 23:00:39 +02:00
await generate_html_report ( assessment , html_path , bundesland = bundesland )
await generate_pdf_report ( assessment , pdf_path , bundesland = bundesland )
2026-03-28 22:30:24 +01:00
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 " )
2026-04-10 17:24:34 +02:00
# ─── 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 ( )
#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
# ─── 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 }
2026-04-10 21:18:10 +02:00
@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 "
2026-04-10 21:19:31 +02:00
import httpx as _httpx
async with _httpx . AsyncClient ( timeout = 10 ) as client :
2026-04-10 21:18:10 +02:00
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 " , " " )
2026-04-10 21:27:32 +02:00
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 """ <!DOCTYPE html><html><head>
< meta http - equiv = " refresh " content = " 0;url=/ " >
< / head > < body > < p > Anmeldung erfolgreich , Weiterleitung . . . < / p > < / body > < / html > """ ,
headers = {
" Set-Cookie " : (
f " access_token= { access_token } ; Path=/; Secure; HttpOnly; "
f " SameSite=Lax; Max-Age= { expires_in } "
)
} ,
2026-04-10 21:18:10 +02:00
)
#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
@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 " : " " }
2026-04-10 21:18:10 +02:00
base = str ( request . base_url ) . rstrip ( " / " ) . replace ( " http:// " , " https:// " )
url = keycloak_login_url ( f " { base } /api/auth/callback " )
#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
return { " enabled " : True , " url " : url }
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
@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
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
# ─── 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 " ] )
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
# ─── 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 (
" <html><body style= ' font-family:sans-serif;max-width:500px;margin:50px auto;padding:20px ' > "
" <h2>Ungültiger Unsubscribe-Link</h2> "
" <p>Der Link ist nicht mehr gültig oder wurde manipuliert.</p> "
" <p><a href= ' / ' >Zurück zur Startseite</a></p> "
" </body></html> " ,
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 " <html><body style= ' font-family:sans-serif;max-width:500px;margin:50px auto;padding:20px ' > "
f " <h2> { msg } </h2> "
f " <p>Du bekommst keine weiteren Benachrichtigungen zu diesem Filter.</p> "
f " <p><a href= ' / ' >Zurück zur Startseite</a></p> "
f " </body></html> "
)
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
@app.post ( " /api/comment " )
async def comment_add (
drucksache : str = Form ( . . . ) ,
text : str = Form ( . . . ) ,
visibility : str = Form ( " all " ) ,
user : dict = Depends ( require_auth ) ,
) :
""" Kommentar hinzufügen. """
if len ( text ) > 2000 :
raise HTTPException ( status_code = 400 , detail = " Kommentar zu lang (max 2000 Zeichen) " )
return await add_comment ( user [ " sub " ] , user . get ( " name " , " " ) , drucksache , text , visibility )
@app.get ( " /api/comments " )
async def comments_list ( drucksache : str , user = Depends ( get_current_user ) ) :
""" Kommentare für einen Antrag. """
user_id = user [ " sub " ] if user else None
return await get_comments ( drucksache , user_id )
@app.delete ( " /api/comment/ {comment_id} " )
async def comment_delete ( comment_id : int , user : dict = Depends ( require_auth ) ) :
""" Eigenen Kommentar löschen. """
deleted = await delete_comment ( comment_id , user [ " sub " ] )
if not deleted :
raise HTTPException ( status_code = 404 , detail = " Kommentar nicht gefunden oder nicht Ihr Kommentar " )
return { " status " : " deleted " }
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
# ─── Assessment-History (#110) ────────────────────────────────────────────
@app.get ( " /api/assessment/history " )
async def assessment_history ( drucksache : str ) :
""" Versionshistorie eines Assessments. """
drucksache = validate_drucksache ( drucksache )
return await get_assessment_history ( drucksache )
# ─── Crowd-Validation / Votes (#112) ─────────────────────────────────────
@app.post ( " /api/vote " )
async def vote_endpoint (
drucksache : str = Form ( . . . ) ,
target : str = Form ( " overall " ) ,
vote : str = Form ( . . . ) ,
user : dict = Depends ( require_auth ) ,
) :
""" Bewertung als treffend/fragwürdig markieren. Toggle: gleicher Vote nochmal = entfernen. """
drucksache = validate_drucksache ( drucksache )
if vote not in ( " up " , " down " ) :
raise HTTPException ( status_code = 400 , detail = " vote muss ' up ' oder ' down ' sein " )
if target not in ( " overall " , ) and not target . startswith ( " matrix: " ) and not target . startswith ( " partei: " ) :
raise HTTPException ( status_code = 400 , detail = " Ungültiges target " )
result = await upsert_vote ( user [ " sub " ] , drucksache , target , vote )
return result
@app.get ( " /api/votes " )
async def votes_endpoint ( drucksache : str , user : dict = Depends ( get_current_user ) ) :
""" Aggregierte Votes + eigener Vote für eine Drucksache. """
drucksache = validate_drucksache ( drucksache )
user_id = user [ " sub " ] if user else None
return await get_votes ( drucksache , user_id )
2026-04-10 23:53:05 +02:00
# ─── Registrierung (#103) ────────────────────────────────────────────────
@app.post ( " /api/auth/register " )
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
@limiter.limit ( " 3/hour " )
2026-04-10 23:53:05 +02:00
async def auth_register (
request : Request ,
firstName : str = Form ( . . . ) ,
lastName : str = Form ( . . . ) ,
email : str = Form ( . . . ) ,
username : str = Form ( . . . ) ,
) :
""" Registrierung: erstellt User in Keycloak mit enabled=false.
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
Admin muss den Account manuell freischalten .
Kein Passwort nötig — nach Freischaltung sendet Keycloak eine
E - Mail zum Passwort - Setzen . """
2026-04-10 23:53:05 +02:00
import httpx as _httpx
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
admin_token = await keycloak_admin_token ( )
2026-04-10 23:53:05 +02:00
async with _httpx . AsyncClient ( timeout = 10 ) as client :
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
# User anlegen (disabled, ohne Passwort, mit required action)
2026-04-10 23:53:05 +02:00
create_resp = await client . post (
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
f " { settings . keycloak_url } /admin/realms/ { settings . keycloak_realm } /users " ,
2026-04-10 23:53:05 +02:00
headers = { " Authorization " : f " Bearer { admin_token } " , " Content-Type " : " application/json " } ,
json = {
" username " : username ,
" email " : email ,
" firstName " : firstName ,
" lastName " : lastName ,
" enabled " : False ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
" emailVerified " : True ,
" requiredActions " : [ " UPDATE_PASSWORD " ] ,
2026-04-10 23:53:05 +02:00
} ,
)
if create_resp . status_code == 409 :
raise HTTPException ( status_code = 409 , detail = " Benutzername oder E-Mail bereits vergeben " )
if create_resp . status_code != 201 :
raise HTTPException ( status_code = 500 , detail = " Registrierung fehlgeschlagen " )
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
return { " status " : " pending_approval " , " message " : " Registrierung eingegangen. Nach Freischaltung erhalten Sie eine E-Mail zum Passwort setzen. " }
2026-04-10 23:53:05 +02:00
@app.get ( " /api/auth/pending-users " )
async def auth_pending_users ( user : dict = Depends ( require_admin ) ) :
""" Liste nicht-freigeschalteter User (Admin-only). """
import httpx as _httpx
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
admin_token = await keycloak_admin_token ( )
2026-04-10 23:53:05 +02:00
async with _httpx . AsyncClient ( timeout = 10 ) as client :
resp = await client . get (
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
f " { settings . keycloak_url } /admin/realms/ { settings . keycloak_realm } /users?enabled=false&max=50 " ,
2026-04-10 23:53:05 +02:00
headers = { " Authorization " : f " Bearer { admin_token } " } ,
)
users = resp . json ( ) if resp . status_code == 200 else [ ]
return [ { " id " : u [ " id " ] , " username " : u . get ( " username " ) ,
" firstName " : u . get ( " firstName " ) , " lastName " : u . get ( " lastName " ) ,
" email " : u . get ( " email " ) , " created " : u . get ( " createdTimestamp " ) }
for u in users ]
@app.post ( " /api/auth/approve-user " )
async def auth_approve_user (
user_id : str = Form ( . . . ) ,
user : dict = Depends ( require_admin ) ,
) :
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
""" User freischalten (Admin-only).
Aktiviert den User und triggert eine Keycloak - E - Mail zum Passwort setzen .
Voraussetzung : Keycloak Realm hat SMTP konfiguriert ( Realm Settings → Email ) .
"""
2026-04-10 23:53:05 +02:00
import httpx as _httpx
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
admin_token = await keycloak_admin_token ( )
2026-04-10 23:53:05 +02:00
async with _httpx . AsyncClient ( timeout = 10 ) as client :
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
headers = { " Authorization " : f " Bearer { admin_token } " , " Content-Type " : " application/json " }
base = f " { settings . keycloak_url } /admin/realms/ { settings . keycloak_realm } /users/ { user_id } "
# 1. User aktivieren
resp = await client . put ( base , headers = headers , json = { " enabled " : True } )
if resp . status_code != 204 :
raise HTTPException ( status_code = 500 , detail = " Freischaltung fehlgeschlagen " )
# 2. Passwort-Setzen-E-Mail senden (Keycloak execute-actions-email)
email_resp = await client . put (
f " { base } /execute-actions-email " , headers = headers , json = [ " UPDATE_PASSWORD " ] ,
2026-04-10 23:53:05 +02:00
)
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
email_sent = email_resp . status_code == 204
return {
" status " : " approved " ,
" user_id " : user_id ,
" password_email_sent " : email_sent ,
}
2026-04-10 23:53:05 +02:00
2026-03-28 22:30:24 +01:00
# API: Load assessments from database
@app.get ( " /api/assessments " )
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>
2026-04-07 23:00:39 +02:00
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 )
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
# Lightweight list format — nur Felder die für die Listenansicht nötig sind.
# Detail-Daten (Matrix, Scores, Verbesserungen) werden on-demand via
# GET /api/assessment?drucksache=X geladen (#122 Speicheroptimierung).
2026-03-28 22:30:24 +01:00
assessments = [ ]
for row in rows :
assessments . append ( {
" drucksache " : row . get ( " drucksache " ) ,
" title " : row . get ( " title " ) ,
" fraktionen " : row . get ( " fraktionen " , [ ] ) ,
" datum " : row . get ( " datum " ) ,
" link " : row . get ( " link " ) ,
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>
2026-04-07 23:00:39 +02:00
" bundesland " : row . get ( " bundesland " ) ,
2026-03-28 22:30:24 +01:00
" gwoeScore " : row . get ( " gwoe_score " ) ,
" empfehlung " : row . get ( " empfehlung " ) ,
" empfehlungSymbol " : row . get ( " empfehlung_symbol " ) ,
" themen " : row . get ( " themen " , [ ] ) ,
2026-04-10 21:10:33 +02:00
" updatedAt " : row . get ( " updated_at " ) ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
" konfidenz " : row . get ( " konfidenz " ) ,
2026-03-28 22:30:24 +01:00
} )
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>
2026-04-07 23:00:39 +02:00
2026-03-28 22:30:24 +01:00
return assessments
# API: Get single assessment (use query param for drucksache with /)
@app.get ( " /api/assessment " )
async def get_single_assessment ( drucksache : str ) :
""" Get a single assessment by drucksache ID. """
Phase A: Audit-Restbefunde #57.3/4/7 (Roadmap #59)
Drei verbleibende Audit-Befunde aus #57 in einem Patch:
- **#57.3 MEDIUM** Drucksache-Regex-Validation: neue
app/validators.py mit validate_drucksache() als gemeinsamer
Validation-Funnel. Pattern ^\d{1,3}/\d{1,7}([-(].{1,20})?$ deckt
alle 10 aktiven Bundesländer (8/6390, 18/12345, 8/6390(neu),
23/3700-A) ab und blockt Path-Traversal (../, /etc/passwd) plus
Standard-Injection (;, <, &). Drei Endpoints durchgeschleust:
/api/assessment, /api/assessment/pdf, /api/analyze-drucksache.
- **#57.4 MEDIUM** print() → logging.getLogger(__name__): main.py
und analyzer.py auf strukturiertes Logging umgestellt. LLM-Inhalte
werden NICHT mehr als Volltext geloggt — neue Helper
_content_fingerprint() liefert nur "len=N sha1=XXXX", reicht zur
Forensik ohne Antrag-Inhalte ins Container-Log zu leaken.
basicConfig() mit ISO-Format setzt strukturiertes Logging früh,
damit logger.exception() auch beim Boot greift.
- **#57.7 LOW-MED** Search-Query-Limit: validate_search_query() mit
MAX_SEARCH_QUERY_LEN=200 schützt /api/search und /api/search-landtag
vor 10-MB-Query-DoS. database._parse_search_query() loggt jetzt
shlex.ValueError-Fallback statt ihn zu verschlucken (deckt Memory-
Regel "stille excepts in Adaptern" ab).
Tests: neue tests/test_main_validators.py mit 22 Cases — Drucksache-
Whitelist-Roundtrip + Path-Traversal-Reject, Search-Query Längen-
Edge-Cases. 107 Unit-Tests grün (85 alt + 22 neu).
Validators in eigenem Modul (app/validators.py), damit Tests sie ohne
slowapi-Dependency direkt importieren können.
Refs: #57, #59 (Phase A)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:15:16 +02:00
drucksache = validate_drucksache ( drucksache )
2026-03-28 22:30:24 +01:00
row = await get_assessment ( drucksache )
if not row :
raise HTTPException ( status_code = 404 , detail = " Assessment nicht gefunden " )
return {
" drucksache " : row . get ( " drucksache " ) ,
" title " : row . get ( " title " ) ,
" fraktionen " : row . get ( " fraktionen " , [ ] ) ,
" datum " : row . get ( " datum " ) ,
" link " : row . get ( " link " ) ,
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>
2026-04-07 23:00:39 +02:00
" bundesland " : row . get ( " bundesland " ) ,
2026-03-28 22:30:24 +01:00
" gwoeScore " : row . get ( " gwoe_score " ) ,
" gwoeBegründung " : row . get ( " gwoe_begruendung " ) ,
" gwoeMatrix " : row . get ( " gwoe_matrix " , [ ] ) ,
" gwoeSchwerpunkt " : row . get ( " gwoe_schwerpunkt " , [ ] ) ,
" wahlprogrammScores " : row . get ( " wahlprogramm_scores " , [ ] ) ,
" verbesserungen " : row . get ( " verbesserungen " , [ ] ) ,
" stärken " : row . get ( " staerken " , [ ] ) ,
" schwächen " : row . get ( " schwaechen " , [ ] ) ,
" empfehlung " : row . get ( " empfehlung " ) ,
" empfehlungSymbol " : row . get ( " empfehlung_symbol " ) ,
" verbesserungspotenzial " : row . get ( " verbesserungspotenzial " ) ,
" themen " : row . get ( " themen " , [ ] ) ,
" antragZusammenfassung " : row . get ( " antrag_zusammenfassung " ) ,
" antragKernpunkte " : row . get ( " antrag_kernpunkte " , [ ] ) ,
2026-04-10 21:10:33 +02:00
" updatedAt " : row . get ( " updated_at " ) ,
" source " : row . get ( " source " ) ,
" model " : row . get ( " model " ) ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
" konfidenz " : row . get ( " konfidenz " ) ,
" fehlendeProgramme " : row . get ( " fehlende_programme " ) or [ ] ,
2026-03-28 22:30:24 +01:00
}
2026-04-10 21:10:33 +02:00
# API: Delete assessment for re-analysis (#97)
@app.delete ( " /api/assessment/delete " )
async def delete_assessment_endpoint (
drucksache : str ,
2026-04-10 23:15:42 +02:00
user : dict = Depends ( require_admin ) ,
2026-04-10 21:10:33 +02:00
) :
""" Löscht ein Assessment, damit es neu analysiert werden kann. """
drucksache = validate_drucksache ( drucksache )
deleted = await delete_assessment ( drucksache )
if not deleted :
raise HTTPException ( status_code = 404 , detail = " Assessment nicht gefunden " )
return { " status " : " deleted " , " drucksache " : drucksache }
2026-03-28 22:30:24 +01:00
# API: Generate PDF on demand for an assessment
@app.get ( " /api/assessment/pdf " )
async def download_assessment_pdf ( drucksache : str ) :
""" Generate and download PDF for an assessment. """
from . models import Assessment
Phase A: Audit-Restbefunde #57.3/4/7 (Roadmap #59)
Drei verbleibende Audit-Befunde aus #57 in einem Patch:
- **#57.3 MEDIUM** Drucksache-Regex-Validation: neue
app/validators.py mit validate_drucksache() als gemeinsamer
Validation-Funnel. Pattern ^\d{1,3}/\d{1,7}([-(].{1,20})?$ deckt
alle 10 aktiven Bundesländer (8/6390, 18/12345, 8/6390(neu),
23/3700-A) ab und blockt Path-Traversal (../, /etc/passwd) plus
Standard-Injection (;, <, &). Drei Endpoints durchgeschleust:
/api/assessment, /api/assessment/pdf, /api/analyze-drucksache.
- **#57.4 MEDIUM** print() → logging.getLogger(__name__): main.py
und analyzer.py auf strukturiertes Logging umgestellt. LLM-Inhalte
werden NICHT mehr als Volltext geloggt — neue Helper
_content_fingerprint() liefert nur "len=N sha1=XXXX", reicht zur
Forensik ohne Antrag-Inhalte ins Container-Log zu leaken.
basicConfig() mit ISO-Format setzt strukturiertes Logging früh,
damit logger.exception() auch beim Boot greift.
- **#57.7 LOW-MED** Search-Query-Limit: validate_search_query() mit
MAX_SEARCH_QUERY_LEN=200 schützt /api/search und /api/search-landtag
vor 10-MB-Query-DoS. database._parse_search_query() loggt jetzt
shlex.ValueError-Fallback statt ihn zu verschlucken (deckt Memory-
Regel "stille excepts in Adaptern" ab).
Tests: neue tests/test_main_validators.py mit 22 Cases — Drucksache-
Whitelist-Roundtrip + Path-Traversal-Reject, Search-Query Längen-
Edge-Cases. 107 Unit-Tests grün (85 alt + 22 neu).
Validators in eigenem Modul (app/validators.py), damit Tests sie ohne
slowapi-Dependency direkt importieren können.
Refs: #57, #59 (Phase A)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:15:16 +02:00
drucksache = validate_drucksache ( drucksache )
2026-03-28 22:30:24 +01:00
row = await get_assessment ( drucksache )
if not row :
raise HTTPException ( status_code = 404 , detail = " Assessment nicht gefunden " )
# Check if PDF already exists
safe_name = drucksache . replace ( " / " , " - " )
pdf_path = settings . reports_dir / f " { safe_name } .pdf "
if not pdf_path . exists ( ) :
# Convert DB row to Assessment model for report generation
assessment_data = {
" drucksache " : row . get ( " drucksache " ) ,
" title " : row . get ( " title " ) ,
" fraktionen " : row . get ( " fraktionen " , [ ] ) ,
" datum " : row . get ( " datum " ) ,
" link " : row . get ( " link " ) ,
" gwoe_score " : row . get ( " gwoe_score " ) or 0 ,
" gwoe_begruendung " : row . get ( " gwoe_begruendung " ) or " " ,
" gwoe_matrix " : row . get ( " gwoe_matrix " , [ ] ) ,
" gwoe_schwerpunkt " : row . get ( " gwoe_schwerpunkt " , [ ] ) ,
" wahlprogramm_scores " : row . get ( " wahlprogramm_scores " , [ ] ) ,
" verbesserungen " : row . get ( " verbesserungen " , [ ] ) ,
" staerken " : row . get ( " staerken " , [ ] ) ,
" schwaechen " : row . get ( " schwaechen " , [ ] ) ,
" empfehlung " : row . get ( " empfehlung " ) or " " ,
" empfehlung_symbol " : row . get ( " empfehlung_symbol " ) or " " ,
" verbesserungspotenzial " : row . get ( " verbesserungspotenzial " ) or " " ,
" themen " : row . get ( " themen " , [ ] ) ,
" antrag_zusammenfassung " : row . get ( " antrag_zusammenfassung " ) or " " ,
" antrag_kernpunkte " : row . get ( " antrag_kernpunkte " , [ ] ) ,
}
try :
assessment = Assessment ( * * assessment_data )
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>
2026-04-07 23:00:39 +02:00
await generate_pdf_report (
assessment ,
pdf_path ,
bundesland = row . get ( " bundesland " ) ,
)
2026-03-28 22:30:24 +01:00
except Exception as e :
raise HTTPException ( status_code = 500 , detail = f " PDF-Generierung fehlgeschlagen: { e } " )
return FileResponse (
pdf_path ,
media_type = " application/pdf " ,
filename = f " gwoe- { safe_name } .pdf "
)
# API: Search internal DB only
@app.get ( " /api/search " )
async def search_internal (
q : str ,
bundesland : str = " NRW " ,
limit : int = 50
) :
"""
Search internal assessments database only .
"""
Phase A: Audit-Restbefunde #57.3/4/7 (Roadmap #59)
Drei verbleibende Audit-Befunde aus #57 in einem Patch:
- **#57.3 MEDIUM** Drucksache-Regex-Validation: neue
app/validators.py mit validate_drucksache() als gemeinsamer
Validation-Funnel. Pattern ^\d{1,3}/\d{1,7}([-(].{1,20})?$ deckt
alle 10 aktiven Bundesländer (8/6390, 18/12345, 8/6390(neu),
23/3700-A) ab und blockt Path-Traversal (../, /etc/passwd) plus
Standard-Injection (;, <, &). Drei Endpoints durchgeschleust:
/api/assessment, /api/assessment/pdf, /api/analyze-drucksache.
- **#57.4 MEDIUM** print() → logging.getLogger(__name__): main.py
und analyzer.py auf strukturiertes Logging umgestellt. LLM-Inhalte
werden NICHT mehr als Volltext geloggt — neue Helper
_content_fingerprint() liefert nur "len=N sha1=XXXX", reicht zur
Forensik ohne Antrag-Inhalte ins Container-Log zu leaken.
basicConfig() mit ISO-Format setzt strukturiertes Logging früh,
damit logger.exception() auch beim Boot greift.
- **#57.7 LOW-MED** Search-Query-Limit: validate_search_query() mit
MAX_SEARCH_QUERY_LEN=200 schützt /api/search und /api/search-landtag
vor 10-MB-Query-DoS. database._parse_search_query() loggt jetzt
shlex.ValueError-Fallback statt ihn zu verschlucken (deckt Memory-
Regel "stille excepts in Adaptern" ab).
Tests: neue tests/test_main_validators.py mit 22 Cases — Drucksache-
Whitelist-Roundtrip + Path-Traversal-Reject, Search-Query Längen-
Edge-Cases. 107 Unit-Tests grün (85 alt + 22 neu).
Validators in eigenem Modul (app/validators.py), damit Tests sie ohne
slowapi-Dependency direkt importieren können.
Refs: #57, #59 (Phase A)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:15:16 +02:00
q = validate_search_query ( q )
2026-03-28 22:30:24 +01:00
db_results = await search_assessments ( q , bundesland , limit )
results = [ ]
for row in db_results :
results . append ( {
" drucksache " : row . get ( " drucksache " ) ,
" title " : row . get ( " title " ) ,
" fraktionen " : row . get ( " fraktionen " , [ ] ) ,
" datum " : row . get ( " datum " ) ,
" link " : row . get ( " link " ) ,
" bundesland " : bundesland ,
" gwoeScore " : row . get ( " gwoe_score " ) ,
" themen " : row . get ( " themen " , [ ] ) ,
" status " : " checked " ,
} )
return results
# API: Search external parliament portal (Landtag)
@app.get ( " /api/search-landtag " )
async def search_landtag (
q : str ,
bundesland : str = " NRW " ,
limit : int = 20
) :
"""
Search external parliament portal ( e . g . , NRW OPAL ) .
Returns results that can be analyzed with " Jetzt prüfen " .
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>
2026-04-07 23:00:39 +02:00
Requires a concrete Bundesland — the special " ALL " / Bundesweit mode
cannot pick a single Landtag adapter and is rejected with HTTP 400.
2026-03-28 22:30:24 +01:00
"""
Phase A: Audit-Restbefunde #57.3/4/7 (Roadmap #59)
Drei verbleibende Audit-Befunde aus #57 in einem Patch:
- **#57.3 MEDIUM** Drucksache-Regex-Validation: neue
app/validators.py mit validate_drucksache() als gemeinsamer
Validation-Funnel. Pattern ^\d{1,3}/\d{1,7}([-(].{1,20})?$ deckt
alle 10 aktiven Bundesländer (8/6390, 18/12345, 8/6390(neu),
23/3700-A) ab und blockt Path-Traversal (../, /etc/passwd) plus
Standard-Injection (;, <, &). Drei Endpoints durchgeschleust:
/api/assessment, /api/assessment/pdf, /api/analyze-drucksache.
- **#57.4 MEDIUM** print() → logging.getLogger(__name__): main.py
und analyzer.py auf strukturiertes Logging umgestellt. LLM-Inhalte
werden NICHT mehr als Volltext geloggt — neue Helper
_content_fingerprint() liefert nur "len=N sha1=XXXX", reicht zur
Forensik ohne Antrag-Inhalte ins Container-Log zu leaken.
basicConfig() mit ISO-Format setzt strukturiertes Logging früh,
damit logger.exception() auch beim Boot greift.
- **#57.7 LOW-MED** Search-Query-Limit: validate_search_query() mit
MAX_SEARCH_QUERY_LEN=200 schützt /api/search und /api/search-landtag
vor 10-MB-Query-DoS. database._parse_search_query() loggt jetzt
shlex.ValueError-Fallback statt ihn zu verschlucken (deckt Memory-
Regel "stille excepts in Adaptern" ab).
Tests: neue tests/test_main_validators.py mit 22 Cases — Drucksache-
Whitelist-Roundtrip + Path-Traversal-Reject, Search-Query Längen-
Edge-Cases. 107 Unit-Tests grün (85 alt + 22 neu).
Validators in eigenem Modul (app/validators.py), damit Tests sie ohne
slowapi-Dependency direkt importieren können.
Refs: #57, #59 (Phase A)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:15:16 +02:00
q = validate_search_query ( q )
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>
2026-04-07 23:00:39 +02:00
if not bundesland or bundesland == " ALL " :
raise HTTPException (
status_code = 400 ,
detail = " Landtag-Suche benötigt ein konkretes Bundesland " ,
)
2026-03-28 22:30:24 +01:00
adapter = get_adapter ( bundesland )
if not adapter :
return { " error " : f " Bundesland { bundesland } noch nicht unterstützt " }
try :
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
external = adapter . _filter_abstimmbar ( await adapter . search ( q , limit ) )
2026-04-25 22:23:22 +02:00
# Zusätzliche Title-Heuristik: bei Adaptern die Typ='Drucksache' liefern
# (NRW), Kleine-Anfrage-Frage-Pattern erkennen und ausfiltern.
from . drucksache_typen import likely_kleine_anfrage_titel , KLEINE_ANFRAGE
2026-03-28 22:30:24 +01:00
results = [ ]
for doc in external :
2026-04-25 22:23:22 +02:00
if doc . typ_normiert == " sonstige " and likely_kleine_anfrage_titel ( doc . title ) :
continue # höchstwahrscheinlich Kleine Anfrage
2026-03-28 22:30:24 +01:00
results . append ( {
" drucksache " : doc . drucksache ,
" title " : doc . title ,
" fraktionen " : doc . fraktionen ,
" datum " : doc . datum ,
" link " : doc . link ,
" bundesland " : bundesland ,
" typ " : doc . typ ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
" typ_normiert " : doc . typ_normiert ,
2026-03-28 22:30:24 +01:00
" gwoeScore " : None ,
" status " : " unchecked " ,
} )
return results
except Exception as e :
Phase A: Audit-Restbefunde #57.3/4/7 (Roadmap #59)
Drei verbleibende Audit-Befunde aus #57 in einem Patch:
- **#57.3 MEDIUM** Drucksache-Regex-Validation: neue
app/validators.py mit validate_drucksache() als gemeinsamer
Validation-Funnel. Pattern ^\d{1,3}/\d{1,7}([-(].{1,20})?$ deckt
alle 10 aktiven Bundesländer (8/6390, 18/12345, 8/6390(neu),
23/3700-A) ab und blockt Path-Traversal (../, /etc/passwd) plus
Standard-Injection (;, <, &). Drei Endpoints durchgeschleust:
/api/assessment, /api/assessment/pdf, /api/analyze-drucksache.
- **#57.4 MEDIUM** print() → logging.getLogger(__name__): main.py
und analyzer.py auf strukturiertes Logging umgestellt. LLM-Inhalte
werden NICHT mehr als Volltext geloggt — neue Helper
_content_fingerprint() liefert nur "len=N sha1=XXXX", reicht zur
Forensik ohne Antrag-Inhalte ins Container-Log zu leaken.
basicConfig() mit ISO-Format setzt strukturiertes Logging früh,
damit logger.exception() auch beim Boot greift.
- **#57.7 LOW-MED** Search-Query-Limit: validate_search_query() mit
MAX_SEARCH_QUERY_LEN=200 schützt /api/search und /api/search-landtag
vor 10-MB-Query-DoS. database._parse_search_query() loggt jetzt
shlex.ValueError-Fallback statt ihn zu verschlucken (deckt Memory-
Regel "stille excepts in Adaptern" ab).
Tests: neue tests/test_main_validators.py mit 22 Cases — Drucksache-
Whitelist-Roundtrip + Path-Traversal-Reject, Search-Query Längen-
Edge-Cases. 107 Unit-Tests grün (85 alt + 22 neu).
Validators in eigenem Modul (app/validators.py), damit Tests sie ohne
slowapi-Dependency direkt importieren können.
Refs: #57, #59 (Phase A)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:15:16 +02:00
logger . exception ( " Landtag search error for q= %r bundesland= %s " , q , bundesland )
2026-03-28 22:30:24 +01:00
return { " error " : f " Suchfehler: { str ( e ) } " }
#44 Batch-Analyse: POST /api/batch-analyze
Neuer Endpoint der die neuesten ungeprüften Drucksachen eines BL
automatisch sucht, herunterlädt und in die Queue (#95) einreiht:
POST /api/batch-analyze
bundesland=NRW (Pflicht)
limit=10 (1-100, default 10)
Flow:
1. adapter.search("", limit=limit*3) holt neueste Drucksachen
2. Pro Drucksache: check ob schon bewertet → skip
3. download_text → enqueue(run_drucksache_analysis)
4. Queue verarbeitet seriell mit 10s Pause (DashScope-freundlich)
Response:
{
"status": "batch_enqueued",
"enqueued": 7,
"skipped_existing": 3,
"jobs": [{"drucksache": "18/...", "title": "...", "queue_position": 1}, ...]
}
Rate-limited auf 3/min. Erfordert Auth (#43).
Bei voller Queue: enqueued nur soweit Platz, kein Error.
Tests: 201 passed.
Refs: #44, #95 (Queue-Basis)
2026-04-10 17:26:05 +02:00
# API: Batch-Analyse (#44) — enqueued ungeprüfte Drucksachen eines BL
@app.post ( " /api/batch-analyze " )
@limiter.limit ( " 3/minute " )
async def batch_analyze (
request : Request ,
bundesland : str = Form ( . . . ) ,
limit : int = Form ( 10 ) ,
2026-04-10 23:15:42 +02:00
user : dict = Depends ( require_admin ) ,
#44 Batch-Analyse: POST /api/batch-analyze
Neuer Endpoint der die neuesten ungeprüften Drucksachen eines BL
automatisch sucht, herunterlädt und in die Queue (#95) einreiht:
POST /api/batch-analyze
bundesland=NRW (Pflicht)
limit=10 (1-100, default 10)
Flow:
1. adapter.search("", limit=limit*3) holt neueste Drucksachen
2. Pro Drucksache: check ob schon bewertet → skip
3. download_text → enqueue(run_drucksache_analysis)
4. Queue verarbeitet seriell mit 10s Pause (DashScope-freundlich)
Response:
{
"status": "batch_enqueued",
"enqueued": 7,
"skipped_existing": 3,
"jobs": [{"drucksache": "18/...", "title": "...", "queue_position": 1}, ...]
}
Rate-limited auf 3/min. Erfordert Auth (#43).
Bei voller Queue: enqueued nur soweit Platz, kein Error.
Tests: 201 passed.
Refs: #44, #95 (Queue-Basis)
2026-04-10 17:26:05 +02:00
) :
""" Sucht die neuesten Drucksachen im Landtag-Portal und enqueued
alle , die noch nicht in der DB bewertet sind .
Returns : Liste der enqueued Drucksachen + Queue - Position .
"""
from . queue import enqueue , QueueFullError
if limit < 1 or limit > 100 :
raise HTTPException ( status_code = 400 , detail = " limit muss 1-100 sein " )
adapter = get_adapter ( bundesland )
if not adapter :
raise HTTPException ( status_code = 400 , detail = f " Bundesland { bundesland } nicht unterstützt " )
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
# Neueste Drucksachen vom Landtag holen, gefiltert auf abstimmbare Typen (#127).
drucksachen = adapter . _filter_abstimmbar ( await adapter . search ( " " , limit = limit * 10 ) )
#44 Batch-Analyse: POST /api/batch-analyze
Neuer Endpoint der die neuesten ungeprüften Drucksachen eines BL
automatisch sucht, herunterlädt und in die Queue (#95) einreiht:
POST /api/batch-analyze
bundesland=NRW (Pflicht)
limit=10 (1-100, default 10)
Flow:
1. adapter.search("", limit=limit*3) holt neueste Drucksachen
2. Pro Drucksache: check ob schon bewertet → skip
3. download_text → enqueue(run_drucksache_analysis)
4. Queue verarbeitet seriell mit 10s Pause (DashScope-freundlich)
Response:
{
"status": "batch_enqueued",
"enqueued": 7,
"skipped_existing": 3,
"jobs": [{"drucksache": "18/...", "title": "...", "queue_position": 1}, ...]
}
Rate-limited auf 3/min. Erfordert Auth (#43).
Bei voller Queue: enqueued nur soweit Platz, kein Error.
Tests: 201 passed.
Refs: #44, #95 (Queue-Basis)
2026-04-10 17:26:05 +02:00
enqueued = [ ]
skipped = 0
for doc in drucksachen :
if len ( enqueued ) > = limit :
break
# Schon bewertet?
existing = await get_assessment ( doc . drucksache )
if existing :
skipped + = 1
continue
# Text herunterladen
text = await adapter . download_text ( doc . drucksache )
if not text :
continue
# Enqueue
job_id = str ( uuid . uuid4 ( ) )
2026-04-10 23:32:40 +02:00
await create_job ( job_id , text [ : 500 ] , bundesland , " qwen-plus " , drucksache = doc . drucksache )
#44 Batch-Analyse: POST /api/batch-analyze
Neuer Endpoint der die neuesten ungeprüften Drucksachen eines BL
automatisch sucht, herunterlädt und in die Queue (#95) einreiht:
POST /api/batch-analyze
bundesland=NRW (Pflicht)
limit=10 (1-100, default 10)
Flow:
1. adapter.search("", limit=limit*3) holt neueste Drucksachen
2. Pro Drucksache: check ob schon bewertet → skip
3. download_text → enqueue(run_drucksache_analysis)
4. Queue verarbeitet seriell mit 10s Pause (DashScope-freundlich)
Response:
{
"status": "batch_enqueued",
"enqueued": 7,
"skipped_existing": 3,
"jobs": [{"drucksache": "18/...", "title": "...", "queue_position": 1}, ...]
}
Rate-limited auf 3/min. Erfordert Auth (#43).
Bei voller Queue: enqueued nur soweit Platz, kein Error.
Tests: 201 passed.
Refs: #44, #95 (Queue-Basis)
2026-04-10 17:26:05 +02:00
try :
position = await enqueue (
job_id ,
run_drucksache_analysis ,
job_id , doc . drucksache , text , bundesland , " qwen-plus " , doc ,
2026-04-10 23:15:42 +02:00
drucksache = doc . drucksache ,
#44 Batch-Analyse: POST /api/batch-analyze
Neuer Endpoint der die neuesten ungeprüften Drucksachen eines BL
automatisch sucht, herunterlädt und in die Queue (#95) einreiht:
POST /api/batch-analyze
bundesland=NRW (Pflicht)
limit=10 (1-100, default 10)
Flow:
1. adapter.search("", limit=limit*3) holt neueste Drucksachen
2. Pro Drucksache: check ob schon bewertet → skip
3. download_text → enqueue(run_drucksache_analysis)
4. Queue verarbeitet seriell mit 10s Pause (DashScope-freundlich)
Response:
{
"status": "batch_enqueued",
"enqueued": 7,
"skipped_existing": 3,
"jobs": [{"drucksache": "18/...", "title": "...", "queue_position": 1}, ...]
}
Rate-limited auf 3/min. Erfordert Auth (#43).
Bei voller Queue: enqueued nur soweit Platz, kein Error.
Tests: 201 passed.
Refs: #44, #95 (Queue-Basis)
2026-04-10 17:26:05 +02:00
)
enqueued . append ( {
" drucksache " : doc . drucksache ,
" title " : doc . title ,
" job_id " : job_id ,
" queue_position " : position ,
} )
except QueueFullError :
break
return {
" status " : " batch_enqueued " ,
" bundesland " : bundesland ,
" enqueued " : len ( enqueued ) ,
" skipped_existing " : skipped ,
" jobs " : enqueued ,
}
2026-03-28 22:30:24 +01:00
# API: Analyze a document from parliament portal
@app.post ( " /api/analyze-drucksache " )
Security hotfixes #1, #2, #6 from audit (#57)
Drei akute Befunde aus dem Live-System-Audit (Issue #57):
- **#1 HIGH** — Resource Exhaustion via öffentlichem POST: slowapi
Limiter (in-memory, IP-key) auf /analyze (10/min), /api/analyze-drucksache
(10/min) und /api/programme/index (3/min). Verhindert, dass ein
unauthentifizierter Client mit einer Schleife die DashScope-Quota oder
die CPU des Containers leerziehen kann. Default-Storage reicht solange
wir auf einem einzigen Worker laufen.
- **#2 MEDIUM** + **#6 MEDIUM** (selber Root-Cause) — XXE/Local-File-Read
via WeasyPrint und Stored XSS via Browser-Rendering: alle LLM-getragenen
Felder in app/report.py laufen jetzt durch html.escape() bevor sie in
die HTML-Template interpoliert werden. format_redline_html escape-first
und ersetzt dann die Markdown-Marker durch von uns kontrollierte
<span>-Tags. build_matrix_html escaped das aspect-Attribut, sodass ein
nacktes " den title="..."-Wert nicht mehr beenden und einen Event-
Handler injizieren kann. Toter jinja2-Import in report.py entfernt
(war never used, blockierte nur den lokalen Test).
- **Tests** — neue tests/test_report.py mit 8 Cases, die direkt die
Bug-Klasse verifizieren: <script>, file://-img, "-attribut-breakout
in Title und ein End-to-End-Render mit XSS-Payloads in jedem LLM-Feld.
Die Marker-Funktionalität (** und ~~) wird mit-getestet, damit der
Escape-First-Ansatz das nicht versehentlich kaputt macht.
77 alte Unit-Tests + 8 neue → 85 grün.
Rate-Limit-Verifikation per TestClient ist Integration-Scope und folgt
in tests/integration/test_main_security.py als separates Folge-Item.
Refs: #57
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:45:43 +02:00
@limiter.limit ( " 10/minute " )
2026-03-28 22:30:24 +01:00
async def analyze_drucksache (
Security hotfixes #1, #2, #6 from audit (#57)
Drei akute Befunde aus dem Live-System-Audit (Issue #57):
- **#1 HIGH** — Resource Exhaustion via öffentlichem POST: slowapi
Limiter (in-memory, IP-key) auf /analyze (10/min), /api/analyze-drucksache
(10/min) und /api/programme/index (3/min). Verhindert, dass ein
unauthentifizierter Client mit einer Schleife die DashScope-Quota oder
die CPU des Containers leerziehen kann. Default-Storage reicht solange
wir auf einem einzigen Worker laufen.
- **#2 MEDIUM** + **#6 MEDIUM** (selber Root-Cause) — XXE/Local-File-Read
via WeasyPrint und Stored XSS via Browser-Rendering: alle LLM-getragenen
Felder in app/report.py laufen jetzt durch html.escape() bevor sie in
die HTML-Template interpoliert werden. format_redline_html escape-first
und ersetzt dann die Markdown-Marker durch von uns kontrollierte
<span>-Tags. build_matrix_html escaped das aspect-Attribut, sodass ein
nacktes " den title="..."-Wert nicht mehr beenden und einen Event-
Handler injizieren kann. Toter jinja2-Import in report.py entfernt
(war never used, blockierte nur den lokalen Test).
- **Tests** — neue tests/test_report.py mit 8 Cases, die direkt die
Bug-Klasse verifizieren: <script>, file://-img, "-attribut-breakout
in Title und ein End-to-End-Render mit XSS-Payloads in jedem LLM-Feld.
Die Marker-Funktionalität (** und ~~) wird mit-getestet, damit der
Escape-First-Ansatz das nicht versehentlich kaputt macht.
77 alte Unit-Tests + 8 neue → 85 grün.
Rate-Limit-Verifikation per TestClient ist Integration-Scope und folgt
in tests/integration/test_main_security.py als separates Folge-Item.
Refs: #57
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:45:43 +02:00
request : Request ,
2026-03-28 22:30:24 +01:00
background_tasks : BackgroundTasks ,
drucksache : str = Form ( . . . ) ,
bundesland : str = Form ( " NRW " ) ,
2026-04-10 14:30:54 +02:00
model : str = Form ( " qwen-plus " ) ,
user : dict = Depends ( require_auth ) ,
2026-03-28 22:30:24 +01:00
) :
"""
Download a document from parliament portal and analyze it .
"""
Phase A: Audit-Restbefunde #57.3/4/7 (Roadmap #59)
Drei verbleibende Audit-Befunde aus #57 in einem Patch:
- **#57.3 MEDIUM** Drucksache-Regex-Validation: neue
app/validators.py mit validate_drucksache() als gemeinsamer
Validation-Funnel. Pattern ^\d{1,3}/\d{1,7}([-(].{1,20})?$ deckt
alle 10 aktiven Bundesländer (8/6390, 18/12345, 8/6390(neu),
23/3700-A) ab und blockt Path-Traversal (../, /etc/passwd) plus
Standard-Injection (;, <, &). Drei Endpoints durchgeschleust:
/api/assessment, /api/assessment/pdf, /api/analyze-drucksache.
- **#57.4 MEDIUM** print() → logging.getLogger(__name__): main.py
und analyzer.py auf strukturiertes Logging umgestellt. LLM-Inhalte
werden NICHT mehr als Volltext geloggt — neue Helper
_content_fingerprint() liefert nur "len=N sha1=XXXX", reicht zur
Forensik ohne Antrag-Inhalte ins Container-Log zu leaken.
basicConfig() mit ISO-Format setzt strukturiertes Logging früh,
damit logger.exception() auch beim Boot greift.
- **#57.7 LOW-MED** Search-Query-Limit: validate_search_query() mit
MAX_SEARCH_QUERY_LEN=200 schützt /api/search und /api/search-landtag
vor 10-MB-Query-DoS. database._parse_search_query() loggt jetzt
shlex.ValueError-Fallback statt ihn zu verschlucken (deckt Memory-
Regel "stille excepts in Adaptern" ab).
Tests: neue tests/test_main_validators.py mit 22 Cases — Drucksache-
Whitelist-Roundtrip + Path-Traversal-Reject, Search-Query Längen-
Edge-Cases. 107 Unit-Tests grün (85 alt + 22 neu).
Validators in eigenem Modul (app/validators.py), damit Tests sie ohne
slowapi-Dependency direkt importieren können.
Refs: #57, #59 (Phase A)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:15:16 +02:00
drucksache = validate_drucksache ( drucksache )
2026-03-28 22:30:24 +01:00
# Check if already analyzed
existing = await get_assessment ( drucksache )
if existing :
return { " status " : " already_checked " , " drucksache " : drucksache }
# Get adapter and download
adapter = get_adapter ( bundesland )
if not adapter :
raise HTTPException ( status_code = 400 , detail = f " Bundesland { bundesland } nicht unterstützt " )
# Download text
text = await adapter . download_text ( drucksache )
if not text :
raise HTTPException ( status_code = 404 , detail = f " Dokument { drucksache } nicht gefunden " )
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
2026-03-28 22:30:24 +01:00
# Get document metadata
doc = await adapter . get_document ( drucksache )
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
# #127: Typ-Check — nur abstimmbare Drucksachen analysieren.
# Falls der Adapter den Typ nicht richtig setzt (NRW: "Drucksache"),
# versuche den Typ aus dem Dokument-Text zu erkennen.
from . drucksache_typen import ist_abstimmbar_original , normalize_typ
doc_typ = doc . typ if doc else " Drucksache "
if doc_typ in ( " Drucksache " , " " ) :
for kw in [ " Kleine Anfrage " , " Große Anfrage " , " Gesetzentwurf " ,
" Änderungsantrag " , " Entschließungsantrag " ,
" Dringlichkeitsantrag " , " Beschlussempfehlung " ,
" Unterrichtung " , " Antrag " ] :
if kw in text [ : 500 ] :
doc_typ = kw
break
if not ist_abstimmbar_original ( doc_typ ) :
return {
" status " : " skipped " ,
" drucksache " : drucksache ,
" typ " : doc_typ ,
" typ_normiert " : normalize_typ ( doc_typ ) ,
" reason " : f " Typ ' { doc_typ } ' ist nicht abstimmbar — keine GWÖ-Bewertung sinnvoll " ,
}
2026-04-10 17:24:34 +02:00
# Create job and enqueue (#95)
from . queue import enqueue , QueueFullError
2026-03-28 22:30:24 +01:00
job_id = str ( uuid . uuid4 ( ) )
2026-04-10 23:32:40 +02:00
await create_job ( job_id , text [ : 500 ] , bundesland , model , drucksache = drucksache )
2026-04-10 17:24:34 +02:00
try :
position = await enqueue (
job_id ,
run_drucksache_analysis ,
job_id , drucksache , text , bundesland , model , doc ,
2026-04-10 23:15:42 +02:00
drucksache = drucksache ,
2026-04-10 17:24:34 +02:00
)
except QueueFullError :
await update_job ( job_id , status = " rejected " , error = " Queue voll " )
raise HTTPException (
status_code = 429 ,
detail = " Analyse-Queue ist voll. Bitte später erneut versuchen. " ,
headers = { " Retry-After " : " 60 " } ,
)
2026-03-28 22:30:24 +01:00
2026-04-10 17:24:34 +02:00
return {
" status " : " queued " ,
" job_id " : job_id ,
" drucksache " : drucksache ,
" queue_position " : position ,
}
2026-03-28 22:30:24 +01:00
async def run_drucksache_analysis (
job_id : str ,
drucksache : str ,
text : str ,
bundesland : str ,
model : str ,
doc
) :
""" Background task for drucksache analysis. """
try :
await update_job ( job_id , status = " processing " )
# Run LLM analysis
assessment = await analyze_antrag ( text , bundesland , model )
# Prepare data for DB
assessment_data = {
" drucksache " : drucksache ,
2026-04-10 16:05:57 +02:00
# Titel-Priorität: LLM-generierter Titel > doc.title,
# ABER nur wenn doc.title ein echter Titel ist (nicht "Drucksache XX",
# wie NRW's get_document zurückgibt). Sonst überschreibt der
# generische doc.title den echten LLM-Titel.
" title " : _pick_best_title ( assessment . title , doc . title if doc else None , drucksache ) ,
2026-03-28 22:30:24 +01:00
" fraktionen " : assessment . fraktionen ,
" datum " : assessment . datum or ( doc . datum if doc else " " ) ,
" link " : doc . link if doc else " " ,
" bundesland " : bundesland ,
" gwoeScore " : assessment . gwoe_score ,
" gwoeBegründung " : assessment . gwoe_begruendung ,
" gwoeMatrix " : [ m . model_dump ( ) for m in assessment . gwoe_matrix ] ,
" gwoeSchwerpunkt " : assessment . gwoe_schwerpunkt ,
" wahlprogrammScores " : [ w . model_dump ( ) for w in assessment . wahlprogramm_scores ] ,
" verbesserungen " : [ v . model_dump ( ) for v in assessment . verbesserungen ] ,
" stärken " : assessment . staerken ,
" schwächen " : assessment . schwaechen ,
" empfehlung " : assessment . empfehlung ,
" empfehlungSymbol " : assessment . empfehlung_symbol ,
" verbesserungspotenzial " : assessment . verbesserungspotenzial ,
" themen " : assessment . themen ,
" antragZusammenfassung " : assessment . antrag_zusammenfassung ,
" antragKernpunkte " : assessment . antrag_kernpunkte ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
" konfidenz " : getattr ( assessment , ' konfidenz ' , None ) ,
" share_threads " : getattr ( assessment , ' share_threads ' , None ) ,
" share_twitter " : getattr ( assessment , ' share_twitter ' , None ) ,
" share_mastodon " : getattr ( assessment , ' share_mastodon ' , None ) ,
" fehlendeProgramme " : getattr ( assessment , ' fehlende_programme ' , None ) or [ ] ,
2026-03-28 22:30:24 +01:00
" source " : " webapp " ,
" model " : model ,
}
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
# #123: Assessment-Embedding für Clustering/Ähnlichkeit erzeugen.
# Fällt bei API-Fehler auf (None, None) zurück — Backfill-Script
# zieht nach. Kein Show-Stopper für das Assessment selbst.
from . embeddings import create_assessment_embedding
emb_blob , emb_model = create_assessment_embedding (
title = assessment_data [ " title " ] ,
zusammenfassung = assessment . antrag_zusammenfassung ,
themen = assessment . themen ,
bundesland = bundesland ,
)
assessment_data [ " summary_embedding " ] = emb_blob
assessment_data [ " embedding_model " ] = emb_model
# #133: Social-Media-Texte separat generieren (schneller, zuverlässiger
# als sie im Haupt-Prompt unterzubringen — Qwen ignoriert sie dort)
try :
from openai import OpenAI
social_client = OpenAI (
api_key = settings . dashscope_api_key ,
base_url = settings . dashscope_base_url ,
)
social_prompt = (
f " Generiere Social-Media-Posts für diesen Parlamentsantrag: \n \n "
f " Titel: { assessment_data [ ' title ' ] } \n "
f " GWÖ-Score: { assessment_data [ ' gwoeScore ' ] } /10 \n "
f " Empfehlung: { assessment_data . get ( ' empfehlung ' , ' ' ) } \n "
f " Fraktionen: { ' , ' . join ( assessment_data . get ( ' fraktionen ' , [ ] ) ) } \n "
f " Themen: { ' , ' . join ( assessment_data . get ( ' themen ' , [ ] ) ) } \n "
f " Bundesland: { bundesland } \n "
f " Zusammenfassung: { ( assessment . antrag_zusammenfassung or ' ' ) [ : 300 ] } \n \n "
f " Antworte NUR mit JSON: \n "
f ' {{ " shareThreads " : " Post für Threads/Instagram (max 500 Zeichen, Emojis, konkret auf den Antrag, CTA, Hashtags #Gemeinwohl #GWÖ + 2 thematische) " , '
f ' " shareTwitter " : " Tweet für X/Twitter (max 280 Zeichen, knackig, pointiert, 2 Hashtags) " , '
f ' " shareMastodon " : " Post für Mastodon (max 500 Zeichen, sachlich-informativ, Kontext, Quellenhinweis) " }} '
)
social_resp = social_client . chat . completions . create (
model = " qwen-plus-latest " ,
messages = [ { " role " : " user " , " content " : social_prompt } ] ,
temperature = 0.7 ,
response_format = { " type " : " json_object " } ,
)
import json as _json
social_data = _json . loads ( social_resp . choices [ 0 ] . message . content )
assessment_data [ " share_threads " ] = social_data . get ( " shareThreads " , " " )
assessment_data [ " share_twitter " ] = social_data . get ( " shareTwitter " , " " )
assessment_data [ " share_mastodon " ] = social_data . get ( " shareMastodon " , " " )
logger . info ( " Social-Texte generiert für %s " , drucksache )
except Exception :
logger . exception ( " Social-Text-Generierung fehlgeschlagen für %s " , drucksache )
2026-03-28 22:30:24 +01:00
# Save to DB
await upsert_assessment ( assessment_data )
# Generate reports
html_path = settings . reports_dir / f " { job_id } .html "
pdf_path = settings . reports_dir / f " { job_id } .pdf "
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>
2026-04-07 23:00:39 +02:00
await generate_html_report ( assessment , html_path , bundesland = bundesland )
await generate_pdf_report ( assessment , pdf_path , bundesland = bundesland )
2026-03-28 22:30:24 +01:00
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 :
Phase A: Audit-Restbefunde #57.3/4/7 (Roadmap #59)
Drei verbleibende Audit-Befunde aus #57 in einem Patch:
- **#57.3 MEDIUM** Drucksache-Regex-Validation: neue
app/validators.py mit validate_drucksache() als gemeinsamer
Validation-Funnel. Pattern ^\d{1,3}/\d{1,7}([-(].{1,20})?$ deckt
alle 10 aktiven Bundesländer (8/6390, 18/12345, 8/6390(neu),
23/3700-A) ab und blockt Path-Traversal (../, /etc/passwd) plus
Standard-Injection (;, <, &). Drei Endpoints durchgeschleust:
/api/assessment, /api/assessment/pdf, /api/analyze-drucksache.
- **#57.4 MEDIUM** print() → logging.getLogger(__name__): main.py
und analyzer.py auf strukturiertes Logging umgestellt. LLM-Inhalte
werden NICHT mehr als Volltext geloggt — neue Helper
_content_fingerprint() liefert nur "len=N sha1=XXXX", reicht zur
Forensik ohne Antrag-Inhalte ins Container-Log zu leaken.
basicConfig() mit ISO-Format setzt strukturiertes Logging früh,
damit logger.exception() auch beim Boot greift.
- **#57.7 LOW-MED** Search-Query-Limit: validate_search_query() mit
MAX_SEARCH_QUERY_LEN=200 schützt /api/search und /api/search-landtag
vor 10-MB-Query-DoS. database._parse_search_query() loggt jetzt
shlex.ValueError-Fallback statt ihn zu verschlucken (deckt Memory-
Regel "stille excepts in Adaptern" ab).
Tests: neue tests/test_main_validators.py mit 22 Cases — Drucksache-
Whitelist-Roundtrip + Path-Traversal-Reject, Search-Query Längen-
Edge-Cases. 107 Unit-Tests grün (85 alt + 22 neu).
Validators in eigenem Modul (app/validators.py), damit Tests sie ohne
slowapi-Dependency direkt importieren können.
Refs: #57, #59 (Phase A)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:15:16 +02:00
# Volltext-Stack via logger.exception, NICHT via print — landet so im
# strukturierten Container-Log und wird vom logging-Framework formatiert
logger . exception ( " run_drucksache_analysis failed for drucksache= %s " , drucksache )
2026-03-28 22:30:24 +01:00
await update_job ( job_id , status = " failed " , error = str ( e ) )
# API: List available Bundesländer
@app.get ( " /api/bundeslaender " )
async def list_bundeslaender ( ) :
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>
2026-04-07 23:00:39 +02:00
""" 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
2026-03-28 22:30:24 +01:00
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
# === Impressum / Datenschutz ===
@app.get ( " /impressum " , response_class = HTMLResponse )
2026-04-25 21:50:36 +02:00
async def impressum_page ( request : Request , current_user : Optional [ dict ] = Depends ( get_current_user ) ) :
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
return templates . TemplateResponse ( " v2/screens/legal.html " , {
" request " : request , " app_name " : settings . app_name ,
" title " : " Impressum " , " section " : " impressum " ,
2026-04-25 21:50:36 +02:00
* * _v2_template_context ( current_user ) ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
} )
@app.get ( " /datenschutz " , response_class = HTMLResponse )
2026-04-25 21:50:36 +02:00
async def datenschutz_page ( request : Request , current_user : Optional [ dict ] = Depends ( get_current_user ) ) :
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
return templates . TemplateResponse ( " v2/screens/legal.html " , {
" request " : request , " app_name " : settings . app_name ,
" title " : " Datenschutzerklärung " , " section " : " datenschutz " ,
2026-04-25 21:50:36 +02:00
* * _v2_template_context ( current_user ) ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
} )
2026-03-28 22:30:24 +01:00
# === Quellen / Programme ===
#96 Methodik-/Transparenz-Seite unter /methodik
Neue Seite für Endnutzer-Transparenz über die Bewertungsmethodik:
- GWÖ-Matrix 2.0 Erklärung mit interaktivem 5×5-Grid
- Analyse-Pipeline als 5-Schritt-Visualisierung (Download → Embedding
→ LLM → Verifikation → Darstellung)
- Wahlprogramm-Vergleich: Erklärung des Retrieval + Top-K + Verifikation
- Qualitätssicherung: Sub-D Property-Tests, server-seitige Quellen-
Rekonstruktion, automatische Neu-Analyse
- Einschränkungen: KI-Bias, keine juristische Bewertung, nur indexierte
Programme, kein Abstimmungsverhalten
- Datenquellen: dynamische Tabelle aller angebundenen Parlamente aus
ADAPTERS + bundeslaender.py
- Technische Details aufklappbar (details/summary) für Interessierte,
Haupttext verständlich für Nicht-Techniker
- Links zu Quellen-Seite, Adapter-Matrix, ADRs
In Hauptnavigation verlinkt (neben Quellen + Auswertungen).
Template-Variablen: adapter_count, model_name, programme_count,
chunk_count, bundeslaender — alles dynamisch aus dem Backend.
Tests: 194/194 grün.
Refs: #96
2026-04-10 16:14:38 +02:00
@app.get ( " /methodik " , response_class = HTMLResponse )
2026-04-25 21:50:36 +02:00
async def methodik_page ( request : Request , current_user : Optional [ dict ] = Depends ( get_current_user ) ) :
#96 Methodik-/Transparenz-Seite unter /methodik
Neue Seite für Endnutzer-Transparenz über die Bewertungsmethodik:
- GWÖ-Matrix 2.0 Erklärung mit interaktivem 5×5-Grid
- Analyse-Pipeline als 5-Schritt-Visualisierung (Download → Embedding
→ LLM → Verifikation → Darstellung)
- Wahlprogramm-Vergleich: Erklärung des Retrieval + Top-K + Verifikation
- Qualitätssicherung: Sub-D Property-Tests, server-seitige Quellen-
Rekonstruktion, automatische Neu-Analyse
- Einschränkungen: KI-Bias, keine juristische Bewertung, nur indexierte
Programme, kein Abstimmungsverhalten
- Datenquellen: dynamische Tabelle aller angebundenen Parlamente aus
ADAPTERS + bundeslaender.py
- Technische Details aufklappbar (details/summary) für Interessierte,
Haupttext verständlich für Nicht-Techniker
- Links zu Quellen-Seite, Adapter-Matrix, ADRs
In Hauptnavigation verlinkt (neben Quellen + Auswertungen).
Template-Variablen: adapter_count, model_name, programme_count,
chunk_count, bundeslaender — alles dynamisch aus dem Backend.
Tests: 194/194 grün.
Refs: #96
2026-04-10 16:14:38 +02:00
""" Transparenz-/Methodik-Seite (#96). """
from . bundeslaender import aktive_bundeslaender , BUNDESLAENDER
from . embeddings import get_indexing_status
bl_list = [ ]
for bl in aktive_bundeslaender ( ) :
bl_list . append ( {
" code " : bl . code ,
" name " : bl . name ,
" doku_system " : bl . doku_system ,
} )
status = get_indexing_status ( )
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
return templates . TemplateResponse ( " v2/screens/methodik.html " , {
#96 Methodik-/Transparenz-Seite unter /methodik
Neue Seite für Endnutzer-Transparenz über die Bewertungsmethodik:
- GWÖ-Matrix 2.0 Erklärung mit interaktivem 5×5-Grid
- Analyse-Pipeline als 5-Schritt-Visualisierung (Download → Embedding
→ LLM → Verifikation → Darstellung)
- Wahlprogramm-Vergleich: Erklärung des Retrieval + Top-K + Verifikation
- Qualitätssicherung: Sub-D Property-Tests, server-seitige Quellen-
Rekonstruktion, automatische Neu-Analyse
- Einschränkungen: KI-Bias, keine juristische Bewertung, nur indexierte
Programme, kein Abstimmungsverhalten
- Datenquellen: dynamische Tabelle aller angebundenen Parlamente aus
ADAPTERS + bundeslaender.py
- Technische Details aufklappbar (details/summary) für Interessierte,
Haupttext verständlich für Nicht-Techniker
- Links zu Quellen-Seite, Adapter-Matrix, ADRs
In Hauptnavigation verlinkt (neben Quellen + Auswertungen).
Template-Variablen: adapter_count, model_name, programme_count,
chunk_count, bundeslaender — alles dynamisch aus dem Backend.
Tests: 194/194 grün.
Refs: #96
2026-04-10 16:14:38 +02:00
" request " : request ,
" app_name " : settings . app_name ,
" adapter_count " : len ( ADAPTERS ) ,
" model_name " : settings . llm_model_default ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
" embedding_model " : settings . embedding_model_read ,
#96 Methodik-/Transparenz-Seite unter /methodik
Neue Seite für Endnutzer-Transparenz über die Bewertungsmethodik:
- GWÖ-Matrix 2.0 Erklärung mit interaktivem 5×5-Grid
- Analyse-Pipeline als 5-Schritt-Visualisierung (Download → Embedding
→ LLM → Verifikation → Darstellung)
- Wahlprogramm-Vergleich: Erklärung des Retrieval + Top-K + Verifikation
- Qualitätssicherung: Sub-D Property-Tests, server-seitige Quellen-
Rekonstruktion, automatische Neu-Analyse
- Einschränkungen: KI-Bias, keine juristische Bewertung, nur indexierte
Programme, kein Abstimmungsverhalten
- Datenquellen: dynamische Tabelle aller angebundenen Parlamente aus
ADAPTERS + bundeslaender.py
- Technische Details aufklappbar (details/summary) für Interessierte,
Haupttext verständlich für Nicht-Techniker
- Links zu Quellen-Seite, Adapter-Matrix, ADRs
In Hauptnavigation verlinkt (neben Quellen + Auswertungen).
Template-Variablen: adapter_count, model_name, programme_count,
chunk_count, bundeslaender — alles dynamisch aus dem Backend.
Tests: 194/194 grün.
Refs: #96
2026-04-10 16:14:38 +02:00
" programme_count " : status . get ( " total " , 0 ) ,
" chunk_count " : sum ( p . get ( " chunks " , 0 ) for p in status . get ( " programmes " , [ ] ) ) ,
" bundeslaender " : sorted ( bl_list , key = lambda x : x [ " name " ] ) ,
2026-04-25 21:50:36 +02:00
* * _v2_template_context ( current_user ) ,
#96 Methodik-/Transparenz-Seite unter /methodik
Neue Seite für Endnutzer-Transparenz über die Bewertungsmethodik:
- GWÖ-Matrix 2.0 Erklärung mit interaktivem 5×5-Grid
- Analyse-Pipeline als 5-Schritt-Visualisierung (Download → Embedding
→ LLM → Verifikation → Darstellung)
- Wahlprogramm-Vergleich: Erklärung des Retrieval + Top-K + Verifikation
- Qualitätssicherung: Sub-D Property-Tests, server-seitige Quellen-
Rekonstruktion, automatische Neu-Analyse
- Einschränkungen: KI-Bias, keine juristische Bewertung, nur indexierte
Programme, kein Abstimmungsverhalten
- Datenquellen: dynamische Tabelle aller angebundenen Parlamente aus
ADAPTERS + bundeslaender.py
- Technische Details aufklappbar (details/summary) für Interessierte,
Haupttext verständlich für Nicht-Techniker
- Links zu Quellen-Seite, Adapter-Matrix, ADRs
In Hauptnavigation verlinkt (neben Quellen + Auswertungen).
Template-Variablen: adapter_count, model_name, programme_count,
chunk_count, bundeslaender — alles dynamisch aus dem Backend.
Tests: 194/194 grün.
Refs: #96
2026-04-10 16:14:38 +02:00
} )
2026-03-28 22:30:24 +01:00
@app.get ( " /quellen " , response_class = HTMLResponse )
2026-04-25 21:50:36 +02:00
async def quellen_page ( request : Request , current_user : Optional [ dict ] = Depends ( get_current_user ) ) :
2026-04-10 19:10:18 +02:00
""" Quellen-Seite mit allen Wahl- und Parteiprogrammen, nach BL gruppiert. """
from . bundeslaender import BUNDESLAENDER
2026-03-28 22:30:24 +01:00
programmes = get_programme_info ( )
status = get_indexing_status ( )
2026-04-10 19:10:18 +02:00
# Wahlprogramme nach Bundesland gruppieren
by_bl : dict [ str , list ] = { }
grundsatz = [ ]
for prog in programmes :
if prog [ " typ " ] == " parteiprogramm " :
grundsatz . append ( prog )
else :
bl = prog . get ( " bundesland " ) or " Sonstige "
bl_name = BUNDESLAENDER [ bl ] . name if bl in BUNDESLAENDER else bl
by_bl . setdefault ( bl_name , [ ] ) . append ( prog )
# Sortieren: alphabetisch nach BL-Name
wahlprogramme_grouped = sorted ( by_bl . items ( ) )
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
return templates . TemplateResponse ( " v2/screens/quellen.html " , {
2026-03-28 22:30:24 +01:00
" request " : request ,
" app_name " : settings . app_name ,
" programmes " : programmes ,
2026-04-10 19:10:18 +02:00
" wahlprogramme_grouped " : wahlprogramme_grouped ,
" grundsatzprogramme " : grundsatz ,
2026-03-28 22:30:24 +01:00
" status " : status ,
2026-04-25 21:50:36 +02:00
* * _v2_template_context ( current_user ) ,
2026-03-28 22:30:24 +01:00
} )
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render
Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF-
Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum
Page-Anchor zu springen und den Leser selbst suchen zu lassen.
Implementation:
embeddings.render_highlighted_page(programm_id, seite, query)
- Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz)
- Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die
angeforderte Seite in einen neuen Document → kleinere Response
- search_for(query[:200]) → Bounding-Boxes aller Treffer
- Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation,
identisch zu find_chunk_for_text/Sub-D-Logik)
- add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0)
- Returns serialisierte PDF-Bytes oder None
embeddings._chunk_pdf_url
- Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL
mit pid=, seite=, q=urlencoded(text[:200])
- Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47
rückwärts-kompatibel)
- text wird auf 200 Zeichen abgeschnitten, sonst blasen
500-Zeichen-Snippets jedes Assessment-JSON auf
main.py /api/wahlprogramm-cite Endpoint
- Validiert pid gegen PROGRAMME registry
- seite: 1 ≤ n ≤ 2000
- Response: application/pdf, Cache-Control max-age=86400
- 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range
Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch:
reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt
bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate
selbst nötig.
Tests: 194/194 grün (185 + 9 neue):
- TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate)
- TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid
render, empty query, query-not-found-falls-back-zu-leerem-Highlight)
- Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine
third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor)
Refs: #47
2026-04-10 01:09:45 +02:00
@app.get ( " /api/wahlprogramm-cite " )
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
async def wahlprogramm_cite (
request : Request ,
background_tasks : BackgroundTasks ,
pid : str = " " , pdf : str = " " , seite : int = 1 , q : str = " " ,
ds : str = " " , bl : str = " " ,
) :
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render
Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF-
Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum
Page-Anchor zu springen und den Leser selbst suchen zu lassen.
Implementation:
embeddings.render_highlighted_page(programm_id, seite, query)
- Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz)
- Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die
angeforderte Seite in einen neuen Document → kleinere Response
- search_for(query[:200]) → Bounding-Boxes aller Treffer
- Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation,
identisch zu find_chunk_for_text/Sub-D-Logik)
- add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0)
- Returns serialisierte PDF-Bytes oder None
embeddings._chunk_pdf_url
- Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL
mit pid=, seite=, q=urlencoded(text[:200])
- Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47
rückwärts-kompatibel)
- text wird auf 200 Zeichen abgeschnitten, sonst blasen
500-Zeichen-Snippets jedes Assessment-JSON auf
main.py /api/wahlprogramm-cite Endpoint
- Validiert pid gegen PROGRAMME registry
- seite: 1 ≤ n ≤ 2000
- Response: application/pdf, Cache-Control max-age=86400
- 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range
Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch:
reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt
bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate
selbst nötig.
Tests: 194/194 grün (185 + 9 neue):
- TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate)
- TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid
render, empty query, query-not-found-falls-back-zu-leerem-Highlight)
- Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine
third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor)
Refs: #47
2026-04-10 01:09:45 +02:00
""" Render eine Wahlprogramm-Seite mit gelb hervorgehobener Zitat-Stelle.
Issue #47: Klick auf eine Zitat-Quelle im Report soll direkt zur
Stelle im Wahlprogramm - PDF springen , mit dem zitierten Snippet
visuell markiert . Statt das ganze PDF auszuliefern ( Browser scrollt
auf #page=N und Leser muss von Hand suchen), liefern wir hier ein
1 - Seiten - PDF mit ` ` add_highlight_annot ` ` - Annotation auf den per
` ` page . search_for ` ` gefundenen Bounding - Boxes .
2026-04-10 09:57:58 +02:00
Akzeptiert ` ` pid ` ` ( PROGRAMME - Key ) ODER ` ` pdf ` ` ( Dateiname wie
` ` spd - grundsatzprogramm . pdf ` ` ) . Letzterer ermöglicht die retroaktive
Nutzung von Pre - #47-URLs im Frontend, wo nur der statische Pfad
` ` / static / referenzen / < pdf > #page=<N>`` gespeichert ist.
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render
Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF-
Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum
Page-Anchor zu springen und den Leser selbst suchen zu lassen.
Implementation:
embeddings.render_highlighted_page(programm_id, seite, query)
- Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz)
- Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die
angeforderte Seite in einen neuen Document → kleinere Response
- search_for(query[:200]) → Bounding-Boxes aller Treffer
- Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation,
identisch zu find_chunk_for_text/Sub-D-Logik)
- add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0)
- Returns serialisierte PDF-Bytes oder None
embeddings._chunk_pdf_url
- Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL
mit pid=, seite=, q=urlencoded(text[:200])
- Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47
rückwärts-kompatibel)
- text wird auf 200 Zeichen abgeschnitten, sonst blasen
500-Zeichen-Snippets jedes Assessment-JSON auf
main.py /api/wahlprogramm-cite Endpoint
- Validiert pid gegen PROGRAMME registry
- seite: 1 ≤ n ≤ 2000
- Response: application/pdf, Cache-Control max-age=86400
- 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range
Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch:
reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt
bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate
selbst nötig.
Tests: 194/194 grün (185 + 9 neue):
- TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate)
- TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid
render, empty query, query-not-found-falls-back-zu-leerem-Highlight)
- Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine
third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor)
Refs: #47
2026-04-10 01:09:45 +02:00
Security : ` ` pid ` ` muss ein registrierter PROGRAMME - Key sein —
verhindert Path - Traversal und arbiträren File - Read aus dem
referenzen - Verzeichnis . ` ` seite ` ` wird per Pydantic - Coercion
auf int gezwungen . ` ` q ` ` ist auf 200 Zeichen begrenzt im Renderer .
"""
2026-04-10 09:57:58 +02:00
# Reverse-Lookup: pdf-Filename → programm_id, falls nur pdf angegeben.
2026-04-10 10:08:02 +02:00
# Zwei Stufen: exakter Match, dann fuzzy (Year-Suffix-Stripping), weil
# Pre-#47 Assessments halluzinierte Dateinamen haben können, z.B.
# "gruene-grundsatzprogramm-2020.pdf" statt "gruene-grundsatzprogramm.pdf".
2026-04-10 09:57:58 +02:00
if not pid and pdf :
2026-04-10 10:08:02 +02:00
# Stage 1: exakt
2026-04-10 09:57:58 +02:00
for p , info in PROGRAMME . items ( ) :
if info . get ( " pdf " ) == pdf :
pid = p
break
2026-04-10 10:08:02 +02:00
# Stage 2: Year-Suffix stripping (z.B. "X-2020.pdf" → "X.pdf")
if not pid :
import re
stripped = re . sub ( r " - \ d {4} \ .pdf$ " , " .pdf " , pdf )
if stripped != pdf :
for p , info in PROGRAMME . items ( ) :
if info . get ( " pdf " ) == stripped :
pid = p
break
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render
Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF-
Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum
Page-Anchor zu springen und den Leser selbst suchen zu lassen.
Implementation:
embeddings.render_highlighted_page(programm_id, seite, query)
- Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz)
- Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die
angeforderte Seite in einen neuen Document → kleinere Response
- search_for(query[:200]) → Bounding-Boxes aller Treffer
- Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation,
identisch zu find_chunk_for_text/Sub-D-Logik)
- add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0)
- Returns serialisierte PDF-Bytes oder None
embeddings._chunk_pdf_url
- Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL
mit pid=, seite=, q=urlencoded(text[:200])
- Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47
rückwärts-kompatibel)
- text wird auf 200 Zeichen abgeschnitten, sonst blasen
500-Zeichen-Snippets jedes Assessment-JSON auf
main.py /api/wahlprogramm-cite Endpoint
- Validiert pid gegen PROGRAMME registry
- seite: 1 ≤ n ≤ 2000
- Response: application/pdf, Cache-Control max-age=86400
- 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range
Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch:
reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt
bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate
selbst nötig.
Tests: 194/194 grün (185 + 9 neue):
- TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate)
- TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid
render, empty query, query-not-found-falls-back-zu-leerem-Highlight)
- Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine
third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor)
Refs: #47
2026-04-10 01:09:45 +02:00
if pid not in PROGRAMME :
raise HTTPException ( status_code = 404 , detail = " Unbekanntes Wahlprogramm " )
if seite < 1 or seite > 2000 :
raise HTTPException ( status_code = 400 , detail = " Ungültige Seitennummer " )
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
pdf_bytes , found_page , highlighted = render_highlighted_page ( pid , seite , q )
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render
Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF-
Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum
Page-Anchor zu springen und den Leser selbst suchen zu lassen.
Implementation:
embeddings.render_highlighted_page(programm_id, seite, query)
- Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz)
- Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die
angeforderte Seite in einen neuen Document → kleinere Response
- search_for(query[:200]) → Bounding-Boxes aller Treffer
- Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation,
identisch zu find_chunk_for_text/Sub-D-Logik)
- add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0)
- Returns serialisierte PDF-Bytes oder None
embeddings._chunk_pdf_url
- Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL
mit pid=, seite=, q=urlencoded(text[:200])
- Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47
rückwärts-kompatibel)
- text wird auf 200 Zeichen abgeschnitten, sonst blasen
500-Zeichen-Snippets jedes Assessment-JSON auf
main.py /api/wahlprogramm-cite Endpoint
- Validiert pid gegen PROGRAMME registry
- seite: 1 ≤ n ≤ 2000
- Response: application/pdf, Cache-Control max-age=86400
- 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range
Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch:
reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt
bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate
selbst nötig.
Tests: 194/194 grün (185 + 9 neue):
- TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate)
- TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid
render, empty query, query-not-found-falls-back-zu-leerem-Highlight)
- Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine
third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor)
Refs: #47
2026-04-10 01:09:45 +02:00
if pdf_bytes is None :
raise HTTPException (
status_code = 404 ,
detail = " Wahlprogramm-PDF oder Seite nicht verfügbar " ,
)
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
# Issue #47: Wenn das Zitat nicht im PDF auffindbar ist UND wir die
# Drucksache kennen, ist das Assessment wahrscheinlich ein Pre-#60-
# Halluzinations-Opfer. Automatische Re-Analyse triggern und dem
# User eine Warte-Seite zeigen statt ein PDF ohne Highlights.
if not highlighted and q and ds and bl :
existing = await get_assessment ( ds )
if existing :
adapter = get_adapter ( bl )
if adapter :
# Altes Assessment löschen und neu analysieren
await delete_assessment ( ds )
job_id = str ( uuid . uuid4 ( ) )
await create_job ( job_id , f " Re-Analyse { ds } (Zitat nicht verifizierbar) " , bl , " qwen-plus " )
text = await adapter . download_text ( ds )
if text :
doc = await adapter . get_document ( ds )
background_tasks . add_task (
run_drucksache_analysis ,
job_id , ds , text , bl , " qwen-plus " , doc ,
)
# HTML-Warte-Seite mit Auto-Redirect zurück zum Assessment
return HTMLResponse ( f """ <!DOCTYPE html>
< html > < head > < meta charset = " utf-8 " >
< meta http - equiv = " refresh " content = " 15;url=/#assessment= {ds} " >
< title > Wird neu analysiert … < / title >
< style > body { { font - family : sans - serif ; display : flex ; justify - content : center ; align - items : center ; height : 100 vh ; margin : 0 ; background : #f5f5f5}}
. box { { text - align : center ; padding : 2 rem ; background : #fff;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,.1)}}
. spinner { { width : 40 px ; height : 40 px ; border : 4 px solid #ddd;border-top:4px solid #009da5;border-radius:50%;animation:spin 1s linear infinite;margin:1rem auto}}
@keyframes spin { { to { { transform : rotate ( 360 deg ) } } } } < / style > < / head >
< body > < div class = " box " >
< div class = " spinner " > < / div >
< h2 > Zitat nicht verifizierbar < / h2 >
< p > Der Antrag < strong > { ds } < / strong > wird mit der aktuellen Pipeline < br >
neu analysiert , um verifizierte Zitate zu erzeugen . < / p >
< p style = " color:#666;font-size:0.9rem " > Automatische Weiterleitung in 15 Sekunden … < / p >
< / div > < / body > < / html > """ )
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render
Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF-
Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum
Page-Anchor zu springen und den Leser selbst suchen zu lassen.
Implementation:
embeddings.render_highlighted_page(programm_id, seite, query)
- Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz)
- Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die
angeforderte Seite in einen neuen Document → kleinere Response
- search_for(query[:200]) → Bounding-Boxes aller Treffer
- Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation,
identisch zu find_chunk_for_text/Sub-D-Logik)
- add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0)
- Returns serialisierte PDF-Bytes oder None
embeddings._chunk_pdf_url
- Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL
mit pid=, seite=, q=urlencoded(text[:200])
- Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47
rückwärts-kompatibel)
- text wird auf 200 Zeichen abgeschnitten, sonst blasen
500-Zeichen-Snippets jedes Assessment-JSON auf
main.py /api/wahlprogramm-cite Endpoint
- Validiert pid gegen PROGRAMME registry
- seite: 1 ≤ n ≤ 2000
- Response: application/pdf, Cache-Control max-age=86400
- 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range
Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch:
reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt
bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate
selbst nötig.
Tests: 194/194 grün (185 + 9 neue):
- TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate)
- TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid
render, empty query, query-not-found-falls-back-zu-leerem-Highlight)
- Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine
third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor)
Refs: #47
2026-04-10 01:09:45 +02:00
info = PROGRAMME [ pid ]
safe_name = info . get ( " pdf " , f " { pid } .pdf " )
return Response (
content = pdf_bytes ,
media_type = " application/pdf " ,
headers = {
" Content-Disposition " : f ' inline; filename= " { safe_name } " ' ,
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
" Cache-Control " : " public, max-age=3600 " ,
2026-04-10 10:16:00 +02:00
" X-Found-Page " : str ( found_page ) ,
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render
Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF-
Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum
Page-Anchor zu springen und den Leser selbst suchen zu lassen.
Implementation:
embeddings.render_highlighted_page(programm_id, seite, query)
- Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz)
- Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die
angeforderte Seite in einen neuen Document → kleinere Response
- search_for(query[:200]) → Bounding-Boxes aller Treffer
- Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation,
identisch zu find_chunk_for_text/Sub-D-Logik)
- add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0)
- Returns serialisierte PDF-Bytes oder None
embeddings._chunk_pdf_url
- Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL
mit pid=, seite=, q=urlencoded(text[:200])
- Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47
rückwärts-kompatibel)
- text wird auf 200 Zeichen abgeschnitten, sonst blasen
500-Zeichen-Snippets jedes Assessment-JSON auf
main.py /api/wahlprogramm-cite Endpoint
- Validiert pid gegen PROGRAMME registry
- seite: 1 ≤ n ≤ 2000
- Response: application/pdf, Cache-Control max-age=86400
- 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range
Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch:
reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt
bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate
selbst nötig.
Tests: 194/194 grün (185 + 9 neue):
- TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate)
- TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid
render, empty query, query-not-found-falls-back-zu-leerem-Highlight)
- Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine
third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor)
Refs: #47
2026-04-10 01:09:45 +02:00
} ,
)
2026-04-10 18:40:13 +02:00
@app.get ( " /api/programme/thumbnail/ {programm_id} " )
async def programme_thumbnail ( programm_id : str ) :
""" Thumbnail der ersten Seite eines Wahlprogramm-PDFs (PNG, 200px breit).
Wird auf der Quellen - Seite als Vorschau angezeigt . Cached 24 h .
"""
import fitz
if programm_id not in PROGRAMME :
raise HTTPException ( status_code = 404 )
info = PROGRAMME [ programm_id ]
pdf_path = static_dir / " referenzen " / info [ " pdf " ]
if not pdf_path . exists ( ) :
raise HTTPException ( status_code = 404 )
try :
doc = fitz . open ( str ( pdf_path ) )
page = doc [ 0 ]
# 200px Breite, proportional skaliert
zoom = 200 / page . rect . width
mat = fitz . Matrix ( zoom , zoom )
pix = page . get_pixmap ( matrix = mat )
png_bytes = pix . tobytes ( " png " )
doc . close ( )
return Response (
content = png_bytes ,
media_type = " image/png " ,
headers = { " Cache-Control " : " public, max-age=86400 " } ,
)
except Exception :
raise HTTPException ( status_code = 500 )
2026-03-28 22:30:24 +01:00
@app.get ( " /api/programme " )
async def list_programme ( ) :
""" List all available programmes. """
return get_programme_info ( )
@app.get ( " /api/programme/status " )
async def programme_status ( ) :
""" Get indexing status of all programmes. """
return get_indexing_status ( )
@app.post ( " /api/programme/index " )
Security hotfixes #1, #2, #6 from audit (#57)
Drei akute Befunde aus dem Live-System-Audit (Issue #57):
- **#1 HIGH** — Resource Exhaustion via öffentlichem POST: slowapi
Limiter (in-memory, IP-key) auf /analyze (10/min), /api/analyze-drucksache
(10/min) und /api/programme/index (3/min). Verhindert, dass ein
unauthentifizierter Client mit einer Schleife die DashScope-Quota oder
die CPU des Containers leerziehen kann. Default-Storage reicht solange
wir auf einem einzigen Worker laufen.
- **#2 MEDIUM** + **#6 MEDIUM** (selber Root-Cause) — XXE/Local-File-Read
via WeasyPrint und Stored XSS via Browser-Rendering: alle LLM-getragenen
Felder in app/report.py laufen jetzt durch html.escape() bevor sie in
die HTML-Template interpoliert werden. format_redline_html escape-first
und ersetzt dann die Markdown-Marker durch von uns kontrollierte
<span>-Tags. build_matrix_html escaped das aspect-Attribut, sodass ein
nacktes " den title="..."-Wert nicht mehr beenden und einen Event-
Handler injizieren kann. Toter jinja2-Import in report.py entfernt
(war never used, blockierte nur den lokalen Test).
- **Tests** — neue tests/test_report.py mit 8 Cases, die direkt die
Bug-Klasse verifizieren: <script>, file://-img, "-attribut-breakout
in Title und ein End-to-End-Render mit XSS-Payloads in jedem LLM-Feld.
Die Marker-Funktionalität (** und ~~) wird mit-getestet, damit der
Escape-First-Ansatz das nicht versehentlich kaputt macht.
77 alte Unit-Tests + 8 neue → 85 grün.
Rate-Limit-Verifikation per TestClient ist Integration-Scope und folgt
in tests/integration/test_main_security.py als separates Folge-Item.
Refs: #57
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:45:43 +02:00
@limiter.limit ( " 3/minute " )
2026-03-28 22:30:24 +01:00
async def index_programme (
Security hotfixes #1, #2, #6 from audit (#57)
Drei akute Befunde aus dem Live-System-Audit (Issue #57):
- **#1 HIGH** — Resource Exhaustion via öffentlichem POST: slowapi
Limiter (in-memory, IP-key) auf /analyze (10/min), /api/analyze-drucksache
(10/min) und /api/programme/index (3/min). Verhindert, dass ein
unauthentifizierter Client mit einer Schleife die DashScope-Quota oder
die CPU des Containers leerziehen kann. Default-Storage reicht solange
wir auf einem einzigen Worker laufen.
- **#2 MEDIUM** + **#6 MEDIUM** (selber Root-Cause) — XXE/Local-File-Read
via WeasyPrint und Stored XSS via Browser-Rendering: alle LLM-getragenen
Felder in app/report.py laufen jetzt durch html.escape() bevor sie in
die HTML-Template interpoliert werden. format_redline_html escape-first
und ersetzt dann die Markdown-Marker durch von uns kontrollierte
<span>-Tags. build_matrix_html escaped das aspect-Attribut, sodass ein
nacktes " den title="..."-Wert nicht mehr beenden und einen Event-
Handler injizieren kann. Toter jinja2-Import in report.py entfernt
(war never used, blockierte nur den lokalen Test).
- **Tests** — neue tests/test_report.py mit 8 Cases, die direkt die
Bug-Klasse verifizieren: <script>, file://-img, "-attribut-breakout
in Title und ein End-to-End-Render mit XSS-Payloads in jedem LLM-Feld.
Die Marker-Funktionalität (** und ~~) wird mit-getestet, damit der
Escape-First-Ansatz das nicht versehentlich kaputt macht.
77 alte Unit-Tests + 8 neue → 85 grün.
Rate-Limit-Verifikation per TestClient ist Integration-Scope und folgt
in tests/integration/test_main_security.py als separates Folge-Item.
Refs: #57
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:45:43 +02:00
request : Request ,
2026-03-28 22:30:24 +01:00
background_tasks : BackgroundTasks ,
programm_id : str = Form ( None ) ,
all_programmes : bool = Form ( False ) ,
2026-04-10 23:15:42 +02:00
user : dict = Depends ( require_admin ) ,
2026-03-28 22:30:24 +01:00
) :
""" Index programme(s) for semantic search. """
pdf_dir = static_dir / " referenzen "
if all_programmes :
# Index sequentially to avoid DB locks
async def index_all_sequential ( ) :
for prog_id in PROGRAMME . keys ( ) :
try :
index_programm ( prog_id , pdf_dir )
Phase A: Audit-Restbefunde #57.3/4/7 (Roadmap #59)
Drei verbleibende Audit-Befunde aus #57 in einem Patch:
- **#57.3 MEDIUM** Drucksache-Regex-Validation: neue
app/validators.py mit validate_drucksache() als gemeinsamer
Validation-Funnel. Pattern ^\d{1,3}/\d{1,7}([-(].{1,20})?$ deckt
alle 10 aktiven Bundesländer (8/6390, 18/12345, 8/6390(neu),
23/3700-A) ab und blockt Path-Traversal (../, /etc/passwd) plus
Standard-Injection (;, <, &). Drei Endpoints durchgeschleust:
/api/assessment, /api/assessment/pdf, /api/analyze-drucksache.
- **#57.4 MEDIUM** print() → logging.getLogger(__name__): main.py
und analyzer.py auf strukturiertes Logging umgestellt. LLM-Inhalte
werden NICHT mehr als Volltext geloggt — neue Helper
_content_fingerprint() liefert nur "len=N sha1=XXXX", reicht zur
Forensik ohne Antrag-Inhalte ins Container-Log zu leaken.
basicConfig() mit ISO-Format setzt strukturiertes Logging früh,
damit logger.exception() auch beim Boot greift.
- **#57.7 LOW-MED** Search-Query-Limit: validate_search_query() mit
MAX_SEARCH_QUERY_LEN=200 schützt /api/search und /api/search-landtag
vor 10-MB-Query-DoS. database._parse_search_query() loggt jetzt
shlex.ValueError-Fallback statt ihn zu verschlucken (deckt Memory-
Regel "stille excepts in Adaptern" ab).
Tests: neue tests/test_main_validators.py mit 22 Cases — Drucksache-
Whitelist-Roundtrip + Path-Traversal-Reject, Search-Query Längen-
Edge-Cases. 107 Unit-Tests grün (85 alt + 22 neu).
Validators in eigenem Modul (app/validators.py), damit Tests sie ohne
slowapi-Dependency direkt importieren können.
Refs: #57, #59 (Phase A)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:15:16 +02:00
except Exception :
logger . exception ( " Error indexing programme %s " , prog_id )
2026-03-28 22:30:24 +01:00
background_tasks . add_task ( index_all_sequential )
return { " status " : " indexing " , " programmes " : list ( PROGRAMME . keys ( ) ) }
if programm_id and programm_id in PROGRAMME :
background_tasks . add_task ( index_programm , programm_id , pdf_dir )
return { " status " : " indexing " , " programm_id " : programm_id }
raise HTTPException ( status_code = 400 , detail = " Ungültiges Programm " )
Phase C: Auswertungen-Dashboard #58 + CSV-Export #45 (Roadmap #59)
Drei-dimensionale Aggregations-Sicht über Bundesland × Partei ×
Wahlperiode mit minimalem Frontend.
Backend (`app/auswertungen.py`):
- `aggregate_matrix(filter_wp=None)` — 2D-Matrix Bundesland × Partei mit
(n, Ø-Score) pro Zelle, optional gefiltert nach Wahlperiode
- `aggregate_zeitreihe(bundesland, partei)` — Score-Verlauf einer
(BL, Partei)-Kombination über alle bekannten WPs
- `export_long_format()` — Long-Format-CSV-Export für externe Tools
(deckt #45 vollständig ab)
- Partei-Auflösung läuft strikt durch `normalize_partei()` aus #55 —
damit wird BB-`FREIE WÄHLER` korrekt als `BVB-FW` aggregiert und
NICHT mit dem RP-FW zusammengezählt
Wahlperioden-Helper (`app/wahlperioden.py`):
- `wahlperiode_for(datum, bundesland)` mappt ein ISO-Datum + BL auf eine
Kennung wie `"NRW-WP18"` oder `"MV-WP7"` (Vorgänger-WP). Single Source
of Truth ist `BUNDESLAENDER[bl].wahlperiode_start`
- `all_wahlperioden()` für UI-Filter-Dropdowns
Endpoints in `app/main.py`:
- `GET /auswertungen` — HTML-Seite (neues Template)
- `GET /api/auswertungen/matrix?wahlperiode=NRW-WP18` — JSON-Matrix
- `GET /api/auswertungen/zeitreihe?bundesland=MV&partei=CDU` — JSON-Verlauf
- `GET /api/auswertungen/export.csv` — CSV-Download
Frontend (`app/templates/auswertungen.html`):
- Statisches Template mit Vanilla-JS, kein Build-Step
- Wahlperioden-Dropdown + Reload-Button + CSV-Export-Button
- Matrix-Tabelle mit Score-Color-Coding (rot ≤ 3, gelb 3-6, grün > 6)
- Sticky-Bundesland-Spalte für horizontales Scrolling
Tests (`tests/test_auswertungen.py`):
- 19 Cases mit in-memory SQLite-Fixture
- Verifiziert WP-Mapping, Matrix-Aggregation, Koalitions-Counting,
WP-Filter-Korrektheit, BVB-FW-Disambiguierung in der Matrix,
CSV-Long-Format
- 176 Unit-Tests grün (157 alt + 19 neu)
Refs: #58, #45, #59 (Phase C)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:25:57 +02:00
# ─────────────────────────────────────────────────────────────────────────────
# Auswertungen #58 — Bundesland × Partei × Wahlperiode Aggregations-Sicht
# ─────────────────────────────────────────────────────────────────────────────
@app.get ( " /auswertungen " , response_class = HTMLResponse )
fix(v2): Topbar-Höhe runter, Share-Felder erweitert (Kopieren/LinkedIn/Email/Bild), Smoke-Test 401-Pattern
- Topbar padding 10px -> 4px, min-height 32px (User: 'Header weniger hoch')
- Share-Buttons im Antragsdetail erweitert auf 7 Plattformen analog v1:
Kopieren (Clipboard), Threads, X, Mastodon, LinkedIn, E-Mail (mailto), Bild (Freepik)
- v2DetailShareCopy/Email/Image-Helper, ANTRAG_TOPICS ans Template uebergeben
- Smoke-Test akzeptiert 401 fuer auth-protected Routen (curl ohne Accept-Header
bekommt 401-JSON, echte Browser bekommen 302-Redirect via _auth_redirect_handler)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:57:04 +02:00
async def auswertungen_page ( request : Request , current_user : dict = Depends ( require_auth ) ) :
""" Auswertungs-Dashboard in v2 (Phase 3 Migration aus Classic). Auth-only. """
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
from . auswertungen import get_wahlperioden
from . bundeslaender import alle_bundeslaender
wahlperioden = get_wahlperioden ( )
bl_codes = sorted ( bl . code for bl in alle_bundeslaender ( ) if bl . aktiv )
return templates . TemplateResponse ( " v2/screens/auswertungen.html " , {
Phase C: Auswertungen-Dashboard #58 + CSV-Export #45 (Roadmap #59)
Drei-dimensionale Aggregations-Sicht über Bundesland × Partei ×
Wahlperiode mit minimalem Frontend.
Backend (`app/auswertungen.py`):
- `aggregate_matrix(filter_wp=None)` — 2D-Matrix Bundesland × Partei mit
(n, Ø-Score) pro Zelle, optional gefiltert nach Wahlperiode
- `aggregate_zeitreihe(bundesland, partei)` — Score-Verlauf einer
(BL, Partei)-Kombination über alle bekannten WPs
- `export_long_format()` — Long-Format-CSV-Export für externe Tools
(deckt #45 vollständig ab)
- Partei-Auflösung läuft strikt durch `normalize_partei()` aus #55 —
damit wird BB-`FREIE WÄHLER` korrekt als `BVB-FW` aggregiert und
NICHT mit dem RP-FW zusammengezählt
Wahlperioden-Helper (`app/wahlperioden.py`):
- `wahlperiode_for(datum, bundesland)` mappt ein ISO-Datum + BL auf eine
Kennung wie `"NRW-WP18"` oder `"MV-WP7"` (Vorgänger-WP). Single Source
of Truth ist `BUNDESLAENDER[bl].wahlperiode_start`
- `all_wahlperioden()` für UI-Filter-Dropdowns
Endpoints in `app/main.py`:
- `GET /auswertungen` — HTML-Seite (neues Template)
- `GET /api/auswertungen/matrix?wahlperiode=NRW-WP18` — JSON-Matrix
- `GET /api/auswertungen/zeitreihe?bundesland=MV&partei=CDU` — JSON-Verlauf
- `GET /api/auswertungen/export.csv` — CSV-Download
Frontend (`app/templates/auswertungen.html`):
- Statisches Template mit Vanilla-JS, kein Build-Step
- Wahlperioden-Dropdown + Reload-Button + CSV-Export-Button
- Matrix-Tabelle mit Score-Color-Coding (rot ≤ 3, gelb 3-6, grün > 6)
- Sticky-Bundesland-Spalte für horizontales Scrolling
Tests (`tests/test_auswertungen.py`):
- 19 Cases mit in-memory SQLite-Fixture
- Verifiziert WP-Mapping, Matrix-Aggregation, Koalitions-Counting,
WP-Filter-Korrektheit, BVB-FW-Disambiguierung in der Matrix,
CSV-Long-Format
- 176 Unit-Tests grün (157 alt + 19 neu)
Refs: #58, #45, #59 (Phase C)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:25:57 +02:00
" request " : request ,
" app_name " : settings . app_name ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
" v2_active_nav " : " auswertungen " ,
" wahlperioden " : wahlperioden ,
" bl_codes " : bl_codes ,
2026-04-25 21:50:36 +02:00
* * _v2_template_context ( current_user ) ,
Phase C: Auswertungen-Dashboard #58 + CSV-Export #45 (Roadmap #59)
Drei-dimensionale Aggregations-Sicht über Bundesland × Partei ×
Wahlperiode mit minimalem Frontend.
Backend (`app/auswertungen.py`):
- `aggregate_matrix(filter_wp=None)` — 2D-Matrix Bundesland × Partei mit
(n, Ø-Score) pro Zelle, optional gefiltert nach Wahlperiode
- `aggregate_zeitreihe(bundesland, partei)` — Score-Verlauf einer
(BL, Partei)-Kombination über alle bekannten WPs
- `export_long_format()` — Long-Format-CSV-Export für externe Tools
(deckt #45 vollständig ab)
- Partei-Auflösung läuft strikt durch `normalize_partei()` aus #55 —
damit wird BB-`FREIE WÄHLER` korrekt als `BVB-FW` aggregiert und
NICHT mit dem RP-FW zusammengezählt
Wahlperioden-Helper (`app/wahlperioden.py`):
- `wahlperiode_for(datum, bundesland)` mappt ein ISO-Datum + BL auf eine
Kennung wie `"NRW-WP18"` oder `"MV-WP7"` (Vorgänger-WP). Single Source
of Truth ist `BUNDESLAENDER[bl].wahlperiode_start`
- `all_wahlperioden()` für UI-Filter-Dropdowns
Endpoints in `app/main.py`:
- `GET /auswertungen` — HTML-Seite (neues Template)
- `GET /api/auswertungen/matrix?wahlperiode=NRW-WP18` — JSON-Matrix
- `GET /api/auswertungen/zeitreihe?bundesland=MV&partei=CDU` — JSON-Verlauf
- `GET /api/auswertungen/export.csv` — CSV-Download
Frontend (`app/templates/auswertungen.html`):
- Statisches Template mit Vanilla-JS, kein Build-Step
- Wahlperioden-Dropdown + Reload-Button + CSV-Export-Button
- Matrix-Tabelle mit Score-Color-Coding (rot ≤ 3, gelb 3-6, grün > 6)
- Sticky-Bundesland-Spalte für horizontales Scrolling
Tests (`tests/test_auswertungen.py`):
- 19 Cases mit in-memory SQLite-Fixture
- Verifiziert WP-Mapping, Matrix-Aggregation, Koalitions-Counting,
WP-Filter-Korrektheit, BVB-FW-Disambiguierung in der Matrix,
CSV-Long-Format
- 176 Unit-Tests grün (157 alt + 19 neu)
Refs: #58, #45, #59 (Phase C)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:25:57 +02:00
} )
@app.get ( " /api/auswertungen/matrix " )
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
async def auswertungen_matrix (
wahlperiode : Optional [ str ] = None ,
bundesland : Optional [ str ] = None ,
) :
Phase C: Auswertungen-Dashboard #58 + CSV-Export #45 (Roadmap #59)
Drei-dimensionale Aggregations-Sicht über Bundesland × Partei ×
Wahlperiode mit minimalem Frontend.
Backend (`app/auswertungen.py`):
- `aggregate_matrix(filter_wp=None)` — 2D-Matrix Bundesland × Partei mit
(n, Ø-Score) pro Zelle, optional gefiltert nach Wahlperiode
- `aggregate_zeitreihe(bundesland, partei)` — Score-Verlauf einer
(BL, Partei)-Kombination über alle bekannten WPs
- `export_long_format()` — Long-Format-CSV-Export für externe Tools
(deckt #45 vollständig ab)
- Partei-Auflösung läuft strikt durch `normalize_partei()` aus #55 —
damit wird BB-`FREIE WÄHLER` korrekt als `BVB-FW` aggregiert und
NICHT mit dem RP-FW zusammengezählt
Wahlperioden-Helper (`app/wahlperioden.py`):
- `wahlperiode_for(datum, bundesland)` mappt ein ISO-Datum + BL auf eine
Kennung wie `"NRW-WP18"` oder `"MV-WP7"` (Vorgänger-WP). Single Source
of Truth ist `BUNDESLAENDER[bl].wahlperiode_start`
- `all_wahlperioden()` für UI-Filter-Dropdowns
Endpoints in `app/main.py`:
- `GET /auswertungen` — HTML-Seite (neues Template)
- `GET /api/auswertungen/matrix?wahlperiode=NRW-WP18` — JSON-Matrix
- `GET /api/auswertungen/zeitreihe?bundesland=MV&partei=CDU` — JSON-Verlauf
- `GET /api/auswertungen/export.csv` — CSV-Download
Frontend (`app/templates/auswertungen.html`):
- Statisches Template mit Vanilla-JS, kein Build-Step
- Wahlperioden-Dropdown + Reload-Button + CSV-Export-Button
- Matrix-Tabelle mit Score-Color-Coding (rot ≤ 3, gelb 3-6, grün > 6)
- Sticky-Bundesland-Spalte für horizontales Scrolling
Tests (`tests/test_auswertungen.py`):
- 19 Cases mit in-memory SQLite-Fixture
- Verifiziert WP-Mapping, Matrix-Aggregation, Koalitions-Counting,
WP-Filter-Korrektheit, BVB-FW-Disambiguierung in der Matrix,
CSV-Long-Format
- 176 Unit-Tests grün (157 alt + 19 neu)
Refs: #58, #45, #59 (Phase C)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:25:57 +02:00
""" 2D-Matrix Bundesland × Partei mit Anzahl + Ø-GWÖ-Score. """
from . auswertungen import aggregate_matrix
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
return aggregate_matrix ( filter_wp = wahlperiode , filter_bl = bundesland )
Phase C: Auswertungen-Dashboard #58 + CSV-Export #45 (Roadmap #59)
Drei-dimensionale Aggregations-Sicht über Bundesland × Partei ×
Wahlperiode mit minimalem Frontend.
Backend (`app/auswertungen.py`):
- `aggregate_matrix(filter_wp=None)` — 2D-Matrix Bundesland × Partei mit
(n, Ø-Score) pro Zelle, optional gefiltert nach Wahlperiode
- `aggregate_zeitreihe(bundesland, partei)` — Score-Verlauf einer
(BL, Partei)-Kombination über alle bekannten WPs
- `export_long_format()` — Long-Format-CSV-Export für externe Tools
(deckt #45 vollständig ab)
- Partei-Auflösung läuft strikt durch `normalize_partei()` aus #55 —
damit wird BB-`FREIE WÄHLER` korrekt als `BVB-FW` aggregiert und
NICHT mit dem RP-FW zusammengezählt
Wahlperioden-Helper (`app/wahlperioden.py`):
- `wahlperiode_for(datum, bundesland)` mappt ein ISO-Datum + BL auf eine
Kennung wie `"NRW-WP18"` oder `"MV-WP7"` (Vorgänger-WP). Single Source
of Truth ist `BUNDESLAENDER[bl].wahlperiode_start`
- `all_wahlperioden()` für UI-Filter-Dropdowns
Endpoints in `app/main.py`:
- `GET /auswertungen` — HTML-Seite (neues Template)
- `GET /api/auswertungen/matrix?wahlperiode=NRW-WP18` — JSON-Matrix
- `GET /api/auswertungen/zeitreihe?bundesland=MV&partei=CDU` — JSON-Verlauf
- `GET /api/auswertungen/export.csv` — CSV-Download
Frontend (`app/templates/auswertungen.html`):
- Statisches Template mit Vanilla-JS, kein Build-Step
- Wahlperioden-Dropdown + Reload-Button + CSV-Export-Button
- Matrix-Tabelle mit Score-Color-Coding (rot ≤ 3, gelb 3-6, grün > 6)
- Sticky-Bundesland-Spalte für horizontales Scrolling
Tests (`tests/test_auswertungen.py`):
- 19 Cases mit in-memory SQLite-Fixture
- Verifiziert WP-Mapping, Matrix-Aggregation, Koalitions-Counting,
WP-Filter-Korrektheit, BVB-FW-Disambiguierung in der Matrix,
CSV-Long-Format
- 176 Unit-Tests grün (157 alt + 19 neu)
Refs: #58, #45, #59 (Phase C)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:25:57 +02:00
@app.get ( " /api/auswertungen/zeitreihe " )
async def auswertungen_zeitreihe ( bundesland : str , partei : str ) :
""" Score-Verlauf einer (BL, Partei)-Kombination über alle WPs. """
from . auswertungen import aggregate_zeitreihe
return aggregate_zeitreihe ( bundesland , partei )
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
@app.get ( " /api/auswertungen/themen-matrix " )
async def auswertungen_themen_matrix ( min_count : int = 3 , bundesland : Optional [ str ] = None ) :
""" Thema × Fraktion Heatmap (#105 Integration in Auswertungen).
Zeigt die Ø - GWÖ - Scores pro Thema und Fraktion . Nur Themen mit
mindestens ` min_count ` Assessments werden angezeigt . Optional auf
ein Bundesland einschränken .
"""
import json as _json
from collections import Counter , defaultdict
from . parteien import normalize_partei
rows = await get_all_assessments ( None )
heatmap : dict [ str , dict [ str , list ] ] = defaultdict ( lambda : defaultdict ( list ) )
thema_counts : Counter = Counter ( )
for row in rows :
if bundesland is not None and row . get ( " bundesland " ) != bundesland :
continue
fraktionen = row . get ( " fraktionen " ) or [ ]
themen = row . get ( " themen " ) or [ ]
score = row . get ( " gwoe_score " )
if score is None :
continue
for thema in themen [ : 3 ] :
thema_counts [ thema ] + = 1
for frak in fraktionen :
# Normalisiere Fraktion (AfD/AFD, LINKE/DIE LINKE)
norm = normalize_partei ( frak ) or frak
heatmap [ thema ] [ norm ] . append ( score )
# Top-Themen filtern
top_themen = [ t for t , n in thema_counts . most_common ( 20 ) if n > = min_count ]
# Alle vorkommenden Fraktionen sammeln
all_fraktionen = sorted ( { f for t in top_themen for f in heatmap [ t ] } )
# Matrix bauen
cells = { }
for thema in top_themen :
cells [ thema ] = { }
for frak in all_fraktionen :
scores = heatmap [ thema ] . get ( frak , [ ] )
if scores :
cells [ thema ] [ frak ] = {
" avg " : round ( sum ( scores ) / len ( scores ) , 1 ) ,
" n " : len ( scores ) ,
}
return {
" themen " : top_themen ,
" fraktionen " : all_fraktionen ,
" cells " : cells ,
" total " : len ( rows ) ,
}
@app.get ( " /api/auswertungen/export.json " )
async def auswertungen_export_json ( ) :
""" Open-Data-JSON-Export aller Assessments (#113).
Vollständiger Download aller Bewertungen als JSON mit Metadaten .
Lizenz : CC BY 4.0 ( öffentliche parlamentarische Dokumente + KI - Analyse ) .
"""
from datetime import datetime
rows = await get_all_assessments ( None )
assessments = [ ]
for row in rows :
assessments . append ( {
" drucksache " : row . get ( " drucksache " ) ,
" title " : row . get ( " title " ) ,
" fraktionen " : row . get ( " fraktionen " , [ ] ) ,
" datum " : row . get ( " datum " ) ,
" link " : row . get ( " link " ) ,
" bundesland " : row . get ( " bundesland " ) ,
" gwoe_score " : row . get ( " gwoe_score " ) ,
" gwoe_begruendung " : row . get ( " gwoe_begruendung " ) ,
" gwoe_matrix " : row . get ( " gwoe_matrix " , [ ] ) ,
" gwoe_schwerpunkt " : row . get ( " gwoe_schwerpunkt " , [ ] ) ,
" wahlprogramm_scores " : row . get ( " wahlprogramm_scores " , [ ] ) ,
" verbesserungen " : row . get ( " verbesserungen " , [ ] ) ,
" staerken " : row . get ( " staerken " , [ ] ) ,
" schwaechen " : row . get ( " schwaechen " , [ ] ) ,
" empfehlung " : row . get ( " empfehlung " ) ,
" themen " : row . get ( " themen " , [ ] ) ,
" antrag_zusammenfassung " : row . get ( " antrag_zusammenfassung " ) ,
" antrag_kernpunkte " : row . get ( " antrag_kernpunkte " , [ ] ) ,
" model " : row . get ( " model " ) ,
" updated_at " : row . get ( " updated_at " ) ,
} )
payload = {
" meta " : {
" name " : " GWÖ-Antragsprüfer Open Data " ,
" description " : " Automatische Gemeinwohl-Bilanzierung von Parlamentsanträgen nach der GWÖ-Matrix 2.0 " ,
" url " : " https://gwoe.toppyr.de " ,
" license " : " CC BY 4.0 " ,
" license_url " : " https://creativecommons.org/licenses/by/4.0/ " ,
" exported_at " : datetime . utcnow ( ) . isoformat ( ) + " Z " ,
" count " : len ( assessments ) ,
} ,
" assessments " : assessments ,
}
import json as _json
content = _json . dumps ( payload , ensure_ascii = False , indent = 2 )
return Response (
content = content ,
media_type = " application/json " ,
headers = {
" Content-Disposition " : ' attachment; filename= " gwoe-assessments.json " ' ,
" Access-Control-Allow-Origin " : " * " ,
} ,
)
@app.get ( " /api/clusters " )
async def clusters_api ( bundesland : Optional [ str ] = None , threshold : Optional [ float ] = None ) :
""" Antrag-Cluster (#105) per Cosine-Similarity über v4-Embeddings.
Wenn threshold nicht angegeben , nutze den Default aus clustering . py
( 0.55 , empirisch kalibriert für die aktuelle Prod - DB ) .
"""
from . clustering import build_hierarchy , DEFAULT_THRESHOLD
return await build_hierarchy (
bundesland = bundesland ,
threshold = threshold if threshold is not None else DEFAULT_THRESHOLD ,
)
@app.get ( " /api/assessment/similar " )
async def assessment_similar ( drucksache : str , top_k : int = 5 ) :
""" Ähnliche Anträge zum gegebenen (#108 Teil B). """
from . clustering import find_similar_assessments
return await find_similar_assessments ( drucksache = drucksache , top_k = min ( max ( 1 , top_k ) , 20 ) )
@app.get ( " /api/feed.xml " )
async def feed_xml ( request : Request , bundesland : Optional [ str ] = None , partei : Optional [ str ] = None , limit : int = 50 ) :
""" Atom 1.0 Feed der neuesten Bewertungen (Issue #125).
Query - Parameter :
- bundesland : optionaler BL - Code ( NRW , MV , BE , … )
- partei : optionaler Partei - Filter ( CDU , SPD , GRÜNE , … ) — matcht gegen fraktionen - Liste
- limit : Anzahl Einträge ( default 50 , max 200 )
"""
from datetime import datetime , timezone
import hashlib
import html
limit = min ( max ( 1 , limit ) , 200 )
rows = await get_all_assessments ( bundesland if bundesland else None )
if partei :
partei_norm = partei . upper ( )
rows = [ r for r in rows if any ( partei_norm in ( f or " " ) . upper ( ) for f in ( r . get ( " fraktionen " ) or [ ] ) ) ]
rows . sort ( key = lambda r : r . get ( " updated_at " ) or " " , reverse = True )
rows = rows [ : limit ]
base_url = " https://gwoe.toppyr.de "
feed_id_parts = [ " gwoe-feed " ]
if bundesland :
feed_id_parts . append ( bundesland )
if partei :
feed_id_parts . append ( partei )
feed_id = " urn: " + " : " . join ( feed_id_parts )
title_parts = [ " GWÖ-Antragsprüfer — Neue Bewertungen " ]
if bundesland :
title_parts . append ( bundesland )
if partei :
title_parts . append ( partei )
feed_title = " · " . join ( title_parts )
latest_updated = rows [ 0 ] . get ( " updated_at " ) if rows else datetime . utcnow ( ) . isoformat ( ) + " Z "
if latest_updated and not latest_updated . endswith ( " Z " ) :
latest_updated = latest_updated + " Z "
etag_src = f " { len ( rows ) } : { latest_updated } : { bundesland } : { partei } "
etag = ' " ' + hashlib . md5 ( etag_src . encode ( ) ) . hexdigest ( ) + ' " '
if request . headers . get ( " if-none-match " ) == etag :
return Response ( status_code = 304 )
def _entry ( row ) :
drucksache = row . get ( " drucksache " ) or " "
title = html . escape ( row . get ( " title " ) or drucksache )
score = row . get ( " gwoe_score " )
empfehlung = row . get ( " empfehlung " ) or " "
zusammenfassung = row . get ( " antrag_zusammenfassung " ) or " "
fraktionen = " , " . join ( row . get ( " fraktionen " ) or [ ] )
bl = row . get ( " bundesland " ) or " "
updated = row . get ( " updated_at " ) or " "
if updated and not updated . endswith ( " Z " ) :
updated + = " Z "
detail_url = f " { base_url } /?drucksache= { drucksache } "
entry_id = f " urn:gwoe: { bl } : { drucksache } "
summary_parts = [ ]
if score is not None :
summary_parts . append ( f " GWÖ-Score: { score } /10 " )
if empfehlung :
summary_parts . append ( f " Empfehlung: { empfehlung } " )
if fraktionen :
summary_parts . append ( f " Fraktionen: { fraktionen } " )
if zusammenfassung :
summary_parts . append ( zusammenfassung )
summary = html . escape ( " — " . join ( summary_parts ) )
return f """ <entry>
< id > { html . escape ( entry_id ) } < / id >
< title > { title } < / title >
< link href = " { html.escape(detail_url)} " / >
< updated > { html . escape ( updated ) } < / updated >
< summary > { summary } < / summary >
< category term = " { html.escape(bl)} " / >
< / entry > """
entries_xml = " \n " . join ( _entry ( r ) for r in rows )
self_url = f " { base_url } /api/feed.xml "
if bundesland or partei :
params = [ ]
if bundesland :
params . append ( f " bundesland= { bundesland } " )
if partei :
params . append ( f " partei= { partei } " )
self_url + = " ? " + " & " . join ( params )
xml = f """ <?xml version= " 1.0 " encoding= " utf-8 " ?>
< feed xmlns = " http://www.w3.org/2005/Atom " >
< id > { html . escape ( feed_id ) } < / id >
< title > { html . escape ( feed_title ) } < / title >
< subtitle > Automatische Gemeinwohl - Bilanzierung von Parlamentsanträgen < / subtitle >
< link href = " { html.escape(base_url)} " rel = " alternate " type = " text/html " / >
< link href = " { html.escape(self_url)} " rel = " self " type = " application/atom+xml " / >
< updated > { html . escape ( latest_updated ) } < / updated >
< author > < name > GWÖ - Antragsprüfer < / name > < / author >
< rights > CC BY 4.0 < / rights >
{ entries_xml }
< / feed >
"""
return Response (
content = xml ,
media_type = " application/atom+xml; charset=utf-8 " ,
headers = {
" ETag " : etag ,
" Cache-Control " : " public, max-age=600 " ,
" Access-Control-Allow-Origin " : " * " ,
} ,
)
Phase C: Auswertungen-Dashboard #58 + CSV-Export #45 (Roadmap #59)
Drei-dimensionale Aggregations-Sicht über Bundesland × Partei ×
Wahlperiode mit minimalem Frontend.
Backend (`app/auswertungen.py`):
- `aggregate_matrix(filter_wp=None)` — 2D-Matrix Bundesland × Partei mit
(n, Ø-Score) pro Zelle, optional gefiltert nach Wahlperiode
- `aggregate_zeitreihe(bundesland, partei)` — Score-Verlauf einer
(BL, Partei)-Kombination über alle bekannten WPs
- `export_long_format()` — Long-Format-CSV-Export für externe Tools
(deckt #45 vollständig ab)
- Partei-Auflösung läuft strikt durch `normalize_partei()` aus #55 —
damit wird BB-`FREIE WÄHLER` korrekt als `BVB-FW` aggregiert und
NICHT mit dem RP-FW zusammengezählt
Wahlperioden-Helper (`app/wahlperioden.py`):
- `wahlperiode_for(datum, bundesland)` mappt ein ISO-Datum + BL auf eine
Kennung wie `"NRW-WP18"` oder `"MV-WP7"` (Vorgänger-WP). Single Source
of Truth ist `BUNDESLAENDER[bl].wahlperiode_start`
- `all_wahlperioden()` für UI-Filter-Dropdowns
Endpoints in `app/main.py`:
- `GET /auswertungen` — HTML-Seite (neues Template)
- `GET /api/auswertungen/matrix?wahlperiode=NRW-WP18` — JSON-Matrix
- `GET /api/auswertungen/zeitreihe?bundesland=MV&partei=CDU` — JSON-Verlauf
- `GET /api/auswertungen/export.csv` — CSV-Download
Frontend (`app/templates/auswertungen.html`):
- Statisches Template mit Vanilla-JS, kein Build-Step
- Wahlperioden-Dropdown + Reload-Button + CSV-Export-Button
- Matrix-Tabelle mit Score-Color-Coding (rot ≤ 3, gelb 3-6, grün > 6)
- Sticky-Bundesland-Spalte für horizontales Scrolling
Tests (`tests/test_auswertungen.py`):
- 19 Cases mit in-memory SQLite-Fixture
- Verifiziert WP-Mapping, Matrix-Aggregation, Koalitions-Counting,
WP-Filter-Korrektheit, BVB-FW-Disambiguierung in der Matrix,
CSV-Long-Format
- 176 Unit-Tests grün (157 alt + 19 neu)
Refs: #58, #45, #59 (Phase C)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:25:57 +02:00
@app.get ( " /api/auswertungen/export.csv " )
async def auswertungen_export_csv ( ) :
""" Long-Format-CSV-Export aller Assessments. Deckt #45 mit ab. """
from . auswertungen import export_long_format
csv_text = export_long_format ( )
return Response (
content = csv_text ,
media_type = " text/csv " ,
headers = { " Content-Disposition " : ' attachment; filename= " gwoe-assessments.csv " ' } ,
)
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
# ─── v2 Frontend (#139 Phase 2 + Phase 3) ───────────────────────────────────
# / ist jetzt Default-v2. /v2 leitet auf / weiter; /v2/antrag/* auf /antrag/*.
# /classic ist die alte Ansicht (index.html unverändert).
# Phase 3: /v2/merkliste, /v2/tags, /v2/cluster, /v2/neu, /v2/batch
@app.get ( " /v2 " , response_class = HTMLResponse )
async def v2_redirect ( request : Request ) :
""" Redirect /v2 → / (v2 ist jetzt Default unter /). """
from fastapi . responses import RedirectResponse
return RedirectResponse ( " / " , status_code = 301 )
@app.get ( " /v2/antrag/ { drucksache:path} " , response_class = HTMLResponse )
async def v2_antrag_redirect ( request : Request , drucksache : str ) :
""" Redirect /v2/antrag/XX → /antrag/XX (kanonische URL). """
from fastapi . responses import RedirectResponse
return RedirectResponse ( f " /antrag/ { drucksache } " , status_code = 301 )
@app.get ( " /v2/merkliste " , response_class = HTMLResponse )
2026-04-25 21:50:36 +02:00
async def v2_merkliste ( request : Request , current_user : dict = Depends ( require_auth ) ) :
""" Merkliste (Bookmarks) — nur für eingeloggte User; lädt Daten via /api/bookmarks client-seitig. """
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
return templates . TemplateResponse ( " v2/screens/merkliste.html " , {
" request " : request ,
" v2_active_nav " : " merkliste " ,
2026-04-25 21:50:36 +02:00
* * _v2_template_context ( current_user ) ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
} )
@app.get ( " /v2/tags " , response_class = HTMLResponse )
2026-04-25 21:50:36 +02:00
async def v2_tags ( request : Request , current_user : Optional [ dict ] = Depends ( get_current_user ) ) :
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
""" Tag-Cloud-Seite — Themen-Filter über alle Assessments. """
return templates . TemplateResponse ( " v2/screens/tags.html " , {
" request " : request ,
" v2_active_nav " : " tags " ,
2026-04-25 21:50:36 +02:00
* * _v2_template_context ( current_user ) ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
} )
2026-04-25 22:34:55 +02:00
@app.get ( " /v2/abos " , response_class = HTMLResponse )
async def v2_abos ( request : Request , current_user : dict = Depends ( require_auth ) ) :
""" Eigene E-Mail-Abos verwalten — auth-only. """
2026-04-25 22:37:31 +02:00
from . parteien import all_canonical_keys
# Landesregierung als Filter unsinnig — ausblenden
parteien = [ p for p in all_canonical_keys ( ) if p != " Landesregierung " ]
2026-04-25 22:34:55 +02:00
return templates . TemplateResponse ( " v2/screens/abos.html " , {
" request " : request ,
" v2_active_nav " : " abos " ,
2026-04-25 22:37:31 +02:00
" parteien " : parteien ,
2026-04-25 22:34:55 +02:00
* * _v2_template_context ( current_user ) ,
} )
@app.get ( " /v2/feed " , response_class = HTMLResponse )
async def v2_feed ( request : Request , current_user : dict = Depends ( require_auth ) ) :
""" Atom-Feed-Konfigurations-Seite — auth-only. """
2026-04-25 22:37:31 +02:00
from . parteien import all_canonical_keys
parteien = [ p for p in all_canonical_keys ( ) if p != " Landesregierung " ]
2026-04-25 22:34:55 +02:00
return templates . TemplateResponse ( " v2/screens/feed.html " , {
" request " : request ,
" v2_active_nav " : " feed " ,
2026-04-25 22:37:31 +02:00
" parteien " : parteien ,
2026-04-25 22:34:55 +02:00
* * _v2_template_context ( current_user ) ,
} )
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
@app.get ( " /v2/cluster " , response_class = HTMLResponse )
2026-04-25 21:50:36 +02:00
async def v2_cluster ( request : Request , current_user : dict = Depends ( require_admin ) ) :
""" Cluster-Liste — nur für Admins. """
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
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/cluster.html " , {
" request " : request ,
" v2_active_nav " : " cluster " ,
" bl_codes " : bl_codes ,
2026-04-25 21:50:36 +02:00
* * _v2_template_context ( current_user ) ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
} )
@app.get ( " /v2/neu " , response_class = HTMLResponse )
2026-04-25 21:50:36 +02:00
async def v2_neu ( request : Request , current_user : dict = Depends ( require_auth ) ) :
""" Neuer-Antrag-Form — nur für eingeloggte User; startet Analyse via /api/analyze-drucksache. """
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
from . bundeslaender import alle_bundeslaender
bl_list = [
{ " code " : bl . code , " name " : bl . name }
for bl in alle_bundeslaender ( )
if bl . aktiv
]
return templates . TemplateResponse ( " v2/screens/neu.html " , {
" request " : request ,
" v2_active_nav " : " neu " ,
" bundeslaender " : sorted ( bl_list , key = lambda x : x [ " name " ] ) ,
" default_model " : settings . llm_model_default ,
2026-04-25 21:50:36 +02:00
* * _v2_template_context ( current_user ) ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
} )
@app.get ( " /v2/landtag-suche " , response_class = HTMLResponse )
2026-04-25 21:50:36 +02:00
async def v2_landtag_suche ( request : Request , current_user : dict = Depends ( require_auth ) ) :
""" Landtag-Suche — nur für eingeloggte User; sucht Drucksachen live im Landtags-Portal. """
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
from . bundeslaender import alle_bundeslaender
bl_list = [
{ " code " : bl . code , " name " : bl . name }
for bl in alle_bundeslaender ( )
if bl . aktiv
]
return templates . TemplateResponse ( " v2/screens/landtag_suche.html " , {
" request " : request ,
" v2_active_nav " : " landtag_suche " ,
" bundeslaender " : sorted ( bl_list , key = lambda x : x [ " name " ] ) ,
2026-04-25 21:50:36 +02:00
* * _v2_template_context ( current_user ) ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
} )
@app.get ( " /v2/batch " , response_class = HTMLResponse )
2026-04-25 21:50:36 +02:00
async def v2_batch ( request : Request , current_user : dict = Depends ( require_admin ) ) :
""" Batch-Analyse-Form — nur für Admins; enqueued ungeprüfte Drucksachen eines BL. """
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
from . bundeslaender import alle_bundeslaender
bl_list = [
{ " code " : bl . code , " name " : bl . name }
for bl in alle_bundeslaender ( )
if bl . aktiv
]
return templates . TemplateResponse ( " v2/screens/batch.html " , {
" request " : request ,
" v2_active_nav " : " batch " ,
" bundeslaender " : sorted ( bl_list , key = lambda x : x [ " name " ] ) ,
2026-04-25 21:50:36 +02:00
* * _v2_template_context ( current_user ) ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
} )
# ─── v2 Admin-Screens ────────────────────────────────────────────────────────
@app.get ( " /v2/admin/freischaltungen " , response_class = HTMLResponse )
async def v2_admin_freischaltungen ( request : Request , user : dict = Depends ( require_admin ) ) :
""" Ausstehende User-Freischaltungen (Admin). """
return templates . TemplateResponse ( " v2/screens/admin_freischaltungen.html " , {
" request " : request ,
" v2_active_nav " : " admin_freischaltungen " ,
2026-04-25 21:50:36 +02:00
* * _v2_template_context ( user ) ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
} )
@app.get ( " /v2/admin/queue " , response_class = HTMLResponse )
async def v2_admin_queue ( request : Request , user : dict = Depends ( require_admin ) ) :
""" Queue-Status-Übersicht (Admin). """
return templates . TemplateResponse ( " v2/screens/admin_queue.html " , {
" request " : request ,
" v2_active_nav " : " admin_queue " ,
2026-04-25 21:50:36 +02:00
* * _v2_template_context ( user ) ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
} )
@app.get ( " /v2/admin/abos " , response_class = HTMLResponse )
async def v2_admin_abos ( request : Request , user : dict = Depends ( require_admin ) ) :
""" Abo-Verwaltung — alle E-Mail-Abonnements (Admin). """
return templates . TemplateResponse ( " v2/screens/admin_abos.html " , {
" request " : request ,
" v2_active_nav " : " admin_abos " ,
2026-04-25 21:50:36 +02:00
* * _v2_template_context ( user ) ,
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
} )
# ─── #141 Open-Graph-Karten ──────────────────────────────────────────────────
@app.get ( " /v2/og-template " , response_class = HTMLResponse )
async def og_template ( request : Request , drucksache : str = " " ) :
""" Internes Render-Template für Playwright (#141).
Wird von render_og_card ( ) intern aufgerufen — nicht für Endnutzer .
"""
antrag = None
if drucksache :
try :
drucksache = validate_drucksache ( drucksache )
row = await get_assessment ( drucksache )
if row :
antrag = _row_to_detail ( row )
except Exception :
pass
return templates . TemplateResponse ( " v2/og_template.html " , {
" request " : request ,
" antrag " : antrag ,
} )
@app.get ( " /api/og/ {drucksache_encoded} .png " )
async def api_og_card ( drucksache_encoded : str , request : Request ) :
""" Liefert die Open-Graph-PNG-Karte für einen Antrag (#141).
Cache - Hit → direkte Datei - Response .
Cache - Miss → Playwright - Render ( synchron , blockiert kurz ) .
"""
import urllib . parse
from . og_card import render_og_card , get_cached
from . config import settings
drucksache = urllib . parse . unquote ( drucksache_encoded )
try :
drucksache = validate_drucksache ( drucksache )
except Exception :
raise HTTPException ( status_code = 400 , detail = " Ungültige Drucksachen-ID " )
row = await get_assessment ( drucksache )
if not row :
raise HTTPException ( status_code = 404 , detail = " Antrag nicht gefunden " )
updated_at = row . get ( " updated_at " , " " )
og_cache_dir = settings . data_dir / " og-cache "
cached_path = get_cached ( drucksache , updated_at , og_cache_dir )
if cached_path :
return FileResponse ( str ( cached_path ) , media_type = " image/png " )
# Interne URL: der laufende Server selbst (Playwright greift loopback an)
base_url = f " http://127.0.0.1: { settings . port } "
png_bytes = render_og_card ( drucksache , updated_at , base_url , og_cache_dir )
if not png_bytes :
raise HTTPException ( status_code = 500 , detail = " OG-Render fehlgeschlagen " )
return Response ( content = png_bytes , media_type = " image/png " )
# ─── #138 Admin: Wahlprogramm-Beschaffung ────────────────────────────────────
@app.get ( " /v2/admin/wahlprogramme " , response_class = HTMLResponse )
async def v2_admin_wahlprogramme ( request : Request , user : dict = Depends ( require_admin ) ) :
""" Admin-Übersicht fehlender Wahlprogramme mit Kandidaten-URLs (#138). """
from . wahlprogramm_fetch import get_missing_programmes
missing = get_missing_programmes ( )
return templates . TemplateResponse ( " v2/screens/admin_wahlprogramme.html " , {
" request " : request ,
" v2_active_nav " : " admin_wahlprogramme " ,
" is_admin " : True ,
" missing " : missing ,
} )
@app.post ( " /api/admin/wahlprogramm-fetch " )
async def api_admin_wahlprogramm_fetch (
request : Request ,
user : dict = Depends ( require_admin ) ,
) :
""" Lädt ein Wahlprogramm von einer angegebenen URL (#138).
Body : JSON { " bl " : " NRW " , " partei " : " BSW " , " url " : " https://... " }
SHA - Gate : Wenn eine Datei bereits vorhanden ist und der SHA abweicht ,
wird sie nicht überschrieben — stattdessen wird ein 409 - Fehler zurückgegeben .
"""
from . wahlprogramm_fetch import fetch_and_verify , suggest_candidates
from . wahlprogramme import WAHLPROGRAMME
body = await request . json ( )
bl = body . get ( " bl " , " " ) . strip ( ) . upper ( )
partei = body . get ( " partei " , " " ) . strip ( ) . upper ( )
url = body . get ( " url " , " " ) . strip ( )
expected_sha = body . get ( " expected_sha " , None )
if not bl or not partei :
raise HTTPException ( status_code = 400 , detail = " bl und partei sind Pflichtfelder " )
if not url :
candidates = suggest_candidates ( bl , partei )
if not candidates :
raise HTTPException (
status_code = 400 ,
detail = f " Keine URL angegeben und keine Kandidaten für { bl } / { partei } hinterlegt " ,
)
url = candidates [ 0 ] [ " url " ]
wp_info = WAHLPROGRAMME . get ( bl , { } ) . get ( partei )
if wp_info :
from pathlib import Path as _Path
dest = _Path ( __file__ ) . parent / " static " / " referenzen " / wp_info [ " file " ]
else :
from pathlib import Path as _Path
dest = _Path ( __file__ ) . parent / " static " / " referenzen " / f " { partei . lower ( ) } - { bl . lower ( ) } -neu.pdf "
result = fetch_and_verify ( url , dest , expected_sha )
if not result [ " ok " ] :
# SHA-Abweichung oder Download-Fehler → 409 damit kein stilles Überschreiben
status = 409 if " SHA " in ( result [ " error " ] or " " ) else 502
raise HTTPException ( status_code = status , detail = result [ " error " ] )
return JSONResponse ( {
" ok " : True ,
" sha256 " : result [ " sha256 " ] ,
" prev_sha256 " : result [ " prev_sha256 " ] ,
" changed " : result [ " changed " ] ,
" dest " : str ( dest . name ) ,
} )
2026-03-28 22:30:24 +01:00
# Health check
@app.get ( " /health " )
async def health ( ) :
return { " status " : " ok " , " version " : settings . app_version }