"""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'
    {label}' f'
    {url_html}
    ' 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")