feat(audit-report): Exec-Summary, Top-N je Modul, Statistik, Gesamtanalyse
User-Feedback umgesetzt: Cookie-Titel-Fix (rendern nicht mehr als nacktes "Befund" — Titel aus cookie/type/vendor), Executive Summary oben, je Modul Statistik (Counts + Severity-Balken + MCs) + nur Top-3 Befunde + Verweis auf "N weitere" mit Frontend-Link (snapshot_id) + Zwischenfazit, Browser-Übersicht, Gesamtanalyse, klarerer "Grenzen"-Satz, Report-Versionsnummer. 6 Tests grün. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,24 +1,27 @@
|
||||
"""Audit-Textreport — deterministischer Section-Assembler + Markdown-Renderer.
|
||||
"""Audit-Textreport — deterministischer Section-Assembler + MD/PDF-Renderer.
|
||||
|
||||
Erzeugt aus den bereits vorhandenen Modul-Ergebnissen eines Snapshots (Cookie-,
|
||||
Impressum-, DSE-, AGB-Check + Browser-Matrix/Cross-Findings) einen firmen-tauglichen
|
||||
Bericht: Einleitung, Testumfang & Methodik, Management-Summary, Detailbefunde je
|
||||
Modul, Maßnahmen, Rechtlicher Hinweis. KEIN Re-Crawl, KEIN LLM — reine Aufbereitung.
|
||||
Erzeugt aus den Modul-Ergebnissen eines Snapshots (Cookie-, Impressum-, DSE-,
|
||||
AGB-Check + Browser-Matrix/Cross-Findings) einen firmen-tauglichen Bericht:
|
||||
Executive Summary, Testumfang & Methodik, Detailbefunde je Modul (mit Statistik,
|
||||
Severity-Balken, Top-Befunden + Verweis auf alle, Zwischenfazit), Browser-
|
||||
Übersicht, Gesamtanalyse, Maßnahmen, Rechtlicher Hinweis. KEIN Re-Crawl, KEIN LLM.
|
||||
|
||||
Leitplanken (siehe [[feedback_breakpilot_tonalitaet]], [[project_compliance_data_model]]):
|
||||
- Co-Pilot-Ton: „Wir analysieren – Sie entscheiden mit DSB/Anwalt." Keine Panik,
|
||||
keine Bußgeld-Drohung als Aufmacher. Wahrscheinlichkeit statt Garantie.
|
||||
- Keine Normtexte reproduzieren — nur Norm-Bezug benennen.
|
||||
- Applicability ≠ Compliance, Unknown ≠ Fail: Konfidenz + Status transparent.
|
||||
Leitplanken ([[feedback_breakpilot_tonalitaet]], [[project_compliance_data_model]]):
|
||||
Co-Pilot-Ton, Wahrscheinlichkeit statt Garantie, Applicability ≠ Verstoß,
|
||||
Tracking statt Cookie-Rohzahl, keine Normtexte reproduzieren.
|
||||
|
||||
`assemble_report` ist PUR (dict→dict) → ohne DB/Netz unit-testbar. Erweitern =
|
||||
neue Sektion in `_build_sections` ergänzen.
|
||||
`assemble_report` ist PUR (dict→dict) → unit-testbar. Renderer iterieren generisch
|
||||
über `sections` → neue Sektion = nur in `_build_sections` ergänzen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
REPORT_VERSION = "1.1"
|
||||
_FRONTEND_PATH = "/sdk/agent/snapshots"
|
||||
_TOP_N = 3
|
||||
|
||||
_SEV_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4}
|
||||
_SEV_LABEL = {"CRITICAL": "Kritisch", "HIGH": "Hoch", "MEDIUM": "Mittel",
|
||||
"LOW": "Niedrig", "INFO": "Hinweis"}
|
||||
@@ -27,6 +30,16 @@ _MODULE_LABEL = {
|
||||
"dse": "Datenschutzerklärung", "agb": "AGB",
|
||||
"browser": "Browser-Verhalten (Cookie-Banner)",
|
||||
}
|
||||
_COOKIE_TYPE_LABEL = {
|
||||
"vague_duration": "Speicherdauer unklar",
|
||||
"tracker_as_necessary": "Als notwendig deklariert, laut Bibliothek Tracker",
|
||||
"missing_purpose": "Zweck nicht angegeben",
|
||||
"excessive_lifetime": "Laufzeit über üblichem Maß",
|
||||
"missing_retention": "Speicherdauer fehlt",
|
||||
"missing_opt_out": "Opt-out-Hinweis fehlt",
|
||||
"undisclosed": "Nicht in der Cookie-Erklärung deklariert",
|
||||
"category_mismatch": "Kategorie weicht von der Realität ab",
|
||||
}
|
||||
_DISCLAIMER = (
|
||||
"Dieser Bericht wurde von BreakPilot automatisiert erstellt und bewertet "
|
||||
"Wahrscheinlichkeiten, keine Rechtsgewissheit. Er ersetzt keine "
|
||||
@@ -38,17 +51,22 @@ _DISCLAIMER = (
|
||||
|
||||
|
||||
def _norm_finding(f: Any) -> dict:
|
||||
"""Befund (dict/obj) → einheitliche Form für den Report."""
|
||||
if not isinstance(f, dict):
|
||||
f = getattr(f, "__dict__", {}) or {}
|
||||
title = f.get("title") or f.get("text") or f.get("message") or ""
|
||||
if not title and f.get("cookie"):
|
||||
lbl = _COOKIE_TYPE_LABEL.get(f.get("type", ""),
|
||||
f.get("type") or "Cookie-Befund")
|
||||
ven = f" ({f['vendor']})" if f.get("vendor") else ""
|
||||
title = f"{f['cookie']}{ven}: {lbl}"
|
||||
sev = (f.get("severity") or "MEDIUM").upper()
|
||||
return {
|
||||
"title": f.get("title") or f.get("text") or f.get("message") or "Befund",
|
||||
"title": title or "Befund",
|
||||
"severity": sev if sev in _SEV_ORDER else "MEDIUM",
|
||||
"status": f.get("status") or "",
|
||||
"legal_ref": f.get("legal_ref") or f.get("legal_reference") or "",
|
||||
"measure": f.get("measure") or f.get("recommendation") or "",
|
||||
"confidence": f.get("confidence"),
|
||||
"measure": (f.get("measure") or f.get("remediation")
|
||||
or f.get("recommendation") or ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -56,13 +74,12 @@ def _module_findings(mod: Optional[dict]) -> list[dict]:
|
||||
if not mod:
|
||||
return []
|
||||
out = [_norm_finding(f) for f in (mod.get("findings") or [])]
|
||||
# Browser-Modul trägt seine Befunde in cross_findings.
|
||||
for cf in (mod.get("cross_findings") or []):
|
||||
n = _norm_finding(cf)
|
||||
if cf.get("detail"):
|
||||
n["title"] = f"{n['title']} — {cf['detail']}"
|
||||
out.append(n)
|
||||
return out
|
||||
return sorted(out, key=lambda f: _SEV_ORDER.get(f["severity"], 2))
|
||||
|
||||
|
||||
def _sev_counts(findings: list[dict]) -> dict:
|
||||
@@ -72,41 +89,84 @@ def _sev_counts(findings: list[dict]) -> dict:
|
||||
return c
|
||||
|
||||
|
||||
def _verdict(counts: dict) -> str:
|
||||
if counts.get("CRITICAL") or counts.get("HIGH", 0) >= 3:
|
||||
def _counts_line(c: dict) -> str:
|
||||
return " · ".join(f"{_SEV_LABEL[k]}: {c[k]}"
|
||||
for k in ("CRITICAL", "HIGH", "MEDIUM", "LOW") if c.get(k))
|
||||
|
||||
|
||||
def _sev_bar(c: dict) -> str:
|
||||
rows = []
|
||||
for k in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
|
||||
n = c.get(k, 0)
|
||||
if n:
|
||||
rows.append(f"{_SEV_LABEL[k]:7} {n:>4} {'▇' * min(n, 28)}")
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def _verdict(c: dict) -> str:
|
||||
if c.get("CRITICAL") or c.get("HIGH", 0) >= 3:
|
||||
return ("Es bestehen mehrere Punkte mit erhöhtem Handlungsbedarf — eine "
|
||||
"kurzfristige Klärung mit DSB/Anwalt ist empfehlenswert.")
|
||||
if counts.get("HIGH"):
|
||||
if c.get("HIGH"):
|
||||
return ("Einzelne Punkte sollten priorisiert geprüft werden; das "
|
||||
"Gesamtbild ist handhabbar.")
|
||||
if counts.get("MEDIUM"):
|
||||
return ("Überwiegend kleinere Hinweise — mit moderatem Aufwand "
|
||||
"nachschärfbar.")
|
||||
return ("Im Prüfumfang wurden keine wesentlichen Auffälligkeiten "
|
||||
"festgestellt.")
|
||||
if c.get("MEDIUM"):
|
||||
return "Überwiegend kleinere Hinweise — mit moderatem Aufwand nachschärfbar."
|
||||
return "Im Prüfumfang wurden keine wesentlichen Auffälligkeiten festgestellt."
|
||||
|
||||
|
||||
def _fmt_finding(f: dict) -> str:
|
||||
extra = []
|
||||
if f["status"]:
|
||||
extra.append(f"Status: {f['status']}")
|
||||
if f["legal_ref"]:
|
||||
extra.append(f["legal_ref"])
|
||||
tail = f" _( {' · '.join(extra)} )_" if extra else ""
|
||||
return f"- **[{_SEV_LABEL[f['severity']]}]** {f['title']}{tail}"
|
||||
|
||||
|
||||
def assemble_report(meta: dict, modules: dict) -> dict:
|
||||
"""meta: {site_label, site_domain, created_at, check_id, scan_context}.
|
||||
modules: {cookie|impressum|dse|agb|browser: <modul-ergebnis-dict oder None>}.
|
||||
Rückgabe: {meta, generated_for, sections:[{title, level, body, findings?}]}."""
|
||||
present = [k for k in ("cookie", "impressum", "dse", "agb", "browser")
|
||||
if modules.get(k)]
|
||||
all_findings = [f for k in present for f in _module_findings(modules.get(k))]
|
||||
per_mod = {k: _module_findings(modules.get(k)) for k in present}
|
||||
all_findings = [f for k in present for f in per_mod[k]]
|
||||
counts = _sev_counts(all_findings)
|
||||
return {
|
||||
"meta": meta,
|
||||
"meta": {**meta, "report_version": REPORT_VERSION},
|
||||
"modules_present": present,
|
||||
"totals": {"findings": len(all_findings), "by_severity": counts},
|
||||
"sections": _build_sections(meta, modules, present, all_findings, counts),
|
||||
"sections": _build_sections(meta, modules, present, per_mod,
|
||||
all_findings, counts),
|
||||
}
|
||||
|
||||
|
||||
def _build_sections(meta, modules, present, all_findings, counts) -> list[dict]:
|
||||
def _frontend_link(meta: dict) -> str:
|
||||
sid = meta.get("snapshot_id") or ""
|
||||
return f"{_FRONTEND_PATH}/{sid}" if sid else _FRONTEND_PATH
|
||||
|
||||
|
||||
def _build_sections(meta, modules, present, per_mod, all_findings, counts):
|
||||
site = meta.get("site_label") or meta.get("site_domain") or "die Website"
|
||||
when = (meta.get("created_at") or "")[:16].replace("T", " ")
|
||||
link = _frontend_link(meta)
|
||||
sec: list[dict] = []
|
||||
|
||||
# ── Executive Summary ────────────────────────────────────────────────
|
||||
top = [f for f in all_findings if f["severity"] in ("CRITICAL", "HIGH")][:5]
|
||||
es = [_verdict(counts), "",
|
||||
f"**Geprüfte Bereiche:** {len(present)} · "
|
||||
f"**Befunde gesamt:** {len(all_findings)}"
|
||||
f"{' (' + _counts_line(counts) + ')' if _counts_line(counts) else ''}."]
|
||||
if top:
|
||||
es += ["", "**Wichtigste Punkte:**"] + [_fmt_finding(f) for f in top]
|
||||
es += ["", f"Die vollständigen Befunde sind interaktiv im BreakPilot-Frontend "
|
||||
f"einsehbar: `{link}`.",
|
||||
"",
|
||||
"_Einstufung nach dem Prinzip Anwendbarkeit ≠ Verstoß: Ein Befund "
|
||||
"markiert einen zu klärenden Punkt, kein automatisches Bußgeldrisiko._"]
|
||||
sec.append({"title": "Executive Summary", "level": 2, "body": "\n".join(es)})
|
||||
|
||||
# ── Einleitung ───────────────────────────────────────────────────────
|
||||
sec.append({"title": "Einleitung", "level": 2, "body": (
|
||||
f"Dieser Bericht fasst die automatisierte Compliance-Analyse von "
|
||||
f"**{site}** ({meta.get('site_domain', '')}) zusammen, durchgeführt mit "
|
||||
@@ -115,6 +175,7 @@ def _build_sections(meta, modules, present, all_findings, counts) -> list[dict]:
|
||||
f"informationsrechtliche Handlungsfelder als Grundlage für die "
|
||||
f"gemeinsame Bewertung mit Ihrem DSB bzw. Ihrer Rechtsberatung.")})
|
||||
|
||||
# ── Testumfang & Methodik (klarerer Grenzen-Satz) ────────────────────
|
||||
mods = ", ".join(_MODULE_LABEL[k] for k in present) or "—"
|
||||
sec.append({"title": "Testumfang & Methodik", "level": 2, "body": (
|
||||
f"**Geprüfte Bereiche:** {mods}.\n\n"
|
||||
@@ -124,82 +185,116 @@ def _build_sections(meta, modules, present, all_findings, counts) -> list[dict]:
|
||||
f"tatsächlichen Verhaltens in mehreren Browser-Engines. Bewertet wird "
|
||||
f"das **nicht-essentielle Tracking** (technisch notwendige Cookies sind "
|
||||
f"ausgenommen, § 25 Abs. 2 TDDDG).\n\n"
|
||||
f"**Grenzen:** Geprüft wurde der erfasste Stand zum Analysezeitpunkt; "
|
||||
f"nicht erfasste Bereiche, eingeloggte Strecken oder dynamische Inhalte "
|
||||
f"sind nicht abschließend abgedeckt. Befunde sind Wahrscheinlichkeiten, "
|
||||
f"keine abschließende rechtliche Feststellung.")})
|
||||
f"**Was dieser Bericht nicht abdeckt:** Geprüft wurde ausschließlich der "
|
||||
f"öffentlich erreichbare Stand zum Analysezeitpunkt. **Nicht** enthalten "
|
||||
f"sind Bereiche hinter einem Login, erst per Interaktion nachgeladene "
|
||||
f"Inhalte sowie Seiten, die nicht erfasst wurden. Die Befunde sind "
|
||||
f"Einschätzungen mit Wahrscheinlichkeit, keine abschließende rechtliche "
|
||||
f"Feststellung.")})
|
||||
|
||||
sev_line = " · ".join(
|
||||
f"{_SEV_LABEL[k]}: {counts[k]}" for k in ("CRITICAL", "HIGH", "MEDIUM", "LOW")
|
||||
if counts.get(k))
|
||||
sec.append({"title": "Management-Summary", "level": 2, "body": (
|
||||
f"{_verdict(counts)}\n\n"
|
||||
f"**Befunde gesamt:** {len(all_findings)}"
|
||||
f"{' (' + sev_line + ')' if sev_line else ''}.\n\n"
|
||||
f"Die Einstufung folgt dem Prinzip *Anwendbarkeit ≠ Verstoß*: Ein Befund "
|
||||
f"markiert einen zu klärenden Punkt, kein automatisches Bußgeldrisiko.")})
|
||||
|
||||
det: list[dict] = [{"title": "Detailbefunde", "level": 2, "body": ""}]
|
||||
# ── Detailbefunde je Modul ───────────────────────────────────────────
|
||||
sec.append({"title": "Detailbefunde", "level": 2, "body": (
|
||||
f"Pro Bereich werden die wichtigsten Befunde gezeigt; die vollständige "
|
||||
f"Liste ist interaktiv im Frontend einsehbar (`{link}`).")})
|
||||
for k in present:
|
||||
fs = sorted(_module_findings(modules.get(k)),
|
||||
key=lambda f: _SEV_ORDER.get(f["severity"], 2))
|
||||
if not fs:
|
||||
det.append({"title": _MODULE_LABEL[k], "level": 3,
|
||||
"body": "Keine Auffälligkeiten im Prüfumfang."})
|
||||
continue
|
||||
lines = []
|
||||
for f in fs:
|
||||
extra = []
|
||||
if f["status"]:
|
||||
extra.append(f"Status: {f['status']}")
|
||||
if f["legal_ref"]:
|
||||
extra.append(f["legal_ref"])
|
||||
tail = f" _( {' · '.join(extra)} )_" if extra else ""
|
||||
lines.append(f"- **[{_SEV_LABEL[f['severity']]}]** {f['title']}{tail}")
|
||||
det.append({"title": _MODULE_LABEL[k], "level": 3,
|
||||
"body": "\n".join(lines)})
|
||||
sec.extend(det)
|
||||
sec.append(_module_section(k, modules.get(k), per_mod[k], link))
|
||||
|
||||
measures = []
|
||||
for f in sorted(all_findings, key=lambda f: _SEV_ORDER.get(f["severity"], 2)):
|
||||
if f["measure"]:
|
||||
measures.append(f"- **[{_SEV_LABEL[f['severity']]}]** {f['measure']}")
|
||||
# ── Browser-Übersicht ────────────────────────────────────────────────
|
||||
bo = _browser_overview(modules.get("browser"))
|
||||
if bo:
|
||||
sec.append({"title": "Browser-Übersicht", "level": 2, "body": bo})
|
||||
|
||||
# ── Gesamtanalyse ────────────────────────────────────────────────────
|
||||
worst = max(present, key=lambda k: _sev_counts(per_mod[k]).get("HIGH", 0),
|
||||
default=None) if present else None
|
||||
ga = [_verdict(counts), ""]
|
||||
if worst and _sev_counts(per_mod[worst]).get("HIGH"):
|
||||
ga.append(f"Der größte Handlungsbedarf liegt im Bereich "
|
||||
f"**{_MODULE_LABEL[worst]}**.")
|
||||
ga.append("Empfohlenes Vorgehen: zuerst die als „Hoch“ markierten Punkte mit "
|
||||
"DSB/Anwalt klären, danach die mittleren Hinweise abarbeiten. Die "
|
||||
"konkreten Schritte stehen im Abschnitt „Empfohlene Maßnahmen“.")
|
||||
sec.append({"title": "Gesamtanalyse", "level": 2, "body": "\n".join(ga)})
|
||||
|
||||
# ── Maßnahmen ────────────────────────────────────────────────────────
|
||||
seen, uniq = set(), []
|
||||
for m in measures:
|
||||
if m not in seen:
|
||||
seen.add(m)
|
||||
uniq.append(m)
|
||||
for f in all_findings:
|
||||
if f["measure"] and f["measure"] not in seen:
|
||||
seen.add(f["measure"])
|
||||
uniq.append(f"- **[{_SEV_LABEL[f['severity']]}]** {f['measure']}")
|
||||
sec.append({"title": "Empfohlene Maßnahmen", "level": 2, "body": (
|
||||
"\n".join(uniq) if uniq else
|
||||
"Keine konkreten Maßnahmen erforderlich.")})
|
||||
"\n".join(uniq[:25]) + ("\n- … weitere im Frontend" if len(uniq) > 25 else "")
|
||||
if uniq else "Keine konkreten Maßnahmen erforderlich.")})
|
||||
|
||||
sec.append({"title": "Rechtlicher Hinweis", "level": 2, "body": _DISCLAIMER})
|
||||
return sec
|
||||
|
||||
|
||||
def _module_section(key: str, mod: Optional[dict], fs: list[dict],
|
||||
link: str) -> dict:
|
||||
c = _sev_counts(fs)
|
||||
mc = len((mod or {}).get("mc_coverage") or [])
|
||||
head = [f"**{len(fs)} Befund(e)**"
|
||||
+ (f" — {_counts_line(c)}" if _counts_line(c) else "")
|
||||
+ (f" · {mc} Anforderungen (MCs) geprüft" if mc else "")]
|
||||
if _sev_bar(c):
|
||||
head += ["", "```", _sev_bar(c), "```"]
|
||||
if not fs:
|
||||
head.append("\nKeine Auffälligkeiten im Prüfumfang.")
|
||||
return {"title": _MODULE_LABEL[key], "level": 3, "body": "\n".join(head)}
|
||||
head += [""] + [_fmt_finding(f) for f in fs[:_TOP_N]]
|
||||
if len(fs) > _TOP_N:
|
||||
head.append(f"\n_… und {len(fs) - _TOP_N} weitere Befund(e) — vollständige "
|
||||
f"Liste im Frontend: `{link}`._")
|
||||
# Zwischenfazit
|
||||
if c.get("HIGH"):
|
||||
fazit = (f"Zwischenfazit: {c['HIGH']} Punkt(e) mit erhöhter Priorität — "
|
||||
f"vorrangig klären.")
|
||||
elif c.get("MEDIUM"):
|
||||
fazit = "Zwischenfazit: überwiegend mittlere Hinweise, planbar nachzuschärfen."
|
||||
else:
|
||||
fazit = "Zwischenfazit: nur kleinere Hinweise."
|
||||
head.append(f"\n_{fazit}_")
|
||||
return {"title": _MODULE_LABEL[key], "level": 3, "body": "\n".join(head)}
|
||||
|
||||
|
||||
def _browser_overview(mod: Optional[dict]) -> str:
|
||||
rows = ((mod or {}).get("browser_matrix") or {}).get("browser_matrix") or []
|
||||
if not rows:
|
||||
return ""
|
||||
lines = ["Verhalten je getesteter Browser-Engine:", ""]
|
||||
for r in rows:
|
||||
s = r.get("summary") or {}
|
||||
if not s:
|
||||
lines.append(f"- **{r.get('label', '?')}**: nicht verfügbar")
|
||||
continue
|
||||
tb = (s.get("violations") or {}).get("before_consent", 0)
|
||||
rej = "respektiert" if s.get("reject_respected") else "**nicht** respektiert"
|
||||
lines.append(
|
||||
f"- **{r.get('label', '?')}** — Tracking vor Consent: {tb} · "
|
||||
f"Ablehnen {rej} · Score {r.get('score', '–')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_markdown(report: dict) -> str:
|
||||
meta = report.get("meta") or {}
|
||||
site = meta.get("site_label") or meta.get("site_domain") or "Website"
|
||||
when = (meta.get("created_at") or "")[:16].replace("T", " ")
|
||||
out = [f"# Compliance-Audit-Bericht — {site}", ""]
|
||||
sub = [meta.get("site_domain") or "", f"Analyse: {when}" if when else "",
|
||||
f"Check-ID: {meta.get('check_id')}" if meta.get("check_id") else ""]
|
||||
out.append(" · ".join(s for s in sub if s))
|
||||
out.append("")
|
||||
out.append("_Erstellt mit BreakPilot — finale Bewertung mit DSB/Anwalt._")
|
||||
out.append("")
|
||||
f"Check-ID: {meta.get('check_id')}" if meta.get("check_id") else "",
|
||||
f"Report v{meta.get('report_version', REPORT_VERSION)}"]
|
||||
out += [" · ".join(s for s in sub if s), "",
|
||||
"_Erstellt mit BreakPilot — finale Bewertung mit DSB/Anwalt._", ""]
|
||||
for s in report.get("sections") or []:
|
||||
out.append(f"{'#' * s.get('level', 2)} {s['title']}")
|
||||
out.append("")
|
||||
if s.get("body"):
|
||||
out.append(s["body"])
|
||||
out.append("")
|
||||
out += [s["body"], ""]
|
||||
return "\n".join(out).strip() + "\n"
|
||||
|
||||
|
||||
def render_pdf(report: dict) -> bytes:
|
||||
"""Druckfertiges PDF (reportlab). Wandelt die Sektionen in gestylte Absätze;
|
||||
unterstützt **fett**, _kursiv_ und Aufzählungen aus den Markdown-Bodies."""
|
||||
import html
|
||||
import re
|
||||
from io import BytesIO
|
||||
@@ -207,7 +302,7 @@ def render_pdf(report: dict) -> bytes:
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
|
||||
from reportlab.platypus import Paragraph, Preformatted, SimpleDocTemplate, Spacer
|
||||
|
||||
meta = report.get("meta") or {}
|
||||
site = meta.get("site_label") or meta.get("site_domain") or "Website"
|
||||
@@ -223,20 +318,22 @@ def render_pdf(report: dict) -> bytes:
|
||||
h3 = ParagraphStyle("rh3", parent=ss["Heading3"], spaceBefore=6, spaceAfter=2)
|
||||
body = ParagraphStyle("rbody", parent=ss["BodyText"], fontSize=10,
|
||||
leading=14, spaceAfter=4)
|
||||
mono = ParagraphStyle("rmono", parent=ss["Code"], fontSize=8, leading=10)
|
||||
small = ParagraphStyle("rsmall", parent=ss["BodyText"], fontSize=8,
|
||||
textColor=colors.grey, spaceAfter=2)
|
||||
|
||||
def _inl(t: str) -> str:
|
||||
t = html.escape(t)
|
||||
t = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", t)
|
||||
t = re.sub(r"_(.+?)_", r"<i>\1</i>", t)
|
||||
return t
|
||||
t = re.sub(r"`(.+?)`", r"<font face='Courier'>\1</font>", t)
|
||||
return re.sub(r"_(.+?)_", r"<i>\1</i>", t)
|
||||
|
||||
story = [Paragraph(f"Compliance-Audit-Bericht — {html.escape(site)}", h1)]
|
||||
sub = " · ".join(x for x in [
|
||||
meta.get("site_domain") or "",
|
||||
(meta.get("created_at") or "")[:16].replace("T", " "),
|
||||
f"Check-ID: {meta.get('check_id')}" if meta.get("check_id") else "",
|
||||
f"Report v{meta.get('report_version', REPORT_VERSION)}",
|
||||
] if x)
|
||||
if sub:
|
||||
story.append(Paragraph(html.escape(sub), small))
|
||||
@@ -244,9 +341,20 @@ def render_pdf(report: dict) -> bytes:
|
||||
"Erstellt mit BreakPilot — finale Bewertung mit DSB/Anwalt.", small))
|
||||
story.append(Spacer(1, 8))
|
||||
for s in report.get("sections") or []:
|
||||
style = {2: h2, 3: h3}.get(s.get("level", 2), h2)
|
||||
story.append(Paragraph(_inl(s["title"]), style))
|
||||
story.append(Paragraph(_inl(s["title"]),
|
||||
{2: h2, 3: h3}.get(s.get("level", 2), h2)))
|
||||
in_code = False
|
||||
block: list[str] = []
|
||||
for line in (s.get("body") or "").split("\n"):
|
||||
if line.strip() == "```":
|
||||
if in_code and block:
|
||||
story.append(Preformatted("\n".join(block), mono))
|
||||
block = []
|
||||
in_code = not in_code
|
||||
continue
|
||||
if in_code:
|
||||
block.append(line)
|
||||
continue
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user