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:
Benjamin Admin
2026-05-17 13:15:40 +02:00
parent 8a44e67293
commit 6d29191e9b
3 changed files with 108 additions and 39 deletions
@@ -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: