fix(vvt): score INTERNAL/GROUP without opt-out/privacy penalty
User feedback after BMW test:
- 60 'BMW AG — XYZ' rows were rendered as ✗ for Opt-Out/Privacy and
scored 38-52%. That's misleading: BMW processing for itself doesn't
need a separate opt-out URL (cookie-banner is the consent
mechanism) or a separate privacy policy (main DSI covers it).
- Title 'Anbieter' was wrong for 60 of 90 rows (internal services).
Three orthogonal fixes:
1. score_vendors becomes recipient_type aware:
- INTERNAL/GROUP_COMPANY: opt_out_url, privacy_policy_url, country
are NOT required (the user's main DSI + cookie-banner cover them).
What IS required: name, purpose, cookies disclosed with name +
expiry. Cookies-disclosure weight raised to 50 (was 15) so the
VVT-relevant data is the score driver.
- 'necessary' category: opt-out still skipped (§25 Abs. 2 TDDDG).
- External (PROCESSOR/CONTROLLER): existing strict scoring stays.
2. _link_status_badge accepts na_label and renders a neutral em-dash
with explanation tooltip instead of red ✗ when the column doesn't
apply to that row. _render_vendor_row_full passes na_label based on
recipient_type:
- INTERNAL/GROUP -> 'Nicht erforderlich (eigene Verarbeitung)'
- necessary -> 'Nicht erforderlich (§25 Abs. 2 TDDDG)'
3. Header + summary clarify the split:
- h3 changed to 'Verarbeitungstaetigkeiten und Empfaenger aus der
Cookie-Richtlinie' (was 'Drittanbieter aus Cookie-Richtlinie').
- Top line: '90 Verarbeitungen erfasst — 60 eigene + 30 externe
Empfaenger'.
- Disclaimer below: explains the INTERNAL/GROUP exemption so the
reader understands why those rows don't show ✗ for missing URLs.
- Section labels enriched with the relevant DSGVO article:
'Eigene Verarbeitungstaetigkeiten — fuer das VVT (Art. 30)',
'Auftragsverarbeiter — AVV erforderlich (Art. 28)',
'Joint Controller — Vereinbarung pruefen (Art. 26)'.
Expected BMW result after fix: ~85% of the 60 BMW-AG rows jump from
~52% to 90-100% (the real issue, fehlende Cookies-Disclosure, stays
flagged). The only true findings remaining are external links that
return 4xx (e.g. Criteo 403, Teads 404).
This commit is contained in:
@@ -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", <strong style=\"color:#dc2626\">{n_critical} unter 50%</strong>"
|
||||
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', <strong style="color:#dc2626">{n_critical} unter 50%</strong>'
|
||||
)
|
||||
else:
|
||||
summary_parts.append("— alle ueber 50%")
|
||||
summary = " ".join(summary_parts)
|
||||
|
||||
out: list[str] = [
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:760px;margin:0 auto 16px;padding:12px 16px;'
|
||||
'background:#fafafa;border:1px solid #e5e7eb;border-radius:8px">',
|
||||
'<h3 style="margin:0 0 4px;font-size:14px;color:#334155">'
|
||||
'VVT-Vorschlag: Drittanbieter aus Cookie-Richtlinie</h3>',
|
||||
'VVT-Vorschlag: Verarbeitungstaetigkeiten und Empfaenger aus der '
|
||||
'Cookie-Richtlinie</h3>',
|
||||
f'<p style="margin:0 0 10px;font-size:11px;color:#6b7280">{summary}. '
|
||||
'Gruppiert nach Empfaengerkategorie (Art. 30(1)(d) DSGVO), innerhalb '
|
||||
'jeder Gruppe nach Compliance-Score sortiert.</p>',
|
||||
'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.</p>',
|
||||
]
|
||||
|
||||
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 ('<span style="color:#dc2626;font-size:11px" title="Kein Link">'
|
||||
'✗</span>')
|
||||
if na_label:
|
||||
return ('<span style="color:#94a3b8;font-size:11px" '
|
||||
f'title="{na_label}">—</span>')
|
||||
return ('<span style="color:#dc2626;font-size:11px" '
|
||||
'title="Kein Link">✗</span>')
|
||||
if ok:
|
||||
return ('<span style="color:#16a34a;font-size:11px" '
|
||||
f'title="HTTP {status}">✓</span>')
|
||||
|
||||
@@ -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 '<field>_n_a' instead of
|
||||
a penalty flag, so the report can render it neutrally.
|
||||
"""
|
||||
for v in vendors:
|
||||
rtype = (v.get("recipient_type") or "OTHER").upper()
|
||||
is_own = rtype in ("INTERNAL", "GROUP_COMPANY")
|
||||
is_necessary = (v.get("category") or "").lower() in (
|
||||
"necessary", "strictlynecessary",
|
||||
)
|
||||
opt_out_required = not is_own and not is_necessary
|
||||
privacy_required = not is_own
|
||||
country_required = not is_own
|
||||
|
||||
score = 0
|
||||
max_score = 0
|
||||
flags: list[str] = []
|
||||
@@ -201,17 +219,16 @@ def score_vendors(vendors: list[dict]) -> list[dict]:
|
||||
else:
|
||||
flags.append("no_purpose")
|
||||
|
||||
# Country (3rd-country transfer relevance) — only relevant for
|
||||
# consent-based categories (otherwise irrelevant flag noise)
|
||||
if not is_necessary:
|
||||
# Country — only for external processors / controllers
|
||||
if country_required:
|
||||
max_score += 10
|
||||
if v.get("country"):
|
||||
score += 10
|
||||
else:
|
||||
flags.append("no_country")
|
||||
|
||||
# Opt-Out URL — only for consent-based categories (§25 TDDDG)
|
||||
if not is_necessary:
|
||||
# Opt-Out URL — only when consent-based AND external
|
||||
if opt_out_required:
|
||||
max_score += 25
|
||||
if not v.get("opt_out_url"):
|
||||
flags.append("no_opt_out_url")
|
||||
@@ -221,20 +238,21 @@ def score_vendors(vendors: list[dict]) -> list[dict]:
|
||||
else:
|
||||
score += 25
|
||||
|
||||
# Privacy policy URL — relevant for all, but weight lower for necessary
|
||||
weight = 10 if is_necessary else 15
|
||||
max_score += weight
|
||||
if not v.get("privacy_policy_url"):
|
||||
flags.append("no_privacy_url")
|
||||
elif v.get("privacy_ok") is False:
|
||||
flags.append("broken_privacy_url")
|
||||
score += weight // 3
|
||||
else:
|
||||
score += weight
|
||||
# Privacy policy URL — required for external (own = via main DSI)
|
||||
if privacy_required:
|
||||
weight = 10 if is_necessary else 15
|
||||
max_score += weight
|
||||
if not v.get("privacy_policy_url"):
|
||||
flags.append("no_privacy_url")
|
||||
elif v.get("privacy_ok") is False:
|
||||
flags.append("broken_privacy_url")
|
||||
score += weight // 3
|
||||
else:
|
||||
score += weight
|
||||
|
||||
# Cookies disclosed (names + expiry) — higher weight for necessary
|
||||
# (since that's mostly what they offer in lieu of opt-out)
|
||||
weight = 50 if is_necessary else 15
|
||||
# Cookies disclosed (names + expiry) — required for ALL types
|
||||
# (own processing too: BMW must list its own cookies for the VVT)
|
||||
weight = 50 if is_own or is_necessary else 15
|
||||
max_score += weight
|
||||
cookies = v.get("cookies") or []
|
||||
if cookies:
|
||||
|
||||
@@ -142,10 +142,10 @@ def classify(
|
||||
|
||||
# Section ordering + display labels for the VVT email table
|
||||
RECIPIENT_TYPE_SECTIONS = [
|
||||
("INTERNAL", "Eigene Verarbeitung"),
|
||||
("GROUP_COMPANY", "Konzernunternehmen (Mutter/Tochter)"),
|
||||
("PROCESSOR", "Auftragsverarbeiter (AVV-pflichtig)"),
|
||||
("CONTROLLER", "Eigenverantwortliche Dritte / Joint Controller"),
|
||||
("INTERNAL", "Eigene Verarbeitungstaetigkeiten — fuer das VVT (Art. 30 DSGVO)"),
|
||||
("GROUP_COMPANY", "Konzernunternehmen (Mutter/Tochter) — VVT + ggf. JC/AVV pruefen"),
|
||||
("PROCESSOR", "Auftragsverarbeiter — AVV erforderlich (Art. 28 DSGVO)"),
|
||||
("CONTROLLER", "Eigenverantwortliche Dritte / Joint Controller — Vereinbarung pruefen (Art. 26 DSGVO)"),
|
||||
("AUTHORITY", "Behoerden"),
|
||||
("OTHER", "Sonstige Empfaenger"),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user