"""
VVT-Tabelle fuer den Email-Report — pro Vendor eine Zeile, gruppiert
nach Empfaengerkategorie (Art. 30(1)(d) DSGVO).
Ausgelagert aus agent_doc_check_extras.py (LOC-Cap). Enthaelt:
* build_vvt_table_html — Haupteinstieg, gruppiert + summary + P60 notice
* _render_vendor_section / _render_vendor_row_full — Zeilenrenderer
* _link_status_badge / _flag_short — kleine Helper
P60b Fuzzy-Match: Vendors mit teilweise befuellten Feldern (z.B. Sitzland
eingetragen) fallen nicht aus der Pattern-Notice raus, nur weil ihr
Flag-Set um 1-2 Items kleiner ist. Jaccard >= 0.7 deckt das ab.
"""
from __future__ import annotations
def _category_label(kat: str) -> str:
return {
"necessary": "Notwendig", "strictlynecessary": "Notwendig",
"preferences": "Praeferenzen", "functional": "Funktional",
"statistics": "Statistik", "marketing": "Marketing",
"unclassified": "Unklassifiziert",
}.get((kat or "").lower(), kat or "—")
def _flag_short(f: str) -> str:
"""Lesbare deutsche Form fuer einen Flag-Token."""
labels = {
"no_cookies_listed": "Cookies fehlen",
"no_country": "Sitzland fehlt",
"no_privacy_url": "Privacy-Link fehlt",
"broken_privacy_url": "Privacy-Link broken",
"no_opt_out_url": "Opt-Out fehlt",
"broken_opt_out": "Opt-Out broken",
}
return labels.get(f, f)
def _link_status_badge(
url: str | None,
ok: bool | None,
status: int | None,
na_label: str | None = None,
) -> str:
if not url:
if na_label:
return ('—')
return ('✗')
if ok:
return ('✓')
status_str = str(status) if status else "?"
return ('✗ ({status_str})')
def _build_pattern_notice(vendors: list[dict]) -> str:
"""P60 + P60b: globale Notice wenn viele Vendors aehnliche Flag-Sets haben.
Mutiert vendors[].`_actions_in_global_notice` so dass die Zeilenrenderer
redundante per-row-Actions ueberspringen koennen.
"""
from collections import Counter
flag_sets: Counter = Counter()
for v in vendors:
flags = v.get("compliance_flags") or []
if flags:
flag_sets[tuple(sorted(flags))] += 1
if not flag_sets:
return ""
most_common, _ = flag_sets.most_common(1)[0]
most_common_set = set(most_common)
def _similar(flags: tuple) -> bool:
fs = set(flags)
if not fs or not most_common_set:
return False
inter = len(fs & most_common_set)
union = len(fs | most_common_set)
return union > 0 and (inter / union) >= 0.7
n_match = sum(cnt for fs, cnt in flag_sets.items() if _similar(fs))
share = n_match / max(1, len(vendors))
if not (n_match >= 8 and share >= 0.5):
return ""
from compliance.services.finding_action_recipes import recipe_for
labels = [_flag_short(f) for f in most_common]
shared_actions: list[str] = []
for f in most_common:
rec = recipe_for(f)
if rec:
shared_actions.append(
f'
{_flag_short(f)}: '
f'{rec.get("fix_text", "").splitlines()[0][:180]}'
)
for v in vendors:
if _similar(tuple(sorted(v.get("compliance_flags") or []))):
v["_actions_in_global_notice"] = True
return (
f''
f'
Wiederkehrendes Muster ({n_match} von {len(vendors)} '
f'Anbietern, {int(share*100)}%): '
f'Bei diesen Anbietern fehlen jeweils: '
f'
{", ".join(labels)}. '
f'Vermutlich systembedingt (z.B. Settings-Export liefert '
f'nur Namen, oder Banner-API blockiert Detail-Extraktion). '
f'Die globalen Empfehlungen unten gelten fuer all diese Eintraege; '
f'in der Tabelle werden sie nicht pro Zeile wiederholt.'
+ (f'
{"".join(shared_actions)}
'
if shared_actions else '')
+ '
'
)
def build_vvt_table_html(vendors: list[dict]) -> str:
"""Render per-vendor VVT-style table for the email."""
if not vendors:
return ""
from compliance.services.vendor_classifier import RECIPIENT_TYPE_SECTIONS
by_type: dict[str, list[dict]] = {}
for v in vendors:
rt = (v.get("recipient_type") or "OTHER").upper()
by_type.setdefault(rt, []).append(v)
n_total = len(vendors)
n_internal = sum(
1 for v in vendors
if (v.get("recipient_type") or "").upper() in ("INTERNAL", "GROUP_COMPANY")
)
n_external = n_total - n_internal
n_critical = sum(1 for v in vendors if v.get("compliance_score", 0) < 50)
summary_parts = [f"{n_total} Verarbeitungen erfasst"]
if n_internal and n_external:
summary_parts.append(
f"— {n_internal} eigene + {n_external} externe Empfaenger"
)
if n_critical:
summary_parts.append(
f', {n_critical} unter 50%'
)
else:
summary_parts.append("— alle ueber 50%")
summary = " ".join(summary_parts)
pattern_notice = _build_pattern_notice(vendors)
out: list[str] = [
'',
'
'
'Vorschlag fuer das Verarbeitungsverzeichnis (Art. 30 DSGVO)
',
# P91: Co-Pilot-Tonalitaet — Wahrscheinlichkeit statt Garantie,
# Empfehlung statt "Verstoss-Liste".
f'
'
f'Wir haben {n_total} Verarbeitungen aus dem '
f'Cookie-Banner abgeleitet, mit unserer globalen Anbieter-Bibliothek '
f'abgeglichen und nach Empfaengerkategorie (Art. 30(1)(d) DSGVO) '
f'gruppiert. Bei einer Reduktion der eingebundenen Anbieter, dem '
f'Wechsel zu europaeischen Alternativen und konsequenter Pruefung '
f'der tatsaechlich benoetigten Cookies ist eine Reduktion des '
f'Tracking-Footprints sowie Lizenz-Einsparungen wahrscheinlich. '
f'Eine fundierte Bewertung erfordert die Abstimmung mit dem '
f'Datenschutzbeauftragten.
'
f'
'
f'{summary}. Innerhalb jeder Gruppe nach Verbesserungspotenzial '
f'sortiert. Bei eigenen Verarbeitungen (INTERNAL/GROUP) sind '
f'Opt-Out und Privacy-Link '
'NICHT als Pflicht gewertet — der Widerruf erfolgt ueber das '
'nicht erforderlich (Widerruf ueber Banner, Privacy in der '
'Haupt-Datenschutzerklaerung dokumentiert).
',
pattern_notice,
]
for rtype, section_label in RECIPIENT_TYPE_SECTIONS:
rows = by_type.get(rtype) or []
if not rows:
continue
rows = sorted(rows, key=lambda v: v.get("compliance_score", 0))
n = len(rows)
n_bad = sum(1 for v in rows if v.get("compliance_score", 0) < 50)
bad_hint = (f'
({n_bad} unter 50%)'
if n_bad else "")
out.append(
f'
'
f'{section_label} '
f'({n}){bad_hint}
'
)
out.append(_render_vendor_section(rows))
out.append('
')
return "".join(out)
def _render_vendor_section(rows: list[dict]) -> str:
body: list[str] = [
''
''
'| Name | '
'Kategorie | '
'Sitz | '
'Cookies | '
'Opt-Out | '
'Privacy | '
'Score | '
'
',
]
for v in rows:
body.append(_render_vendor_row_full(v))
body.append('
')
return "".join(body)
def _render_vendor_row_full(v: dict) -> str:
rtype = (v.get("recipient_type") or "OTHER").upper()
is_own = rtype in ("INTERNAL", "GROUP_COMPANY")
cat = (v.get("category") or "").lower()
is_necessary = cat in ("necessary", "strictlynecessary")
name = v.get("name") or "Unbekannt"
category = _category_label(v.get("category", ""))
country = v.get("country") or "—"
cookies = v.get("cookies") or []
n_cookies = len(cookies)
score = int(v.get("compliance_score", 0))
flags = v.get("compliance_flags") or []
opt_na_reason = ("Nicht erforderlich (eigene Verarbeitung — "
"Widerruf ueber Cookie-Banner)") if is_own else (
"Nicht erforderlich (§25 Abs. 2 TDDDG — technisch notwendig)"
if is_necessary else None
)
opt_status = _link_status_badge(
v.get("opt_out_url"), v.get("opt_out_ok"), v.get("opt_out_status"),
na_label=opt_na_reason,
)
privacy_na_reason = (
"Nicht erforderlich (eigene Verarbeitung — durch Haupt-DSI abgedeckt)"
if is_own else None
)
privacy_status = _link_status_badge(
v.get("privacy_policy_url"), v.get("privacy_ok"),
v.get("privacy_status"), na_label=privacy_na_reason,
)
score_color = ("#16a34a" if score >= 80 else
"#d97706" if score >= 50 else "#dc2626")
n_criteria = 3 if is_own else 5
n_failed = len(flags) if flags else 0
score_tooltip = (
f"{n_criteria - n_failed} von {n_criteria} Kriterien erfuellt"
+ (f" — fehlt: {', '.join(_flag_short(f) for f in flags[:3])}"
if flags else "")
)
actions_html = ""
skip_actions = bool(v.get("_actions_in_global_notice"))
if flags and not skip_actions:
from compliance.services.finding_action_recipes import recipe_for
action_items = []
for f in flags:
rec = recipe_for(f)
if not rec:
continue
action_items.append(
f'{_flag_short(f)}: '
f'{rec.get("what", "")}
'
f'Was tun: '
f'{rec.get("fix_text", "").splitlines()[0][:200]}
'
f'Quelle: '
f'{rec.get("why", "")[:160]}'
)
if action_items:
actions_html = (
f'Was muss ich tun? '
f'({len(action_items)} Action{"s" if len(action_items) != 1 else ""})
'
f''
+ "".join(action_items)
+ '
'
)
flag_str = ""
if flags:
flag_str = (
f''
f'{", ".join(flags[:4])}
'
f'{actions_html}'
)
risk = v.get("compliance_risk") or {}
risk_label = risk.get("label") or ""
risk_badge = ""
if risk_label and risk_label != "unklar":
rc = {
"kritisch": ("#dc2626", "#fff"),
"hoch": ("#fecaca", "#991b1b"),
"mittel": ("#fde68a", "#92400e"),
"gering": ("#d1fae5", "#065f46"),
}.get(risk_label, ("#e5e7eb", "#475569"))
risk_badge = (f'Risk: {risk_label}')
return (
f''
f'| '
f'{name}{risk_badge}{flag_str} | '
f'{category} | '
f'{country} | '
f''
f'{n_cookies} | '
f'{opt_status} | '
f'{privacy_status} | '
f''
f'{score}% '
f'{n_criteria - n_failed}/{n_criteria} | '
f'
'
)