""" 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'' 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] = [ '' '' '' '' '' '' '' '' '' '', ] for v in rows: body.append(_render_vendor_row_full(v)) body.append('
    NameKategorieSitzCookiesOpt-OutPrivacyScore
    ') 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'
    ' ) 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'' )