"""Mail-V2 section renderers — one function per top-level block.
Each renderer takes a slice of `state` and returns ready-to-concatenate
HTML using the helpers from `_style`. Every block is full-width, has
the same card shell, and uses the same color palette.
Finding-bucket renderers (critical / manual / internal) live in
`_blocks_findings.py` to keep this file under the LOC cap.
"""
from __future__ import annotations
from html import escape as h
from ._aggregator import group_by_action
from ._blocks_findings import count_critical, count_internal, count_manual
from ._cookie_inventory import (
build_cookie_inventory,
inventory_headers,
render_inventory_rows,
)
from ._style import (
SZ_H3,
SZ_SMALL,
TEXT,
TEXT_MUTED,
card,
chip,
kpi_row,
section,
table,
)
# ── Helpers ──────────────────────────────────────────────────────
def _score_sev(pct: int | None) -> str:
if pct is None:
return "info"
if pct >= 90:
return "pass"
if pct >= 70:
return "info"
if pct >= 40:
return "warn"
return "fail"
# ── 1. Header + KPI row ──────────────────────────────────────────
def render_header(state: dict) -> str:
site = h(state.get("site_name") or "—")
dom = h(state.get("domain") or "")
scorecard = state.get("scorecard") or {}
score_pct = (scorecard.get("totals") or {}).get("pct")
doc_count = state.get("doc_count") or 0
docs_total = len(state.get("results") or [])
findings = state.get("total_findings") or 0
vendors = len(state.get("cmp_vendors") or [])
title_html = (
f'
{site}
'
f''
f'{dom} · Compliance-Audit
'
)
kpis = [
{"label": "Compliance-Score",
"value": f"{score_pct}%" if score_pct is not None else "—",
"sev": _score_sev(score_pct)},
{"label": "Findings", "value": str(findings),
"sev": "fail" if findings > 5 else "warn" if findings > 0 else "pass"},
{"label": "Dokumente",
"value": f"{doc_count}/{docs_total}", "sev": "info"},
{"label": "Vendors", "value": str(vendors),
"sev": "warn" if vendors > 20 else "info"},
]
return title_html + kpi_row(kpis)
# ── 2. Table of contents ────────────────────────────────────────
def render_toc(state: dict) -> str:
rows = [
("#critical", f"Kritische Befunde ({count_critical(state)})"),
("#manual", f"Manuelle Prüfung ({count_manual(state)})"),
("#internal", f"Interne Reminder ({count_internal(state)})"),
("#sofortmassnahmen", "Sofortmaßnahmen"),
("#per-doc",
f"Pro Dokument ({len(state.get('results') or [])})"),
("#per-theme", "Pro Thema"),
("#caveats",
f"Audit-Vorbehalte ({len(state.get('audit_quality_findings') or [])})"),
("#attach",
f"Anhänge ({1 if state.get('cookie_evidence_slices') else 0})"),
]
items = "".join(
f'{h(label)}'
for href, label in rows
)
return section(
"📋 Inhalt",
f'{items}
',
)
# ── 4. Per-document blocks ──────────────────────────────────────
def render_per_doc(state: dict) -> str:
results = state.get("results") or []
if not results:
return ""
cards = []
for r in results:
label = h(getattr(r, "label", "") or "—")
url = getattr(r, "url", "") or ""
url_html = (f'{h(url)}') if url else ""
corr = getattr(r, "correctness_pct", 0) or 0
err = getattr(r, "error", "") or ""
checks = getattr(r, "checks", []) or []
n_total = len(checks)
n_pass = sum(1 for c in checks if c.passed and not c.skipped)
n_fail = sum(1 for c in checks if not c.passed and not c.skipped)
n_skip = sum(1 for c in checks if c.skipped)
score_sev = _score_sev(corr)
head = (
f''
f'
'
f'
'
f'{chip(f"{corr}%", score_sev)}
'
)
if err:
body = (f''
f'{h(err)}
')
else:
counts = (
f''
f'{n_total} MCs · {n_pass} ✓ · {n_fail} ✗ · {n_skip} ?
'
)
top = [c for c in checks
if not c.passed and not c.skipped][:3]
top_list = ""
if top:
lis = "".join(
f''
f'{h(getattr(c, "label", "")[:120])}'
for c in top
)
top_list = (
f''
)
body = counts + top_list
cards.append(card(head + body,
sev=score_sev if not err else "info"))
return section(f"📄 4. Pro Dokument ({len(results)})",
"".join(cards), anchor="per-doc")
# ── 5. Per-theme blocks ─────────────────────────────────────────
def render_theme_cookie_banner(state: dict) -> str:
br = state.get("banner_result") or {}
if not br:
return ""
detected = br.get("detected") or br.get("banner_detected")
provider = br.get("provider") or br.get("banner_provider") or "—"
violations = br.get("violations") or len(
(br.get("banner_checks") or {}).get("violations") or [])
body = (
f'Provider: {h(str(provider))} · '
f'Detected: '
f'{chip("Ja" if detected else "Nein", "pass" if detected else "fail")} · '
f'Violations: {violations}
'
)
return card(
f'▶ Cookie-Banner
'
+ body,
sev="warn" if violations else "pass",
)
def render_theme_cookie_inventory(state: dict) -> str:
rows, summary = build_cookie_inventory(state)
if summary["total"] == 0:
return ""
head = (
f''
f'▶ Cookie-Inventar ({summary["total"]})
'
f''
f'{summary["declared"]} deklariert · '
f'{summary["in_browser"]} im Browser · '
f'{summary["undoc"]} UNDOC · '
f'{summary["orph"]} ORPH · '
f'{summary["ok"]} OK'
f' · {summary["third_country"]} Drittland'
f'
'
f''
f'Fehlende Pflichtangaben — Sitzland: {summary["missing_country"]}'
f' · Speicherdauer: {summary["missing_duration"]}'
f'
'
)
show_rows = render_inventory_rows(rows[:50])
body = table(inventory_headers(), show_rows)
if len(rows) > 50:
body += (
f''
f'… und {len(rows) - 50} weitere
'
)
sev = "fail" if summary["undoc"] else "warn" if summary["orph"] else "pass"
return card(head + body, sev=sev)
def render_sofortmassnahmen(state: dict) -> str:
"""Aggregated bulk-recommendations: '1 Aktion fixt N Items'."""
groups = group_by_action(state)
if not groups:
return ""
rows = []
for g in groups:
items = g["items"]
sample = ", ".join(items[:5])
more = f" + {len(items) - 5} weitere" if len(items) > 5 else ""
eff_sev = ("pass" if g["effort"] == "low"
else "warn" if g["effort"] == "med" else "fail")
rows.append([
f'{g.get("icon") or "•"} {h(g["label"])}'
f''
f'{h(g.get("norm") or "")}
',
f'{g["count"]}',
f''
f'{h(sample)}{h(more)}
',
chip(g["effort"].upper(), eff_sev),
])
body = table(["Maßnahme", "Anz.", "Betrifft", "Aufwand"], rows)
return section(
f"🛠 Sofortmaßnahmen ({len(groups)})",
''
'Eine Aktion behebt mehrere Findings auf einmal — nach Aufwand sortiert.'
'
' + body,
sev="warn",
anchor="sofortmassnahmen",
)
def render_theme_retention(state: dict) -> str:
s = state.get("retention_theme_summary") or {}
findings = state.get("retention_findings") or []
if not s.get("total"):
return ""
head = (
f''
f'▶ Speicherdauer-Konsistenz (TH-RETENTION)
'
f''
f'{s["total"]} Cookies · '
f'{s["passed"]} ✓ · '
f'{s["failed"]} ✗ · '
f'{s["incomplete"]} ?'
f'
'
)
fails = [f for f in findings
if not f.get("matches")
and f.get("severity_reason") != "incomplete"][:5]
if not fails:
return card(head, sev="pass")
rows = []
for f in fails:
sev = (f.get("severity") or "").upper()
sev_key = "fail" if sev == "HIGH" else "warn"
rows.append([
f'{h(f.get("cookie_name") or "—")}',
h(f.get("vendor_name") or "—"),
h(f.get("mismatch_type") or ""),
chip(sev, sev_key),
])
body = table(["Cookie", "Vendor", "Mismatch", "Sev"], rows)
sev = "fail" if s.get("failed", 0) else "warn"
return card(head + body, sev=sev)
def render_theme_reachability(state: dict) -> str:
f = state.get("reachability_finding") or {}
if not f:
return ""
passed = f.get("passed")
sev_key = "pass" if passed else (
"fail" if (f.get("severity") or "").upper() == "HIGH" else "warn")
notes_html = "".join(
f'{h(n)}'
for n in (f.get("notes") or [])
)
sub = (
f'' if notes_html else ""
)
head = (
f''
f'▶ Mobile Reachability (COOKIE-CONSENT-UX-001)
'
f'{chip((f.get("severity") or "PASS").upper(), sev_key)} '
f'{h(f.get("severity_reason") or "ok")}'
f'
'
)
return card(head + sub, sev=sev_key)
def render_per_theme(state: dict) -> str:
parts = [
render_theme_cookie_banner(state),
render_theme_cookie_inventory(state),
render_theme_retention(state),
render_theme_reachability(state),
]
parts = [p for p in parts if p]
if not parts:
return ""
return section("🎯 5. Pro Thema", "".join(parts), anchor="per-theme")
# ── 6. Audit caveats ────────────────────────────────────────────
def render_caveats(state: dict) -> str:
fs = state.get("audit_quality_findings") or []
if not fs:
return ""
items = []
for f in fs:
sev = (f.get("severity") or "INFO").upper()
sev_key = ("fail" if sev == "HIGH"
else "warn" if sev == "MEDIUM" else "info")
title = h(f.get("title") or f.get("label") or "Vorbehalt")
msg = h(f.get("message") or f.get("hint") or "")
items.append(card(
f'{chip(sev, sev_key)} {title}'
f'{msg}
',
sev=sev_key,
))
return section(f"⚠️ 6. Audit-Vorbehalte ({len(fs)})",
"".join(items), sev="warn", anchor="caveats")
# ── 7. Attachments ──────────────────────────────────────────────
def render_attachments(state: dict) -> str:
slices = state.get("cookie_evidence_slices") or []
if not slices:
return ""
meta = state.get("cookie_evidence_meta") or {}
n = len(slices)
body = (
f''
f'Beweis-ZIP evidence-{h(state.get("check_id", "")[:8])}.zip '
f'mit {n} Slice(s), '
f'manifest.json + audit_metadata.json (SHA256 pro Slice).
'
f''
f'Quelle: {h(meta.get("url") or "—")}'
f'
'
)
return section("📎 7. Anhänge", body, sev="info", anchor="attach")