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
|
|
|
|
"""Report generation for HTML and PDF output.
|
|
|
|
|
|
|
|
|
|
|
|
All LLM-generated fields are HTML-escaped before being interpolated into
|
|
|
|
|
|
the report template. WeasyPrint will happily resolve ``<img src="file://...">``
|
|
|
|
|
|
or ``<link rel=stylesheet href="file://...">`` against the container
|
|
|
|
|
|
filesystem, so unescaped LLM output is a Local-File-Read primitive — see
|
|
|
|
|
|
issue #57 (audit findings #2 and #6). The ``_e`` helper is the single
|
|
|
|
|
|
funnel through which all LLM strings must pass on their way into the HTML.
|
|
|
|
|
|
"""
|
2026-03-28 22:30:24 +01:00
|
|
|
|
|
2026-04-10 17:05:12 +02:00
|
|
|
|
import logging
|
2026-03-28 22:30:24 +01:00
|
|
|
|
import subprocess
|
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 html import escape as _e
|
2026-03-28 22:30:24 +01:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
feat: PDF-Generierung auf v3-Layout, Werkstatt-Link im Admin, Share nur bei Login
Drei Aufgaben in einem Schwung:
1. Werkstatt-Link im Admin
admin_stand bekommt eine Sektion 'Design-Werkstaetten' mit Link auf
/v2/scorecard-werkstatt — damit Admins den Live-Editor finden ohne
die URL kennen zu muessen.
2. Share-Block nur fuer angemeldete User
Der ganze Share-Block (Kopieren, Threads, Mastodon, LinkedIn,
Instagram, E-Mail, Scorecard, Stock-Bild) bekommt id=v2-share-block
und wird per initAuth() display:none/block geschaltet — analog zum
Comment-Form. Default im Markup: display:none, damit Gaeste ihn
nicht waehrend des Auth-Roundtrips kurz sehen. Funktioniert in v2
und v3 (gleicher JS-Handler via super-Inheritance).
3. PDF-Layout = v3-Layout
Neues Template v3/pdf/antrag_pdf.html (single column, A4) reused die
Visuallogik aus der Online-Detailseite:
- Score-Hero-Block mit Farb-Tint
- Matrix 5×5 mit Achsen-Labels (Werte oben, Berührungsgruppen links)
- Programm-Treue pro Fraktion mit Begruendung + Zitaten + Fallback-
Hinweis bei fehlenden Zitaten
- Verbesserungsvorschlaege mit Redline-Format
- Abstimmungsergebnis (best-effort via get_plenum_votes) inkl.
Konsistenz-Hinweis
Online-spezifisches gestrichen: Merken-Button, Vote-treffend, Share,
Kommentare, News-Box, Reanalyze, Historie, Modals.
NEU im PDF: 'Schwerpunkte erklaert'-Sektion direkt unter der Matrix.
Listet die Top-4 positiven und Top-4 negativen Matrix-Felder mit
ihrem LLM-generierten label + aspect — Ersatz fuer den interaktiven
Klick, der im PDF nicht funktioniert.
report.generate_html_report_v3() neu, generate_pdf_report() ruft
diese statt der alten Inline-HTML-Variante. Alte generate_html_report
bleibt als Fallback erhalten.
WeasyPrint rendert mit @page A4, Footer mit Drucksache + Branding +
Seitenzahl 'Seite X von Y'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:48:19 +02:00
|
|
|
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
|
|
|
|
|
2026-04-10 17:05:12 +02:00
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
from .models import Assessment, MATRIX_LABELS, EMPFEHLUNG_CONFIG
|
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
|
|
|
|
from .bundeslaender import BUNDESLAENDER
|
2026-03-28 22:30:24 +01:00
|
|
|
|
|
feat: PDF-Generierung auf v3-Layout, Werkstatt-Link im Admin, Share nur bei Login
Drei Aufgaben in einem Schwung:
1. Werkstatt-Link im Admin
admin_stand bekommt eine Sektion 'Design-Werkstaetten' mit Link auf
/v2/scorecard-werkstatt — damit Admins den Live-Editor finden ohne
die URL kennen zu muessen.
2. Share-Block nur fuer angemeldete User
Der ganze Share-Block (Kopieren, Threads, Mastodon, LinkedIn,
Instagram, E-Mail, Scorecard, Stock-Bild) bekommt id=v2-share-block
und wird per initAuth() display:none/block geschaltet — analog zum
Comment-Form. Default im Markup: display:none, damit Gaeste ihn
nicht waehrend des Auth-Roundtrips kurz sehen. Funktioniert in v2
und v3 (gleicher JS-Handler via super-Inheritance).
3. PDF-Layout = v3-Layout
Neues Template v3/pdf/antrag_pdf.html (single column, A4) reused die
Visuallogik aus der Online-Detailseite:
- Score-Hero-Block mit Farb-Tint
- Matrix 5×5 mit Achsen-Labels (Werte oben, Berührungsgruppen links)
- Programm-Treue pro Fraktion mit Begruendung + Zitaten + Fallback-
Hinweis bei fehlenden Zitaten
- Verbesserungsvorschlaege mit Redline-Format
- Abstimmungsergebnis (best-effort via get_plenum_votes) inkl.
Konsistenz-Hinweis
Online-spezifisches gestrichen: Merken-Button, Vote-treffend, Share,
Kommentare, News-Box, Reanalyze, Historie, Modals.
NEU im PDF: 'Schwerpunkte erklaert'-Sektion direkt unter der Matrix.
Listet die Top-4 positiven und Top-4 negativen Matrix-Felder mit
ihrem LLM-generierten label + aspect — Ersatz fuer den interaktiven
Klick, der im PDF nicht funktioniert.
report.generate_html_report_v3() neu, generate_pdf_report() ruft
diese statt der alten Inline-HTML-Variante. Alte generate_html_report
bleibt als Fallback erhalten.
WeasyPrint rendert mit @page A4, Footer mit Drucksache + Branding +
Seitenzahl 'Seite X von Y'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:48:19 +02:00
|
|
|
|
# Eigene Jinja-Env fuer PDF-Templates (separat von Starlette templates,
|
|
|
|
|
|
# weil report.py auch von Hintergrund-Jobs ohne FastAPI-Request laufen muss).
|
|
|
|
|
|
_TEMPLATE_DIR = Path(__file__).parent / "templates"
|
|
|
|
|
|
_pdf_jinja = Environment(
|
|
|
|
|
|
loader=FileSystemLoader(str(_TEMPLATE_DIR)),
|
|
|
|
|
|
autoescape=select_autoescape(["html"]),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
# ECOnGOOD Colors
|
|
|
|
|
|
COLORS = {
|
|
|
|
|
|
"darkgray": "#5a5a5a",
|
|
|
|
|
|
"green": "#889e33",
|
|
|
|
|
|
"blue": "#009da5",
|
|
|
|
|
|
"lightgray": "#bfbfbf",
|
|
|
|
|
|
"orange": "#F7941D",
|
|
|
|
|
|
"red": "#d00000",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_score_color(score: float) -> str:
|
|
|
|
|
|
"""Get color for a score value."""
|
|
|
|
|
|
if score >= 7:
|
|
|
|
|
|
return COLORS["blue"]
|
|
|
|
|
|
if score >= 4:
|
|
|
|
|
|
return COLORS["green"]
|
|
|
|
|
|
if score >= 2:
|
|
|
|
|
|
return "#FFC20E"
|
|
|
|
|
|
if score >= 1:
|
|
|
|
|
|
return COLORS["orange"]
|
|
|
|
|
|
return COLORS["red"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_rating_symbol(rating: int) -> str:
|
2026-05-06 23:42:15 +02:00
|
|
|
|
"""Convert numeric rating to symbol — gleiche Logik wie in models.py
|
|
|
|
|
|
und v2/components/matrix_mini.html. Skala -5..+5."""
|
|
|
|
|
|
if rating >= 4:
|
2026-03-28 22:30:24 +01:00
|
|
|
|
return "++"
|
2026-05-06 23:42:15 +02:00
|
|
|
|
if rating >= 1:
|
2026-03-28 22:30:24 +01:00
|
|
|
|
return "+"
|
|
|
|
|
|
if rating == 0:
|
|
|
|
|
|
return "○"
|
2026-05-06 23:42:15 +02:00
|
|
|
|
if rating <= -4:
|
|
|
|
|
|
return "−−"
|
|
|
|
|
|
return "−"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_rating_class(rating: int) -> str:
|
|
|
|
|
|
"""5-Klassen-Coloring analog zu v2 matrix_mini (m-pp/m-p/m-0/m-n/m-nn)
|
|
|
|
|
|
— vorher hatte das PDF nur 3 Klassen (positive/negative/neutral),
|
|
|
|
|
|
was zu 'gleichfarbig' für + und ++ führte."""
|
|
|
|
|
|
if rating >= 4:
|
|
|
|
|
|
return "rating-pp"
|
|
|
|
|
|
if rating >= 1:
|
|
|
|
|
|
return "rating-p"
|
|
|
|
|
|
if rating == 0:
|
|
|
|
|
|
return "rating-0"
|
|
|
|
|
|
if rating <= -4:
|
|
|
|
|
|
return "rating-nn"
|
|
|
|
|
|
return "rating-n"
|
2026-03-28 22:30:24 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def format_redline_html(text: str) -> str:
|
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
|
|
|
|
"""Convert redline markup (``**ins**`` / ``~~del~~``) to HTML.
|
|
|
|
|
|
|
|
|
|
|
|
Escapes the input first so that any HTML in the LLM output (e.g.
|
|
|
|
|
|
``<img src="file:///etc/passwd">``) becomes inert text. The marker
|
|
|
|
|
|
regexes still fire because ``**`` and ``~~`` are not HTML special
|
|
|
|
|
|
chars and survive escaping unchanged. The inserted ``<span>`` tags
|
|
|
|
|
|
are the only raw HTML in the result and are produced by us.
|
|
|
|
|
|
"""
|
2026-03-28 22:30:24 +01:00
|
|
|
|
import re
|
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
|
|
|
|
text = _e(text or "")
|
2026-03-28 22:30:24 +01:00
|
|
|
|
# **text** → green bold (inserted)
|
|
|
|
|
|
text = re.sub(r'\*\*([^*]+)\*\*', r'<span class="inserted">\1</span>', text)
|
|
|
|
|
|
# ~~text~~ → red strikethrough (deleted)
|
|
|
|
|
|
text = re.sub(r'~~([^~]+)~~', r'<span class="deleted">\1</span>', text)
|
|
|
|
|
|
return text
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-07 09:15:25 +02:00
|
|
|
|
def build_matrix_html_v2(assessment: Assessment) -> str:
|
|
|
|
|
|
"""Render Matrix mit dem v2-Macro (matrix_mini) — gleiche Quelle wie
|
|
|
|
|
|
die Web-View. Erste Stufe von #175 Phase 23: PDF nutzt v2-Block-
|
|
|
|
|
|
Macros für Konsistenz-by-Design."""
|
|
|
|
|
|
from jinja2 import Environment, FileSystemLoader
|
|
|
|
|
|
template_dir = Path(__file__).resolve().parent / "templates"
|
|
|
|
|
|
env = Environment(loader=FileSystemLoader(str(template_dir)),
|
|
|
|
|
|
autoescape=True)
|
|
|
|
|
|
macro_template = env.get_template("v2/components/matrix_mini.html")
|
|
|
|
|
|
matrix_dict = {}
|
|
|
|
|
|
for e in assessment.gwoe_matrix:
|
|
|
|
|
|
matrix_dict[e.field] = {"rating": e.rating, "symbol": ""}
|
|
|
|
|
|
# Macro über `module` aufrufen
|
|
|
|
|
|
module = macro_template.module
|
|
|
|
|
|
return str(module.matrix_mini(matrix_dict))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
def build_matrix_html(assessment: Assessment) -> str:
|
2026-05-07 09:15:25 +02:00
|
|
|
|
"""Legacy-Renderer: 5x5-Tabelle für PDF (Stand vor Phase 23).
|
|
|
|
|
|
|
|
|
|
|
|
Hauptpfad rendert weiterhin diese Funktion — der v2-Macro-Pfad
|
|
|
|
|
|
(build_matrix_html_v2) ist als Folge-Schritt verfügbar, sobald
|
|
|
|
|
|
das v2.css-Stylesheet im PDF eingebunden ist.
|
|
|
|
|
|
"""
|
2026-03-28 22:30:24 +01:00
|
|
|
|
rating_map = {e.field: e for e in assessment.gwoe_matrix}
|
|
|
|
|
|
|
|
|
|
|
|
rows = ["A", "B", "C", "D", "E"]
|
|
|
|
|
|
row_labels = {
|
|
|
|
|
|
"A": "Lieferant:innen",
|
|
|
|
|
|
"B": "Finanzen",
|
|
|
|
|
|
"C": "Führung/Verwaltung",
|
|
|
|
|
|
"D": "Bürger:innen",
|
|
|
|
|
|
"E": "Gesellschaft/Natur",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
html = ['<table class="matrix-table">']
|
|
|
|
|
|
html.append('<thead><tr>')
|
|
|
|
|
|
html.append('<th></th>')
|
|
|
|
|
|
for col in range(1, 6):
|
|
|
|
|
|
html.append(f'<th>{col}</th>')
|
|
|
|
|
|
html.append('</tr></thead>')
|
|
|
|
|
|
html.append('<tbody>')
|
|
|
|
|
|
|
|
|
|
|
|
for row in rows:
|
|
|
|
|
|
html.append(f'<tr><th>{row}: {row_labels[row]}</th>')
|
|
|
|
|
|
for col in range(1, 6):
|
|
|
|
|
|
field = f"{row}{col}"
|
|
|
|
|
|
entry = rating_map.get(field)
|
|
|
|
|
|
if entry:
|
|
|
|
|
|
symbol = get_rating_symbol(entry.rating)
|
2026-05-06 23:42:15 +02:00
|
|
|
|
css_class = get_rating_class(entry.rating)
|
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
|
|
|
|
# entry.aspect comes from the LLM and is interpolated into a
|
|
|
|
|
|
# title="..." attribute — escape it so a stray double-quote
|
|
|
|
|
|
# cannot break out and inject attributes/handlers.
|
|
|
|
|
|
html.append(f'<td class="{css_class}" title="{_e(entry.aspect)}">{symbol}</td>')
|
2026-03-28 22:30:24 +01:00
|
|
|
|
else:
|
2026-05-06 23:42:15 +02:00
|
|
|
|
html.append('<td class="rating-0"></td>')
|
2026-03-28 22:30:24 +01:00
|
|
|
|
html.append('</tr>')
|
|
|
|
|
|
|
|
|
|
|
|
html.append('</tbody></table>')
|
|
|
|
|
|
return '\n'.join(html)
|
|
|
|
|
|
|
|
|
|
|
|
|
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 generate_html_report(
|
|
|
|
|
|
assessment: Assessment,
|
|
|
|
|
|
output_path: Path,
|
|
|
|
|
|
bundesland: Optional[str] = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Generate HTML report.
|
|
|
|
|
|
|
|
|
|
|
|
``bundesland`` is the optional state code (e.g. ``"NRW"``, ``"LSA"``).
|
|
|
|
|
|
When set and known in ``BUNDESLAENDER``, the resulting report carries
|
|
|
|
|
|
the parlament name in its header so the source parliament is always
|
|
|
|
|
|
visible — important since assessments from multiple bundesländer share
|
|
|
|
|
|
the same Drucksachen-ID space.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
empf_config = EMPFEHLUNG_CONFIG.get(assessment.empfehlung.value, {})
|
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
|
|
|
|
|
|
|
|
|
|
parlament_name = ""
|
|
|
|
|
|
if bundesland and bundesland in BUNDESLAENDER:
|
|
|
|
|
|
parlament_name = BUNDESLAENDER[bundesland].parlament_name
|
2026-03-28 22:30:24 +01:00
|
|
|
|
|
|
|
|
|
|
html = f"""<!DOCTYPE html>
|
|
|
|
|
|
<html lang="de">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
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
|
|
|
|
<title>GWÖ-Antragsprüfung: {_e(assessment.title or "")}</title>
|
2026-03-28 22:30:24 +01:00
|
|
|
|
<style>
|
|
|
|
|
|
:root {{
|
|
|
|
|
|
--color-darkgray: {COLORS['darkgray']};
|
|
|
|
|
|
--color-green: {COLORS['green']};
|
|
|
|
|
|
--color-blue: {COLORS['blue']};
|
|
|
|
|
|
--color-lightgray: {COLORS['lightgray']};
|
|
|
|
|
|
--color-orange: {COLORS['orange']};
|
|
|
|
|
|
--color-red: {COLORS['red']};
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
body {{
|
|
|
|
|
|
font-family: 'Avenir', Arial, sans-serif;
|
|
|
|
|
|
max-width: 800px;
|
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
|
padding: 1.5rem 2rem;
|
|
|
|
|
|
color: var(--color-darkgray);
|
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
|
font-size: 10pt;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
.header {{
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
border-bottom: 2px solid var(--color-blue);
|
|
|
|
|
|
padding-bottom: 0.75rem;
|
|
|
|
|
|
margin-bottom: 1.25rem;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
.header img {{
|
|
|
|
|
|
max-width: 150px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
.header-label {{
|
|
|
|
|
|
font-size: 8pt;
|
|
|
|
|
|
letter-spacing: 0.5px;
|
|
|
|
|
|
color: var(--color-blue);
|
|
|
|
|
|
margin-bottom: 0.5rem;
|
|
|
|
|
|
}}
|
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
|
|
|
|
|
|
|
|
|
|
.header-parlament {{
|
|
|
|
|
|
font-size: 9pt;
|
|
|
|
|
|
color: var(--color-blue);
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
margin-top: 0.4rem;
|
|
|
|
|
|
letter-spacing: 0.3px;
|
|
|
|
|
|
}}
|
2026-03-28 22:30:24 +01:00
|
|
|
|
|
|
|
|
|
|
h1 {{
|
|
|
|
|
|
color: var(--color-darkgray);
|
|
|
|
|
|
font-size: 14pt;
|
|
|
|
|
|
margin: 0.75rem 0;
|
|
|
|
|
|
line-height: 1.3;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
h2 {{
|
|
|
|
|
|
color: var(--color-blue);
|
|
|
|
|
|
font-size: 11pt;
|
|
|
|
|
|
border-bottom: 1px solid var(--color-lightgray);
|
|
|
|
|
|
padding-bottom: 0.3rem;
|
|
|
|
|
|
margin-top: 1.25rem;
|
|
|
|
|
|
margin-bottom: 0.5rem;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
h3 {{
|
|
|
|
|
|
color: var(--color-green);
|
|
|
|
|
|
font-size: 10pt;
|
|
|
|
|
|
margin-top: 0.75rem;
|
|
|
|
|
|
margin-bottom: 0.3rem;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
.meta-box {{
|
|
|
|
|
|
background: #f5f5f5;
|
|
|
|
|
|
padding: 0.6rem 0.8rem;
|
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
|
margin-bottom: 0.75rem;
|
|
|
|
|
|
font-size: 9pt;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
.empfehlung-box {{
|
|
|
|
|
|
background: {empf_config.get('color', COLORS['blue'])}15;
|
|
|
|
|
|
border: 1px solid {empf_config.get('color', COLORS['blue'])};
|
|
|
|
|
|
padding: 0.5rem 0.75rem;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
|
margin: 0.75rem 0;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
.empfehlung-box .symbol {{
|
|
|
|
|
|
font-size: 12pt;
|
|
|
|
|
|
color: {empf_config.get('color', COLORS['blue'])};
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
display: inline;
|
|
|
|
|
|
margin-right: 0.5rem;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
.empfehlung-box .text {{
|
|
|
|
|
|
font-size: 10pt;
|
|
|
|
|
|
display: inline;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
.score-bar {{
|
|
|
|
|
|
background: var(--color-lightgray);
|
|
|
|
|
|
height: 12px;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
margin: 0.3rem 0;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
.score-bar-fill {{
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
.matrix-table {{
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
border-collapse: collapse;
|
|
|
|
|
|
margin: 0.5rem 0;
|
|
|
|
|
|
font-size: 8pt;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
.matrix-table th, .matrix-table td {{
|
|
|
|
|
|
border: 1px solid var(--color-lightgray);
|
|
|
|
|
|
padding: 0.25rem 0.4rem;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
.matrix-table thead th {{
|
|
|
|
|
|
background: var(--color-blue);
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
font-size: 8pt;
|
|
|
|
|
|
font-weight: normal;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
.matrix-table tbody th {{
|
|
|
|
|
|
background: #f5f5f5;
|
|
|
|
|
|
text-align: left;
|
|
|
|
|
|
font-weight: normal;
|
|
|
|
|
|
font-size: 8pt;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
2026-05-06 23:42:15 +02:00
|
|
|
|
/* 5-Klassen-Coloring analog zu v2 matrix_mini (#177): ++ und +
|
|
|
|
|
|
müssen visuell deutlich unterscheidbar sein. */
|
|
|
|
|
|
.matrix-table .rating-pp {{
|
2026-03-28 22:30:24 +01:00
|
|
|
|
background: var(--color-green);
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
}}
|
2026-05-06 23:42:15 +02:00
|
|
|
|
.matrix-table .rating-p {{
|
|
|
|
|
|
background: #cddaa1; /* heller Grün-Tint */
|
|
|
|
|
|
color: var(--color-darkgray);
|
|
|
|
|
|
}}
|
|
|
|
|
|
.matrix-table .rating-0 {{
|
|
|
|
|
|
background: #f6f6f6;
|
|
|
|
|
|
color: #888;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.matrix-table .rating-n {{
|
|
|
|
|
|
background: #efc9c3; /* heller Rot-Tint */
|
|
|
|
|
|
color: var(--color-darkgray);
|
|
|
|
|
|
}}
|
|
|
|
|
|
.matrix-table .rating-nn {{
|
2026-03-28 22:30:24 +01:00
|
|
|
|
background: var(--color-red);
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
}}
|
2026-05-06 23:42:15 +02:00
|
|
|
|
/* Backwards-Compat fuer evtl. zwischengespeicherte HTML mit
|
|
|
|
|
|
alten Klassennamen — gleiche Optik wie rating-pp/-nn/-0. */
|
|
|
|
|
|
.matrix-table .positive {{ background: var(--color-green); color: white; font-weight: bold; }}
|
|
|
|
|
|
.matrix-table .negative {{ background: var(--color-red); color: white; font-weight: bold; }}
|
|
|
|
|
|
.matrix-table .neutral {{ background: #f0f0f0; }}
|
2026-03-28 22:30:24 +01:00
|
|
|
|
|
|
|
|
|
|
.verbesserung {{
|
|
|
|
|
|
margin: 0.5rem 0;
|
|
|
|
|
|
padding: 0.5rem;
|
|
|
|
|
|
border: 1px solid var(--color-lightgray);
|
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
|
font-size: 9pt;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
.verbesserung .original {{
|
|
|
|
|
|
background: #f9f9f9;
|
|
|
|
|
|
padding: 0.4rem;
|
|
|
|
|
|
margin-bottom: 0.3rem;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
.verbesserung .vorschlag {{
|
|
|
|
|
|
background: rgba(136, 158, 51, 0.1);
|
|
|
|
|
|
border-left: 2px solid var(--color-green);
|
|
|
|
|
|
padding: 0.4rem;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
.inserted {{
|
|
|
|
|
|
color: var(--color-green);
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
.deleted {{
|
|
|
|
|
|
color: var(--color-red);
|
|
|
|
|
|
text-decoration: line-through;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
.two-columns {{
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: 1fr 1fr;
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
.staerken {{
|
|
|
|
|
|
border-left: 2px solid var(--color-green);
|
|
|
|
|
|
padding-left: 0.5rem;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
.schwaechen {{
|
|
|
|
|
|
border-left: 2px solid var(--color-orange);
|
|
|
|
|
|
padding-left: 0.5rem;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
ul {{
|
|
|
|
|
|
margin: 0.3rem 0;
|
|
|
|
|
|
padding-left: 1.2rem;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
li {{
|
|
|
|
|
|
margin-bottom: 0.2rem;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
p {{
|
|
|
|
|
|
margin: 0.4rem 0;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
.footer {{
|
|
|
|
|
|
margin-top: 1.5rem;
|
|
|
|
|
|
padding-top: 0.5rem;
|
|
|
|
|
|
border-top: 1px solid var(--color-lightgray);
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
color: var(--color-lightgray);
|
|
|
|
|
|
font-size: 7pt;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
@media print {{
|
|
|
|
|
|
body {{ max-width: none; }}
|
|
|
|
|
|
}}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<div class="header">
|
|
|
|
|
|
<div class="header-label">GEMEINWOHL-ÖKONOMIE | ANTRAGSBEWERTUNG</div>
|
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
|
|
|
|
<h1>{_e(assessment.title or "")}</h1>
|
|
|
|
|
|
{f'<div class="header-parlament">{_e(parlament_name)}</div>' if parlament_name else ''}
|
2026-03-28 22:30:24 +01:00
|
|
|
|
</div>
|
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
|
|
|
|
<div class="meta-box">
|
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
|
|
|
|
<strong>Drucksache:</strong> {_e(assessment.drucksache or "")} |
|
|
|
|
|
|
<strong>Datum:</strong> {_e(assessment.datum or "")} |
|
|
|
|
|
|
<strong>Fraktion(en):</strong> {_e(', '.join(assessment.fraktionen))} |
|
2026-03-28 22:30:24 +01:00
|
|
|
|
<strong>GWÖ-Score:</strong> <span style="color: {get_score_color(assessment.gwoe_score)}; font-weight: bold;">{assessment.gwoe_score}/10</span>
|
|
|
|
|
|
</div>
|
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
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
<div class="empfehlung-box">
|
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
|
|
|
|
<span class="symbol">{_e(empf_config.get('symbol', '[?]'))}</span>
|
|
|
|
|
|
<span class="text"><strong>Empfehlung:</strong> {_e(assessment.empfehlung.value)}</span>
|
2026-03-28 22:30:24 +01:00
|
|
|
|
</div>
|
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
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
<h2>Der Antrag im Überblick</h2>
|
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
|
|
|
|
<p>{_e(assessment.antrag_zusammenfassung or 'Keine Zusammenfassung verfügbar.')}</p>
|
|
|
|
|
|
|
|
|
|
|
|
{('<ul>' + ''.join(f'<li>{_e(k)}</li>' for k in assessment.antrag_kernpunkte) + '</ul>') if assessment.antrag_kernpunkte else ''}
|
|
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
<h2>GWÖ-Treue</h2>
|
|
|
|
|
|
<p style="font-size: 9pt;"><strong>Score:</strong> <span style="color: {get_score_color(assessment.gwoe_score)};">{assessment.gwoe_score}/10</span></p>
|
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
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
<div class="score-bar">
|
|
|
|
|
|
<div class="score-bar-fill" style="width: {assessment.gwoe_score * 10}%; background: {get_score_color(assessment.gwoe_score)};"></div>
|
|
|
|
|
|
</div>
|
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
|
|
|
|
|
|
|
|
|
|
<p><strong>Begründung:</strong> {_e(assessment.gwoe_begruendung or "")}</p>
|
|
|
|
|
|
<p><strong>Schwerpunkte:</strong> {_e(', '.join(assessment.gwoe_schwerpunkt))}</p>
|
|
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
<h2>Matrix-Zuordnung (Matrix 2.0 für Gemeinden)</h2>
|
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
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
{build_matrix_html(assessment)}
|
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
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
<p style="font-size: 7pt; color: #999;">
|
|
|
|
|
|
<strong>Legende:</strong> ++ stark fördernd, + fördernd, ○ neutral, − widersprechend, −− stark widersprechend
|
|
|
|
|
|
</p>
|
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
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
<h3>Berührte Themenfelder</h3>
|
|
|
|
|
|
<ul>
|
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
|
|
|
|
{''.join(f'<li><strong>{_e(e.field)}:</strong> {_e(e.aspect)} [{get_rating_symbol(e.rating)}]</li>' for e in assessment.gwoe_matrix)}
|
2026-03-28 22:30:24 +01:00
|
|
|
|
</ul>
|
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
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
<h2>Programmtreue</h2>
|
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
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
{''.join(f'''
|
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
|
|
|
|
<h3>{_e(s.fraktion)} {' (Antragsteller)' if s.ist_antragsteller else ''}{' (Regierung)' if s.ist_regierung else ''}</h3>
|
|
|
|
|
|
<p><strong>Wahlprogramm:</strong> {s.wahlprogramm.score}/10 — {_e(s.wahlprogramm.begruendung or "")}</p>
|
|
|
|
|
|
<p><strong>Parteiprogramm:</strong> {s.parteiprogramm.score}/10 — {_e(s.parteiprogramm.begruendung or "")}</p>
|
2026-03-28 22:30:24 +01:00
|
|
|
|
''' for s in assessment.wahlprogramm_scores)}
|
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
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
<h2>Verbesserungsvorschläge</h2>
|
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
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
{''.join(f'''
|
|
|
|
|
|
<div class="verbesserung">
|
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
|
|
|
|
<div class="original"><strong>Original:</strong><br>{_e(v.original or "")}</div>
|
2026-03-28 22:30:24 +01:00
|
|
|
|
<div class="vorschlag"><strong>Vorschlag:</strong><br>{format_redline_html(v.vorschlag)}</div>
|
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
|
|
|
|
<div style="font-style: italic; margin-top: 0.5rem;">{_e(v.begruendung or "")}</div>
|
2026-03-28 22:30:24 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
''' for v in assessment.verbesserungen) or '<p>Keine Verbesserungsvorschläge.</p>'}
|
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
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
<h2>Zusammenfassung</h2>
|
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
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
<div class="two-columns">
|
|
|
|
|
|
<div class="staerken">
|
|
|
|
|
|
<h3 style="color: var(--color-green);">Stärken</h3>
|
|
|
|
|
|
<ul>
|
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
|
|
|
|
{''.join(f'<li>{_e(s)}</li>' for s in assessment.staerken) or '<li>(keine)</li>'}
|
2026-03-28 22:30:24 +01:00
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="schwaechen">
|
|
|
|
|
|
<h3 style="color: var(--color-orange);">Schwächen</h3>
|
|
|
|
|
|
<ul>
|
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
|
|
|
|
{''.join(f'<li>{_e(s)}</li>' for s in assessment.schwaechen) or '<li>(keine)</li>'}
|
2026-03-28 22:30:24 +01:00
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="footer">
|
|
|
|
|
|
<p>Erstellt mit GWÖ-Antragsprüfer v4.1 | Matrix 2.0 für Gemeinden</p>
|
|
|
|
|
|
<p style="color: var(--color-blue);">germany.econgood.org</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>"""
|
|
|
|
|
|
|
|
|
|
|
|
output_path.write_text(html)
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: PDF-Generierung auf v3-Layout, Werkstatt-Link im Admin, Share nur bei Login
Drei Aufgaben in einem Schwung:
1. Werkstatt-Link im Admin
admin_stand bekommt eine Sektion 'Design-Werkstaetten' mit Link auf
/v2/scorecard-werkstatt — damit Admins den Live-Editor finden ohne
die URL kennen zu muessen.
2. Share-Block nur fuer angemeldete User
Der ganze Share-Block (Kopieren, Threads, Mastodon, LinkedIn,
Instagram, E-Mail, Scorecard, Stock-Bild) bekommt id=v2-share-block
und wird per initAuth() display:none/block geschaltet — analog zum
Comment-Form. Default im Markup: display:none, damit Gaeste ihn
nicht waehrend des Auth-Roundtrips kurz sehen. Funktioniert in v2
und v3 (gleicher JS-Handler via super-Inheritance).
3. PDF-Layout = v3-Layout
Neues Template v3/pdf/antrag_pdf.html (single column, A4) reused die
Visuallogik aus der Online-Detailseite:
- Score-Hero-Block mit Farb-Tint
- Matrix 5×5 mit Achsen-Labels (Werte oben, Berührungsgruppen links)
- Programm-Treue pro Fraktion mit Begruendung + Zitaten + Fallback-
Hinweis bei fehlenden Zitaten
- Verbesserungsvorschlaege mit Redline-Format
- Abstimmungsergebnis (best-effort via get_plenum_votes) inkl.
Konsistenz-Hinweis
Online-spezifisches gestrichen: Merken-Button, Vote-treffend, Share,
Kommentare, News-Box, Reanalyze, Historie, Modals.
NEU im PDF: 'Schwerpunkte erklaert'-Sektion direkt unter der Matrix.
Listet die Top-4 positiven und Top-4 negativen Matrix-Felder mit
ihrem LLM-generierten label + aspect — Ersatz fuer den interaktiven
Klick, der im PDF nicht funktioniert.
report.generate_html_report_v3() neu, generate_pdf_report() ruft
diese statt der alten Inline-HTML-Variante. Alte generate_html_report
bleibt als Fallback erhalten.
WeasyPrint rendert mit @page A4, Footer mit Drucksache + Branding +
Seitenzahl 'Seite X von Y'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:48:19 +02:00
|
|
|
|
async def generate_html_report_v3(
|
|
|
|
|
|
assessment: Assessment,
|
|
|
|
|
|
output_path: Path,
|
|
|
|
|
|
bundesland: Optional[str] = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Render Antrags-PDF im neuen v3-Layout (single column, A4 portrait).
|
|
|
|
|
|
|
|
|
|
|
|
Reuses die v3-Layout-Logik (Score-Hero, Matrix mit Achsen-Labels,
|
|
|
|
|
|
Programm-Treue, Verbesserungen) und ergaenzt sie um die im PDF
|
|
|
|
|
|
notwendigen Adaptionen:
|
|
|
|
|
|
|
|
|
|
|
|
- Kein interaktiver Matrix-Klick → "Schwerpunkte erklaert"-Sektion
|
|
|
|
|
|
listet die Top-3 positiven und Top-3 negativen Felder mit ihren
|
|
|
|
|
|
LLM-generierten label/aspect-Texten unter der Matrix.
|
|
|
|
|
|
- Plenum-Votes werden best-effort geladen, inkl. Konsistenz-Hinweis
|
|
|
|
|
|
(Mehrheit deckt sich / gegen GWOE-Empfehlung).
|
|
|
|
|
|
- Online-Elemente (Share, Vote-treffend, Kommentare, News, Modals)
|
|
|
|
|
|
sind im Template gar nicht erst angelegt.
|
|
|
|
|
|
|
|
|
|
|
|
Template: app/templates/v3/pdf/antrag_pdf.html
|
|
|
|
|
|
"""
|
|
|
|
|
|
matrix_lookup = {e.field: {"rating": e.rating} for e in assessment.gwoe_matrix}
|
|
|
|
|
|
|
|
|
|
|
|
# Schwerpunkt-Felder mit Erklaerung: Top + Bottom Ratings.
|
|
|
|
|
|
sorted_matrix = sorted(
|
|
|
|
|
|
assessment.gwoe_matrix, key=lambda e: e.rating, reverse=True
|
|
|
|
|
|
)
|
|
|
|
|
|
matrix_top = [
|
|
|
|
|
|
{"field": e.field, "label": e.label, "aspect": e.aspect, "rating": e.rating}
|
|
|
|
|
|
for e in sorted_matrix if e.rating > 0
|
|
|
|
|
|
][:4]
|
|
|
|
|
|
matrix_bottom = [
|
|
|
|
|
|
{"field": e.field, "label": e.label, "aspect": e.aspect, "rating": e.rating}
|
|
|
|
|
|
for e in sorted(sorted_matrix, key=lambda e: e.rating) if e.rating < 0
|
|
|
|
|
|
][:4]
|
|
|
|
|
|
|
|
|
|
|
|
# Score-Color (gleich wie Scorecard)
|
|
|
|
|
|
s = assessment.gwoe_score
|
|
|
|
|
|
if s >= 8: score_color = "#1a7f37"
|
|
|
|
|
|
elif s >= 5: score_color = "#bf6c10"
|
|
|
|
|
|
else: score_color = "#9a2a2a"
|
|
|
|
|
|
|
|
|
|
|
|
parlament_name = ""
|
|
|
|
|
|
if bundesland and bundesland in BUNDESLAENDER:
|
|
|
|
|
|
parlament_name = BUNDESLAENDER[bundesland].parlament_name
|
|
|
|
|
|
|
|
|
|
|
|
# Plenum-Votes best-effort (Hintergrund-Job kann ohne DB-Pfad laufen,
|
|
|
|
|
|
# in dem Fall einfach keine Votes anzeigen).
|
|
|
|
|
|
plenum_votes: list[dict] = []
|
|
|
|
|
|
konsistenz_state: Optional[str] = None
|
|
|
|
|
|
konsistenz_decisive: Optional[str] = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
from .database import get_plenum_votes
|
|
|
|
|
|
plenum_votes = await get_plenum_votes(
|
|
|
|
|
|
bundesland or "NRW", assessment.drucksache,
|
|
|
|
|
|
)
|
|
|
|
|
|
if plenum_votes:
|
|
|
|
|
|
from .marker import consistency_state, decisive_outcome
|
|
|
|
|
|
konsistenz_state = consistency_state(
|
|
|
|
|
|
assessment.empfehlung.value, plenum_votes,
|
|
|
|
|
|
)
|
|
|
|
|
|
konsistenz_decisive = decisive_outcome(plenum_votes)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Plenum-Votes fuer PDF nicht ladbar (drucksache=%s): %s",
|
|
|
|
|
|
assessment.drucksache, exc,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-09 02:21:12 +02:00
|
|
|
|
# Pre-compute Heuchelei-/Opportunismus-Marker pro Fraktion.
|
|
|
|
|
|
# Jinja-Globals sind im Web ``heuchelei_score`` und ``opportunismus_score``;
|
|
|
|
|
|
# sie erwarten Dict-Listen (fraktions_scores aus _row_to_detail). Das
|
|
|
|
|
|
# Pydantic-Assessment.wahlprogramm_scores hat dieselben Daten, aber als
|
|
|
|
|
|
# Pydantic-Objekte. Wir mappen einmal um und rechnen die Marker für
|
|
|
|
|
|
# alle abstimmenden Fraktionen, damit das Template nur noch Lookup
|
|
|
|
|
|
# statt Logik macht.
|
|
|
|
|
|
fraktions_scores_dict = []
|
|
|
|
|
|
for fs in (assessment.wahlprogramm_scores or []):
|
|
|
|
|
|
fraktions_scores_dict.append({
|
|
|
|
|
|
"fraktion": fs.fraktion,
|
|
|
|
|
|
"wahlprogramm": {"score": fs.wahlprogramm.score if fs.wahlprogramm else None},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
from .marker import heuchelei_score as _h, opportunismus_score as _o
|
|
|
|
|
|
heuchelei_by_fraktion: dict[str, float] = {}
|
|
|
|
|
|
opportunismus_by_fraktion: dict[str, float] = {}
|
|
|
|
|
|
if plenum_votes and fraktions_scores_dict:
|
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
|
for v in plenum_votes:
|
|
|
|
|
|
for f in (v.get("fraktionen_nein") or []):
|
|
|
|
|
|
if f in seen:
|
|
|
|
|
|
continue
|
|
|
|
|
|
hv = _h(f, fraktions_scores_dict)
|
|
|
|
|
|
if hv is not None:
|
|
|
|
|
|
heuchelei_by_fraktion[f] = hv
|
|
|
|
|
|
for f in (v.get("fraktionen_ja") or []):
|
|
|
|
|
|
if f in seen:
|
|
|
|
|
|
continue
|
|
|
|
|
|
ov = _o(f, fraktions_scores_dict)
|
|
|
|
|
|
if ov is not None:
|
|
|
|
|
|
opportunismus_by_fraktion[f] = ov
|
|
|
|
|
|
|
feat: PDF-Generierung auf v3-Layout, Werkstatt-Link im Admin, Share nur bei Login
Drei Aufgaben in einem Schwung:
1. Werkstatt-Link im Admin
admin_stand bekommt eine Sektion 'Design-Werkstaetten' mit Link auf
/v2/scorecard-werkstatt — damit Admins den Live-Editor finden ohne
die URL kennen zu muessen.
2. Share-Block nur fuer angemeldete User
Der ganze Share-Block (Kopieren, Threads, Mastodon, LinkedIn,
Instagram, E-Mail, Scorecard, Stock-Bild) bekommt id=v2-share-block
und wird per initAuth() display:none/block geschaltet — analog zum
Comment-Form. Default im Markup: display:none, damit Gaeste ihn
nicht waehrend des Auth-Roundtrips kurz sehen. Funktioniert in v2
und v3 (gleicher JS-Handler via super-Inheritance).
3. PDF-Layout = v3-Layout
Neues Template v3/pdf/antrag_pdf.html (single column, A4) reused die
Visuallogik aus der Online-Detailseite:
- Score-Hero-Block mit Farb-Tint
- Matrix 5×5 mit Achsen-Labels (Werte oben, Berührungsgruppen links)
- Programm-Treue pro Fraktion mit Begruendung + Zitaten + Fallback-
Hinweis bei fehlenden Zitaten
- Verbesserungsvorschlaege mit Redline-Format
- Abstimmungsergebnis (best-effort via get_plenum_votes) inkl.
Konsistenz-Hinweis
Online-spezifisches gestrichen: Merken-Button, Vote-treffend, Share,
Kommentare, News-Box, Reanalyze, Historie, Modals.
NEU im PDF: 'Schwerpunkte erklaert'-Sektion direkt unter der Matrix.
Listet die Top-4 positiven und Top-4 negativen Matrix-Felder mit
ihrem LLM-generierten label + aspect — Ersatz fuer den interaktiven
Klick, der im PDF nicht funktioniert.
report.generate_html_report_v3() neu, generate_pdf_report() ruft
diese statt der alten Inline-HTML-Variante. Alte generate_html_report
bleibt als Fallback erhalten.
WeasyPrint rendert mit @page A4, Footer mit Drucksache + Branding +
Seitenzahl 'Seite X von Y'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:48:19 +02:00
|
|
|
|
template = _pdf_jinja.get_template("v3/pdf/antrag_pdf.html")
|
|
|
|
|
|
html = template.render(
|
|
|
|
|
|
assessment=assessment,
|
|
|
|
|
|
matrix_lookup=matrix_lookup,
|
|
|
|
|
|
matrix_top=matrix_top,
|
|
|
|
|
|
matrix_bottom=matrix_bottom,
|
|
|
|
|
|
score_color=score_color,
|
|
|
|
|
|
parlament_name=parlament_name,
|
|
|
|
|
|
bundesland=bundesland or "",
|
|
|
|
|
|
plenum_votes=plenum_votes,
|
|
|
|
|
|
konsistenz_state=konsistenz_state,
|
|
|
|
|
|
konsistenz_decisive=konsistenz_decisive,
|
2026-05-09 02:21:12 +02:00
|
|
|
|
heuchelei_by_fraktion=heuchelei_by_fraktion,
|
|
|
|
|
|
opportunismus_by_fraktion=opportunismus_by_fraktion,
|
feat: PDF-Generierung auf v3-Layout, Werkstatt-Link im Admin, Share nur bei Login
Drei Aufgaben in einem Schwung:
1. Werkstatt-Link im Admin
admin_stand bekommt eine Sektion 'Design-Werkstaetten' mit Link auf
/v2/scorecard-werkstatt — damit Admins den Live-Editor finden ohne
die URL kennen zu muessen.
2. Share-Block nur fuer angemeldete User
Der ganze Share-Block (Kopieren, Threads, Mastodon, LinkedIn,
Instagram, E-Mail, Scorecard, Stock-Bild) bekommt id=v2-share-block
und wird per initAuth() display:none/block geschaltet — analog zum
Comment-Form. Default im Markup: display:none, damit Gaeste ihn
nicht waehrend des Auth-Roundtrips kurz sehen. Funktioniert in v2
und v3 (gleicher JS-Handler via super-Inheritance).
3. PDF-Layout = v3-Layout
Neues Template v3/pdf/antrag_pdf.html (single column, A4) reused die
Visuallogik aus der Online-Detailseite:
- Score-Hero-Block mit Farb-Tint
- Matrix 5×5 mit Achsen-Labels (Werte oben, Berührungsgruppen links)
- Programm-Treue pro Fraktion mit Begruendung + Zitaten + Fallback-
Hinweis bei fehlenden Zitaten
- Verbesserungsvorschlaege mit Redline-Format
- Abstimmungsergebnis (best-effort via get_plenum_votes) inkl.
Konsistenz-Hinweis
Online-spezifisches gestrichen: Merken-Button, Vote-treffend, Share,
Kommentare, News-Box, Reanalyze, Historie, Modals.
NEU im PDF: 'Schwerpunkte erklaert'-Sektion direkt unter der Matrix.
Listet die Top-4 positiven und Top-4 negativen Matrix-Felder mit
ihrem LLM-generierten label + aspect — Ersatz fuer den interaktiven
Klick, der im PDF nicht funktioniert.
report.generate_html_report_v3() neu, generate_pdf_report() ruft
diese statt der alten Inline-HTML-Variante. Alte generate_html_report
bleibt als Fallback erhalten.
WeasyPrint rendert mit @page A4, Footer mit Drucksache + Branding +
Seitenzahl 'Seite X von Y'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:48:19 +02:00
|
|
|
|
)
|
|
|
|
|
|
output_path.write_text(html)
|
|
|
|
|
|
|
|
|
|
|
|
|
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 generate_pdf_report(
|
|
|
|
|
|
assessment: Assessment,
|
|
|
|
|
|
output_path: Path,
|
|
|
|
|
|
bundesland: Optional[str] = None,
|
|
|
|
|
|
) -> None:
|
Append original Antrag-PDF to GWÖ-Report (#9)
Extends generate_pdf_report() with a best-effort second stage that
appends the original Antrag PDF to the freshly rendered GWÖ-Report so
the analysis and its source document live in the same file.
Pipeline
1. WeasyPrint renders the report PDF as before.
2. _append_original_antrag() then:
- Skips silently if assessment.link is empty or non-HTTP (manual
uploads / pasted text leave nothing to fetch).
- Downloads the original PDF via httpx (30s timeout, follow redirects,
custom user agent).
- Validates the response is actually a PDF (Content-Length not relied
on; the magic bytes %PDF- are checked).
- Adds a single A4 separator page that says "Original-Antrag",
repeats the Drucksachen-ID and title, and either confirms the
append or shows the failure reason (HTTP code, network error,
parse error) plus the source URL.
- Appends the downloaded PDF via PyMuPDF doc.insert_pdf().
- Saves to a sibling .tmp file and atomically replaces the original
(PyMuPDF refuses non-incremental save into the same file).
Edge cases handled
- No link / pasted-text upload → no append, no divider, original report
unchanged.
- Download error / 404 / non-PDF response → divider page with explicit
error message and source URL, report still ships.
- PDF parse error → divider page without appended content, error logged.
- Hard failure during save → fall back to the original WeasyPrint PDF.
Verified live in production container against drucksache 8/6645
(Untrending Frauenhass, BÜNDNIS 90/DIE GRÜNEN LSA):
- Report 4 pages + 1 divider + 3 pages original = 8 pages total
- Divider correctly placed at index 4
- Page 5 starts with "(Ausgegeben am 24.02.2026) … Drucksache 8/6645 …
Antrag — Fraktion BÜNDNIS 90/DIE GRÜNEN — Untrending Frauenhass …"
- Negative test with a synthetic 404 link: 5 pages total, divider at
index 4 with "Original-PDF konnte nicht angehängt werden. Grund: HTTP
404".
Resolves #9.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:15:05 +02:00
|
|
|
|
"""Generate PDF report using WeasyPrint, then append the original Antrag.
|
|
|
|
|
|
|
|
|
|
|
|
Two-step pipeline:
|
|
|
|
|
|
|
|
|
|
|
|
1. Render the GWÖ-Report HTML and convert to PDF via WeasyPrint
|
|
|
|
|
|
(existing behaviour).
|
|
|
|
|
|
2. If ``assessment.link`` is a fetchable PDF URL, download it via
|
|
|
|
|
|
``httpx`` and append it after a separator page so the resulting
|
|
|
|
|
|
single file contains both the analysis and its source document
|
|
|
|
|
|
(issue #9).
|
|
|
|
|
|
|
|
|
|
|
|
The append step is best-effort: a missing/empty link is silently
|
|
|
|
|
|
skipped, network errors and parse errors fall back to a single
|
|
|
|
|
|
placeholder page so the report itself is always delivered.
|
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`` is forwarded to ``generate_html_report`` so the source
|
|
|
|
|
|
parlament name appears in the report header.
|
|
|
|
|
|
"""
|
feat: PDF-Generierung auf v3-Layout, Werkstatt-Link im Admin, Share nur bei Login
Drei Aufgaben in einem Schwung:
1. Werkstatt-Link im Admin
admin_stand bekommt eine Sektion 'Design-Werkstaetten' mit Link auf
/v2/scorecard-werkstatt — damit Admins den Live-Editor finden ohne
die URL kennen zu muessen.
2. Share-Block nur fuer angemeldete User
Der ganze Share-Block (Kopieren, Threads, Mastodon, LinkedIn,
Instagram, E-Mail, Scorecard, Stock-Bild) bekommt id=v2-share-block
und wird per initAuth() display:none/block geschaltet — analog zum
Comment-Form. Default im Markup: display:none, damit Gaeste ihn
nicht waehrend des Auth-Roundtrips kurz sehen. Funktioniert in v2
und v3 (gleicher JS-Handler via super-Inheritance).
3. PDF-Layout = v3-Layout
Neues Template v3/pdf/antrag_pdf.html (single column, A4) reused die
Visuallogik aus der Online-Detailseite:
- Score-Hero-Block mit Farb-Tint
- Matrix 5×5 mit Achsen-Labels (Werte oben, Berührungsgruppen links)
- Programm-Treue pro Fraktion mit Begruendung + Zitaten + Fallback-
Hinweis bei fehlenden Zitaten
- Verbesserungsvorschlaege mit Redline-Format
- Abstimmungsergebnis (best-effort via get_plenum_votes) inkl.
Konsistenz-Hinweis
Online-spezifisches gestrichen: Merken-Button, Vote-treffend, Share,
Kommentare, News-Box, Reanalyze, Historie, Modals.
NEU im PDF: 'Schwerpunkte erklaert'-Sektion direkt unter der Matrix.
Listet die Top-4 positiven und Top-4 negativen Matrix-Felder mit
ihrem LLM-generierten label + aspect — Ersatz fuer den interaktiven
Klick, der im PDF nicht funktioniert.
report.generate_html_report_v3() neu, generate_pdf_report() ruft
diese statt der alten Inline-HTML-Variante. Alte generate_html_report
bleibt als Fallback erhalten.
WeasyPrint rendert mit @page A4, Footer mit Drucksache + Branding +
Seitenzahl 'Seite X von Y'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:48:19 +02:00
|
|
|
|
# Step 1 — render the report itself, neues v3-Layout (single column,
|
|
|
|
|
|
# Score-Hero, Matrix mit Achsen-Labels, Schwerpunkte-erklaert).
|
2026-03-28 22:30:24 +01:00
|
|
|
|
html_path = output_path.with_suffix('.tmp.html')
|
feat: PDF-Generierung auf v3-Layout, Werkstatt-Link im Admin, Share nur bei Login
Drei Aufgaben in einem Schwung:
1. Werkstatt-Link im Admin
admin_stand bekommt eine Sektion 'Design-Werkstaetten' mit Link auf
/v2/scorecard-werkstatt — damit Admins den Live-Editor finden ohne
die URL kennen zu muessen.
2. Share-Block nur fuer angemeldete User
Der ganze Share-Block (Kopieren, Threads, Mastodon, LinkedIn,
Instagram, E-Mail, Scorecard, Stock-Bild) bekommt id=v2-share-block
und wird per initAuth() display:none/block geschaltet — analog zum
Comment-Form. Default im Markup: display:none, damit Gaeste ihn
nicht waehrend des Auth-Roundtrips kurz sehen. Funktioniert in v2
und v3 (gleicher JS-Handler via super-Inheritance).
3. PDF-Layout = v3-Layout
Neues Template v3/pdf/antrag_pdf.html (single column, A4) reused die
Visuallogik aus der Online-Detailseite:
- Score-Hero-Block mit Farb-Tint
- Matrix 5×5 mit Achsen-Labels (Werte oben, Berührungsgruppen links)
- Programm-Treue pro Fraktion mit Begruendung + Zitaten + Fallback-
Hinweis bei fehlenden Zitaten
- Verbesserungsvorschlaege mit Redline-Format
- Abstimmungsergebnis (best-effort via get_plenum_votes) inkl.
Konsistenz-Hinweis
Online-spezifisches gestrichen: Merken-Button, Vote-treffend, Share,
Kommentare, News-Box, Reanalyze, Historie, Modals.
NEU im PDF: 'Schwerpunkte erklaert'-Sektion direkt unter der Matrix.
Listet die Top-4 positiven und Top-4 negativen Matrix-Felder mit
ihrem LLM-generierten label + aspect — Ersatz fuer den interaktiven
Klick, der im PDF nicht funktioniert.
report.generate_html_report_v3() neu, generate_pdf_report() ruft
diese statt der alten Inline-HTML-Variante. Alte generate_html_report
bleibt als Fallback erhalten.
WeasyPrint rendert mit @page A4, Footer mit Drucksache + Branding +
Seitenzahl 'Seite X von Y'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:48:19 +02:00
|
|
|
|
await generate_html_report_v3(assessment, html_path, bundesland=bundesland)
|
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
|
|
|
|
try:
|
|
|
|
|
|
from weasyprint import HTML
|
|
|
|
|
|
HTML(filename=str(html_path)).write_pdf(str(output_path))
|
|
|
|
|
|
finally:
|
|
|
|
|
|
html_path.unlink(missing_ok=True)
|
Append original Antrag-PDF to GWÖ-Report (#9)
Extends generate_pdf_report() with a best-effort second stage that
appends the original Antrag PDF to the freshly rendered GWÖ-Report so
the analysis and its source document live in the same file.
Pipeline
1. WeasyPrint renders the report PDF as before.
2. _append_original_antrag() then:
- Skips silently if assessment.link is empty or non-HTTP (manual
uploads / pasted text leave nothing to fetch).
- Downloads the original PDF via httpx (30s timeout, follow redirects,
custom user agent).
- Validates the response is actually a PDF (Content-Length not relied
on; the magic bytes %PDF- are checked).
- Adds a single A4 separator page that says "Original-Antrag",
repeats the Drucksachen-ID and title, and either confirms the
append or shows the failure reason (HTTP code, network error,
parse error) plus the source URL.
- Appends the downloaded PDF via PyMuPDF doc.insert_pdf().
- Saves to a sibling .tmp file and atomically replaces the original
(PyMuPDF refuses non-incremental save into the same file).
Edge cases handled
- No link / pasted-text upload → no append, no divider, original report
unchanged.
- Download error / 404 / non-PDF response → divider page with explicit
error message and source URL, report still ships.
- PDF parse error → divider page without appended content, error logged.
- Hard failure during save → fall back to the original WeasyPrint PDF.
Verified live in production container against drucksache 8/6645
(Untrending Frauenhass, BÜNDNIS 90/DIE GRÜNEN LSA):
- Report 4 pages + 1 divider + 3 pages original = 8 pages total
- Divider correctly placed at index 4
- Page 5 starts with "(Ausgegeben am 24.02.2026) … Drucksache 8/6645 …
Antrag — Fraktion BÜNDNIS 90/DIE GRÜNEN — Untrending Frauenhass …"
- Negative test with a synthetic 404 link: 5 pages total, divider at
index 4 with "Original-PDF konnte nicht angehängt werden. Grund: HTTP
404".
Resolves #9.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:15:05 +02:00
|
|
|
|
|
|
|
|
|
|
# Step 2 — append the original Antrag (best-effort)
|
|
|
|
|
|
await _append_original_antrag(assessment, output_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _append_original_antrag(
|
|
|
|
|
|
assessment: Assessment,
|
|
|
|
|
|
report_path: Path,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Try to download the original Antrag PDF and append it to ``report_path``.
|
|
|
|
|
|
|
|
|
|
|
|
Failure modes (download error, non-PDF content, parse error) are
|
|
|
|
|
|
handled gracefully: a single placeholder page is appended noting the
|
|
|
|
|
|
issue, so the user always sees that an attempt was made.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import fitz # PyMuPDF
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
|
|
link = (assessment.link or "").strip()
|
|
|
|
|
|
if not link or not link.startswith(("http://", "https://")):
|
|
|
|
|
|
# Manual upload / pasted text — nothing to append.
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
download_error: Optional[str] = None
|
|
|
|
|
|
pdf_bytes: Optional[bytes] = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with httpx.AsyncClient(
|
|
|
|
|
|
timeout=30,
|
|
|
|
|
|
follow_redirects=True,
|
|
|
|
|
|
headers={"User-Agent": "Mozilla/5.0 GWOE-Antragspruefer"},
|
|
|
|
|
|
) as client:
|
|
|
|
|
|
resp = await client.get(link)
|
|
|
|
|
|
if resp.status_code != 200:
|
|
|
|
|
|
download_error = f"HTTP {resp.status_code}"
|
|
|
|
|
|
elif not resp.content[:5].startswith(b"%PDF-"):
|
|
|
|
|
|
download_error = f"kein PDF (Content-Type: {resp.headers.get('content-type', 'unknown')})"
|
|
|
|
|
|
else:
|
|
|
|
|
|
pdf_bytes = resp.content
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
download_error = f"Download-Fehler: {e}"
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
report_doc = fitz.open(report_path)
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Always insert a divider page so the user sees what comes next
|
|
|
|
|
|
_insert_divider_page(report_doc, assessment, download_error)
|
|
|
|
|
|
|
|
|
|
|
|
if pdf_bytes is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
src_doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
|
|
|
|
|
try:
|
|
|
|
|
|
report_doc.insert_pdf(src_doc)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
src_doc.close()
|
|
|
|
|
|
except Exception as e:
|
2026-04-10 17:05:12 +02:00
|
|
|
|
logger.exception("_append_original_antrag: PDF-Parse-Fehler für %s", assessment.drucksache)
|
Append original Antrag-PDF to GWÖ-Report (#9)
Extends generate_pdf_report() with a best-effort second stage that
appends the original Antrag PDF to the freshly rendered GWÖ-Report so
the analysis and its source document live in the same file.
Pipeline
1. WeasyPrint renders the report PDF as before.
2. _append_original_antrag() then:
- Skips silently if assessment.link is empty or non-HTTP (manual
uploads / pasted text leave nothing to fetch).
- Downloads the original PDF via httpx (30s timeout, follow redirects,
custom user agent).
- Validates the response is actually a PDF (Content-Length not relied
on; the magic bytes %PDF- are checked).
- Adds a single A4 separator page that says "Original-Antrag",
repeats the Drucksachen-ID and title, and either confirms the
append or shows the failure reason (HTTP code, network error,
parse error) plus the source URL.
- Appends the downloaded PDF via PyMuPDF doc.insert_pdf().
- Saves to a sibling .tmp file and atomically replaces the original
(PyMuPDF refuses non-incremental save into the same file).
Edge cases handled
- No link / pasted-text upload → no append, no divider, original report
unchanged.
- Download error / 404 / non-PDF response → divider page with explicit
error message and source URL, report still ships.
- PDF parse error → divider page without appended content, error logged.
- Hard failure during save → fall back to the original WeasyPrint PDF.
Verified live in production container against drucksache 8/6645
(Untrending Frauenhass, BÜNDNIS 90/DIE GRÜNEN LSA):
- Report 4 pages + 1 divider + 3 pages original = 8 pages total
- Divider correctly placed at index 4
- Page 5 starts with "(Ausgegeben am 24.02.2026) … Drucksache 8/6645 …
Antrag — Fraktion BÜNDNIS 90/DIE GRÜNEN — Untrending Frauenhass …"
- Negative test with a synthetic 404 link: 5 pages total, divider at
index 4 with "Original-PDF konnte nicht angehängt werden. Grund: HTTP
404".
Resolves #9.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:15:05 +02:00
|
|
|
|
|
|
|
|
|
|
# PyMuPDF refuses to overwrite the source file in non-incremental
|
|
|
|
|
|
# mode — write to a sibling temp file and atomically replace.
|
|
|
|
|
|
tmp_path = report_path.with_suffix(report_path.suffix + ".tmp")
|
|
|
|
|
|
report_doc.save(
|
|
|
|
|
|
str(tmp_path),
|
|
|
|
|
|
deflate=True,
|
|
|
|
|
|
garbage=3,
|
|
|
|
|
|
)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
report_doc.close()
|
|
|
|
|
|
tmp_path.replace(report_path)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
# Hard failure — leave the original report file untouched.
|
2026-04-10 17:05:12 +02:00
|
|
|
|
logger.exception("_append_original_antrag: Konnte Report nicht erweitern für %s", assessment.drucksache)
|
Append original Antrag-PDF to GWÖ-Report (#9)
Extends generate_pdf_report() with a best-effort second stage that
appends the original Antrag PDF to the freshly rendered GWÖ-Report so
the analysis and its source document live in the same file.
Pipeline
1. WeasyPrint renders the report PDF as before.
2. _append_original_antrag() then:
- Skips silently if assessment.link is empty or non-HTTP (manual
uploads / pasted text leave nothing to fetch).
- Downloads the original PDF via httpx (30s timeout, follow redirects,
custom user agent).
- Validates the response is actually a PDF (Content-Length not relied
on; the magic bytes %PDF- are checked).
- Adds a single A4 separator page that says "Original-Antrag",
repeats the Drucksachen-ID and title, and either confirms the
append or shows the failure reason (HTTP code, network error,
parse error) plus the source URL.
- Appends the downloaded PDF via PyMuPDF doc.insert_pdf().
- Saves to a sibling .tmp file and atomically replaces the original
(PyMuPDF refuses non-incremental save into the same file).
Edge cases handled
- No link / pasted-text upload → no append, no divider, original report
unchanged.
- Download error / 404 / non-PDF response → divider page with explicit
error message and source URL, report still ships.
- PDF parse error → divider page without appended content, error logged.
- Hard failure during save → fall back to the original WeasyPrint PDF.
Verified live in production container against drucksache 8/6645
(Untrending Frauenhass, BÜNDNIS 90/DIE GRÜNEN LSA):
- Report 4 pages + 1 divider + 3 pages original = 8 pages total
- Divider correctly placed at index 4
- Page 5 starts with "(Ausgegeben am 24.02.2026) … Drucksache 8/6645 …
Antrag — Fraktion BÜNDNIS 90/DIE GRÜNEN — Untrending Frauenhass …"
- Negative test with a synthetic 404 link: 5 pages total, divider at
index 4 with "Original-PDF konnte nicht angehängt werden. Grund: HTTP
404".
Resolves #9.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:15:05 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _insert_divider_page(
|
|
|
|
|
|
report_doc, # fitz.Document
|
|
|
|
|
|
assessment: Assessment,
|
|
|
|
|
|
download_error: Optional[str],
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Append a single A4 separator page that introduces the original Antrag.
|
|
|
|
|
|
|
|
|
|
|
|
Uses PyMuPDF's text drawing API directly so we don't need a second
|
|
|
|
|
|
WeasyPrint round-trip just for one page.
|
|
|
|
|
|
"""
|
|
|
|
|
|
page = report_doc.new_page(width=595, height=842) # A4
|
|
|
|
|
|
margin_left = 60
|
|
|
|
|
|
y = 200
|
|
|
|
|
|
|
|
|
|
|
|
# Title
|
|
|
|
|
|
page.insert_text(
|
|
|
|
|
|
(margin_left, y),
|
|
|
|
|
|
"Original-Antrag",
|
|
|
|
|
|
fontsize=24,
|
|
|
|
|
|
fontname="helv",
|
|
|
|
|
|
color=(0 / 255, 157 / 255, 165 / 255), # var(--color-blue)
|
|
|
|
|
|
)
|
|
|
|
|
|
y += 38
|
|
|
|
|
|
|
|
|
|
|
|
# Drucksache
|
|
|
|
|
|
page.insert_text(
|
|
|
|
|
|
(margin_left, y),
|
|
|
|
|
|
f"Drucksache {assessment.drucksache}",
|
|
|
|
|
|
fontsize=14,
|
|
|
|
|
|
fontname="helv",
|
|
|
|
|
|
color=(0.35, 0.35, 0.35),
|
|
|
|
|
|
)
|
|
|
|
|
|
y += 22
|
|
|
|
|
|
|
|
|
|
|
|
# Title (truncated to ~75 chars to fit one line)
|
|
|
|
|
|
title = assessment.title or ""
|
|
|
|
|
|
if len(title) > 75:
|
|
|
|
|
|
title = title[:72] + "…"
|
|
|
|
|
|
page.insert_text(
|
|
|
|
|
|
(margin_left, y),
|
|
|
|
|
|
title,
|
|
|
|
|
|
fontsize=11,
|
|
|
|
|
|
fontname="helv",
|
|
|
|
|
|
color=(0.35, 0.35, 0.35),
|
|
|
|
|
|
)
|
|
|
|
|
|
y += 40
|
|
|
|
|
|
|
|
|
|
|
|
if download_error:
|
|
|
|
|
|
page.insert_text(
|
|
|
|
|
|
(margin_left, y),
|
|
|
|
|
|
"⚠ Original-PDF konnte nicht angehängt werden.",
|
|
|
|
|
|
fontsize=11,
|
|
|
|
|
|
fontname="helv",
|
|
|
|
|
|
color=(0.82, 0.0, 0.0),
|
|
|
|
|
|
)
|
|
|
|
|
|
y += 18
|
|
|
|
|
|
page.insert_text(
|
|
|
|
|
|
(margin_left, y),
|
|
|
|
|
|
f"Grund: {download_error}",
|
|
|
|
|
|
fontsize=10,
|
|
|
|
|
|
fontname="helv",
|
|
|
|
|
|
color=(0.5, 0.5, 0.5),
|
|
|
|
|
|
)
|
|
|
|
|
|
y += 18
|
|
|
|
|
|
if assessment.link:
|
|
|
|
|
|
page.insert_text(
|
|
|
|
|
|
(margin_left, y),
|
|
|
|
|
|
f"Quelle: {assessment.link[:90]}",
|
|
|
|
|
|
fontsize=9,
|
|
|
|
|
|
fontname="helv",
|
|
|
|
|
|
color=(0.5, 0.5, 0.5),
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
page.insert_text(
|
|
|
|
|
|
(margin_left, y),
|
|
|
|
|
|
"Die folgenden Seiten enthalten den unveränderten Originalantrag.",
|
|
|
|
|
|
fontsize=11,
|
|
|
|
|
|
fontname="helv",
|
|
|
|
|
|
color=(0.35, 0.35, 0.35),
|
|
|
|
|
|
)
|