diff --git a/backend-compliance/compliance/api/agent_doc_check_extras.py b/backend-compliance/compliance/api/agent_doc_check_extras.py index 77497f9a..7a4f30f5 100644 --- a/backend-compliance/compliance/api/agent_doc_check_extras.py +++ b/backend-compliance/compliance/api/agent_doc_check_extras.py @@ -264,22 +264,38 @@ def build_vvt_table_html(vendors: list[dict]) -> str: # Top summary 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 = ( - f"{n_total} Anbieter erfasst" - + (f", {n_critical} unter 50%" - if n_critical else " — alle ueber 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) out: list[str] = [ '
{summary}. ' - 'Gruppiert nach Empfaengerkategorie (Art. 30(1)(d) DSGVO), innerhalb ' - 'jeder Gruppe nach Compliance-Score sortiert.
', + 'Gruppiert nach Empfaengerkategorie (Art. 30(1)(d) DSGVO). Innerhalb ' + 'jeder Gruppe nach Compliance-Score sortiert. Bei eigenen ' + 'Verarbeitungen (INTERNAL/GROUP) werden Opt-Out und Privacy-Link ' + 'NICHT als Pflicht gewertet — der Widerruf erfolgt ueber das ' + 'Cookie-Banner, Privacy ist in der Haupt-DSI dokumentiert.', ] for rtype, section_label in RECIPIENT_TYPE_SECTIONS: @@ -323,19 +339,38 @@ def _render_vendor_section(rows: list[dict]) -> str: 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 "—" + country = v.get("country") or ("—" if is_own else "—") cookies = v.get("cookies") or [] n_cookies = len(cookies) score = int(v.get("compliance_score", 0)) flags = v.get("compliance_flags") or [] + + # Opt-Out: nicht erforderlich fuer eigene Verarbeitung oder + # technisch notwendige Cookies (§25 Abs. 2 TDDDG). + 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: nicht erforderlich fuer eigene Verarbeitung (Haupt-DSI). + 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"), + v.get("privacy_status"), na_label=privacy_na_reason, ) score_color = ("#16a34a" if score >= 80 else "#d97706" if score >= 50 else "#dc2626") @@ -361,10 +396,26 @@ def _render_vendor_row_full(v: dict) -> str: ) -def _link_status_badge(url: str | None, ok: bool | None, status: int | None) -> str: +def _link_status_badge( + url: str | None, + ok: bool | None, + status: int | None, + na_label: str | None = None, +) -> str: + """Render the link-status cell. + + - url + ok -> green check + - url + broken -> red cross with status + - no url + na_label -> neutral em-dash with explanation tooltip + (used for INTERNAL/necessary rows where the field isn't required) + - no url + no na_label -> red cross (real gap) + """ if not url: - return ('' - '✗') + if na_label: + return ('—') + return ('✗') if ok: return ('✓') diff --git a/backend-compliance/compliance/services/cookie_link_validator.py b/backend-compliance/compliance/services/cookie_link_validator.py index 42888b30..a1407532 100644 --- a/backend-compliance/compliance/services/cookie_link_validator.py +++ b/backend-compliance/compliance/services/cookie_link_validator.py @@ -173,16 +173,34 @@ async def validate_vendor_urls(vendors: list[dict]) -> list[dict]: def score_vendors(vendors: list[dict]) -> list[dict]: - """Compute per-vendor compliance score (0-100) and flags. Mutates. + """Compute per-vendor compliance score (0-100) and flags. - Category-aware: 'necessary' (technisch erforderliche Cookies) do NOT - require an opt-out — §25 Abs. 2 TDDDG. Penalising them for that would - be wrong; instead we require precise purpose + cookie disclosure. + Scoring is recipient-type AND category aware. Two orthogonal axes + influence which fields are required: + + recipient_type == INTERNAL / GROUP_COMPANY + Own processing — the user's consent + main DSI cover privacy + + opt-out for ALL of these. Per-row opt-out / privacy URLs are + NOT a compliance gap. What matters: VVT-relevante Fields + (purpose, cookies with names + expiry). + + category == 'necessary' (§25 Abs. 2 TDDDG) + Technically necessary cookies don't need consent → no opt-out + required even for external processors. + + For each non-applicable field we set flag '