Features: - GWÖ-Matrix 2.0 Analyse für NRW-Landtagsanträge - Verbesserungsvorschläge im Redline-Format (Original/Vorschlag/Begründung) - Wahlprogramm- und Parteiprogrammtreue-Bewertung - Landtag-Suche via OPAL-API - Tag-Wolke mit Multi-Select Filter - Partei-Filter mit Durchschnittswerten - PDF-Report-Generierung - Security Headers (CSP, X-Frame-Options, etc.) - Persistente SQLite-DB via Docker Volumes Tech Stack: - FastAPI + Jinja2 - Qwen LLM via DashScope API - SQLite + aiosqlite - WeasyPrint für PDF - Docker Compose mit Traefik
428 lines
13 KiB
Python
428 lines
13 KiB
Python
"""Report generation for HTML and PDF output."""
|
||
|
||
import subprocess
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
from jinja2 import Environment, FileSystemLoader
|
||
|
||
from .models import Assessment, MATRIX_LABELS, EMPFEHLUNG_CONFIG
|
||
|
||
# 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:
|
||
"""Convert numeric rating to symbol."""
|
||
if rating >= 2:
|
||
return "++"
|
||
if rating == 1:
|
||
return "+"
|
||
if rating == 0:
|
||
return "○"
|
||
if rating == -1:
|
||
return "−"
|
||
return "−−"
|
||
|
||
|
||
def format_redline_html(text: str) -> str:
|
||
"""Convert redline markup to HTML."""
|
||
import re
|
||
# **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
|
||
|
||
|
||
def build_matrix_html(assessment: Assessment) -> str:
|
||
"""Build HTML matrix table."""
|
||
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)
|
||
css_class = "positive" if entry.rating > 0 else ("negative" if entry.rating < 0 else "neutral")
|
||
html.append(f'<td class="{css_class}" title="{entry.aspect}">{symbol}</td>')
|
||
else:
|
||
html.append('<td></td>')
|
||
html.append('</tr>')
|
||
|
||
html.append('</tbody></table>')
|
||
return '\n'.join(html)
|
||
|
||
|
||
async def generate_html_report(assessment: Assessment, output_path: Path) -> None:
|
||
"""Generate HTML report."""
|
||
|
||
empf_config = EMPFEHLUNG_CONFIG.get(assessment.empfehlung.value, {})
|
||
|
||
html = f"""<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>GWÖ-Antragsprüfung: {assessment.title}</title>
|
||
<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;
|
||
}}
|
||
|
||
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;
|
||
}}
|
||
|
||
.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;
|
||
}}
|
||
|
||
.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>
|
||
<h1>{assessment.title}</h1>
|
||
</div>
|
||
|
||
<div class="meta-box">
|
||
<strong>Drucksache:</strong> {assessment.drucksache} |
|
||
<strong>Datum:</strong> {assessment.datum} |
|
||
<strong>Fraktion(en):</strong> {', '.join(assessment.fraktionen)} |
|
||
<strong>GWÖ-Score:</strong> <span style="color: {get_score_color(assessment.gwoe_score)}; font-weight: bold;">{assessment.gwoe_score}/10</span>
|
||
</div>
|
||
|
||
<div class="empfehlung-box">
|
||
<span class="symbol">{empf_config.get('symbol', '[?]')}</span>
|
||
<span class="text"><strong>Empfehlung:</strong> {assessment.empfehlung.value}</span>
|
||
</div>
|
||
|
||
<h2>Der Antrag im Überblick</h2>
|
||
<p>{assessment.antrag_zusammenfassung or 'Keine Zusammenfassung verfügbar.'}</p>
|
||
|
||
{('<ul>' + ''.join(f'<li>{k}</li>' for k in assessment.antrag_kernpunkte) + '</ul>') if assessment.antrag_kernpunkte else ''}
|
||
|
||
<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>
|
||
|
||
<div class="score-bar">
|
||
<div class="score-bar-fill" style="width: {assessment.gwoe_score * 10}%; background: {get_score_color(assessment.gwoe_score)};"></div>
|
||
</div>
|
||
|
||
<p><strong>Begründung:</strong> {assessment.gwoe_begruendung}</p>
|
||
<p><strong>Schwerpunkte:</strong> {', '.join(assessment.gwoe_schwerpunkt)}</p>
|
||
|
||
<h2>Matrix-Zuordnung (Matrix 2.0 für Gemeinden)</h2>
|
||
|
||
{build_matrix_html(assessment)}
|
||
|
||
<p style="font-size: 7pt; color: #999;">
|
||
<strong>Legende:</strong> ++ stark fördernd, + fördernd, ○ neutral, − widersprechend, −− stark widersprechend
|
||
</p>
|
||
|
||
<h3>Berührte Themenfelder</h3>
|
||
<ul>
|
||
{''.join(f'<li><strong>{e.field}:</strong> {e.aspect} [{get_rating_symbol(e.rating)}]</li>' for e in assessment.gwoe_matrix)}
|
||
</ul>
|
||
|
||
<h2>Programmtreue</h2>
|
||
|
||
{''.join(f'''
|
||
<h3>{s.fraktion} {' (Antragsteller)' if s.ist_antragsteller else ''}{' (Regierung)' if s.ist_regierung else ''}</h3>
|
||
<p><strong>Wahlprogramm:</strong> {s.wahlprogramm.score}/10 — {s.wahlprogramm.begruendung}</p>
|
||
<p><strong>Parteiprogramm:</strong> {s.parteiprogramm.score}/10 — {s.parteiprogramm.begruendung}</p>
|
||
''' for s in assessment.wahlprogramm_scores)}
|
||
|
||
<h2>Verbesserungsvorschläge</h2>
|
||
|
||
{''.join(f'''
|
||
<div class="verbesserung">
|
||
<div class="original"><strong>Original:</strong><br>{v.original}</div>
|
||
<div class="vorschlag"><strong>Vorschlag:</strong><br>{format_redline_html(v.vorschlag)}</div>
|
||
<div style="font-style: italic; margin-top: 0.5rem;">{v.begruendung}</div>
|
||
</div>
|
||
''' for v in assessment.verbesserungen) or '<p>Keine Verbesserungsvorschläge.</p>'}
|
||
|
||
<h2>Zusammenfassung</h2>
|
||
|
||
<div class="two-columns">
|
||
<div class="staerken">
|
||
<h3 style="color: var(--color-green);">Stärken</h3>
|
||
<ul>
|
||
{''.join(f'<li>{s}</li>' for s in assessment.staerken) or '<li>(keine)</li>'}
|
||
</ul>
|
||
</div>
|
||
<div class="schwaechen">
|
||
<h3 style="color: var(--color-orange);">Schwächen</h3>
|
||
<ul>
|
||
{''.join(f'<li>{s}</li>' for s in assessment.schwaechen) or '<li>(keine)</li>'}
|
||
</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)
|
||
|
||
|
||
async def generate_pdf_report(assessment: Assessment, output_path: Path) -> None:
|
||
"""Generate PDF report using WeasyPrint."""
|
||
# First generate HTML
|
||
html_path = output_path.with_suffix('.tmp.html')
|
||
await generate_html_report(assessment, html_path)
|
||
|
||
try:
|
||
from weasyprint import HTML
|
||
HTML(filename=str(html_path)).write_pdf(str(output_path))
|
||
finally:
|
||
html_path.unlink(missing_ok=True)
|