feat(agent): migrate compliance-check results to banner + documents (M1-M5)

After a compliance-check run finishes, the user can now apply the
extracted vendor inventory directly to their own:

  - CookieBanner config (admin /sdk/einwilligungen)
  - Cookie-Policy / VVT-Register / Privacy-Policy templates
    (admin /sdk/document-generator)

Backend:
  - migration_to_banner.py: vendor list -> CookieBannerConfig with
    ESSENTIAL/PERFORMANCE/PERSONALIZATION/EXTERNAL_MEDIA buckets +
    review flags (broken opt-out URLs, missing expiry, no cookies listed)
  - migration_to_document.py: vendor list -> pre-fills for 3 doc
    templates, recipient-type aware (INTERNAL/GROUP/PROCESSOR/CONTROLLER)
  - agent_migration_routes.py: GET /banner-preview, /document-preview,
    /summary keyed on check_id
  - compliance_audit_log: new check_payloads table persists cmp_vendors +
    extracted_profile so the preview survives an app restart
  - tests: 9 mapper units + 4 endpoint integration tests

Frontend:
  - MigrationPanel.tsx: modal showing banner-config diff + document
    pre-fills, plus links into the existing editors
  - ComplianceCheckTab.tsx: replaces standalone audit link with the
    panel; net -3 lines, stays at the 500-cap

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-17 14:06:28 +02:00
parent f4c9cea770
commit df7d83134b
13 changed files with 1207 additions and 15 deletions
@@ -0,0 +1,25 @@
/**
* Proxy: GET /api/sdk/v1/agent/migration/<checkId>/banner-preview
* -> backend GET /api/compliance/agent/migration/<checkId>/banner-preview
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
export async function GET(
request: NextRequest,
{ params }: { params: { checkId: string } },
) {
const qs = request.nextUrl.searchParams.toString()
const url = `${BACKEND_URL}/api/compliance/agent/migration/${params.checkId}/banner-preview${qs ? `?${qs}` : ''}`
try {
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) })
const data = await resp.json()
return NextResponse.json(data, { status: resp.status })
} catch {
return NextResponse.json(
{ error: 'Banner-Preview fehlgeschlagen' },
{ status: 503 },
)
}
}
@@ -0,0 +1,24 @@
/**
* Proxy: GET /api/sdk/v1/agent/migration/<checkId>/document-preview
* -> backend GET /api/compliance/agent/migration/<checkId>/document-preview
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
export async function GET(
_request: NextRequest,
{ params }: { params: { checkId: string } },
) {
const url = `${BACKEND_URL}/api/compliance/agent/migration/${params.checkId}/document-preview`
try {
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) })
const data = await resp.json()
return NextResponse.json(data, { status: resp.status })
} catch {
return NextResponse.json(
{ error: 'Dokument-Preview fehlgeschlagen' },
{ status: 503 },
)
}
}
@@ -0,0 +1,24 @@
/**
* Proxy: GET /api/sdk/v1/agent/migration/<checkId>/summary
* -> backend GET /api/compliance/agent/migration/<checkId>/summary
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
export async function GET(
_request: NextRequest,
{ params }: { params: { checkId: string } },
) {
const url = `${BACKEND_URL}/api/compliance/agent/migration/${params.checkId}/summary`
try {
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) })
const data = await resp.json()
return NextResponse.json(data, { status: resp.status })
} catch {
return NextResponse.json(
{ error: 'Migrations-Summary fehlgeschlagen' },
{ status: 503 },
)
}
}
@@ -3,6 +3,7 @@
import React, { useState, useCallback } from 'react'
import { ChecklistView } from './ChecklistView'
import { DocumentRow } from './DocumentRow'
import { MigrationPanel } from './MigrationPanel'
const DOCUMENT_TYPES = [
{ id: 'dse', label: 'DSI (Datenschutzinformation)', required: true },
@@ -455,21 +456,14 @@ export function ComplianceCheckTab() {
<ChecklistView results={results.results} />
{/* Email status + Full-audit link */}
<div className="mt-3 flex items-center justify-between gap-3">
{results.email_status && (
<div className="text-xs text-gray-500 flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${results.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status}
</div>
)}
{results.check_id && (
<a href={`/sdk/agent/audit/${results.check_id}`} target="_blank" rel="noopener"
className="text-xs text-blue-700 hover:text-blue-900 underline">
Voll-Audit oeffnen (alle MCs) &rarr;
</a>
)}
</div>
{/* Email + Migration + Full-audit */}
{results.email_status && (
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${results.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status}
</div>
)}
{results.check_id && <MigrationPanel checkId={results.check_id} />}
</div>
)}
@@ -0,0 +1,194 @@
'use client'
import { useState } from 'react'
interface BannerFlag {
level: 'ERROR' | 'WARNING' | 'INFO'
vendor: string
issue: string
message: string
}
interface BannerPreview {
config: { categories: { id: string; cookies: { name: string }[] }[] }
flags: BannerFlag[]
summary: {
vendors_total: number
vendors_with_no_cookies: number
cookies_total: number
categories: Record<string, number>
flags_error: number
flags_warning: number
flags_info: number
}
}
interface DocumentPreview {
check_id: string
vendor_count: number
templates: Record<string, {
templateType: string
initialContent: string
suggested_template_search?: string
}>
}
type Mode = 'banner' | 'documents'
export function MigrationPanel({ checkId }: { checkId: string }) {
const [open, setOpen] = useState(false)
const [mode, setMode] = useState<Mode>('banner')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [banner, setBanner] = useState<BannerPreview | null>(null)
const [docs, setDocs] = useState<DocumentPreview | null>(null)
async function loadPreview(next: Mode) {
setMode(next)
setOpen(true)
setError(null)
setLoading(true)
try {
const path = next === 'banner'
? `/api/sdk/v1/agent/migration/${checkId}/banner-preview`
: `/api/sdk/v1/agent/migration/${checkId}/document-preview`
const r = await fetch(path)
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const data = await r.json()
if (next === 'banner') setBanner(data)
else setDocs(data)
} catch (e) {
setError(e instanceof Error ? e.message : 'Preview-Ladefehler')
} finally {
setLoading(false)
}
}
return (
<>
<div className="mt-3 flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<button onClick={() => loadPreview('banner')}
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-purple-50 text-purple-700 border border-purple-200 hover:bg-purple-100">
Cookie-Banner uebernehmen
</button>
<button onClick={() => loadPreview('documents')}
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-amber-50 text-amber-700 border border-amber-200 hover:bg-amber-100">
Dokumente vorbefuellen
</button>
</div>
<a href={`/sdk/agent/audit/${checkId}`} target="_blank" rel="noopener"
className="text-xs text-blue-700 hover:text-blue-900 underline">
Voll-Audit oeffnen (alle MCs) &rarr;
</a>
</div>
{open && (
<div className="fixed inset-0 z-50 bg-black/40 flex items-start justify-center p-6 overflow-y-auto">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl p-6 mt-12">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">
{mode === 'banner' ? 'Cookie-Banner Migration' : 'Dokument-Vorbefuellung'}
</h3>
<button onClick={() => setOpen(false)}
className="text-gray-400 hover:text-gray-600 text-xl leading-none">&times;</button>
</div>
{loading && <div className="text-sm text-gray-500">Lade Preview ...</div>}
{error && <div className="text-sm text-red-600">Fehler: {error}</div>}
{!loading && !error && mode === 'banner' && banner && (
<BannerPreviewBody data={banner} />
)}
{!loading && !error && mode === 'documents' && docs && (
<DocumentPreviewBody data={docs} />
)}
<div className="mt-5 flex justify-end gap-2">
<button onClick={() => setOpen(false)}
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 hover:bg-gray-50">
Schliessen
</button>
<a href={mode === 'banner' ? '/sdk/einwilligungen' : '/sdk/document-generator'}
className="px-3 py-1.5 text-sm rounded-lg bg-purple-600 text-white hover:bg-purple-700">
Im Editor oeffnen
</a>
</div>
</div>
</div>
)}
</>
)
}
function BannerPreviewBody({ data }: { data: BannerPreview }) {
const { summary, flags, config } = data
return (
<div className="space-y-4 text-sm">
<div className="grid grid-cols-3 gap-3">
<Stat label="Anbieter" value={summary.vendors_total} />
<Stat label="Cookies" value={summary.cookies_total} />
<Stat label="Kategorien" value={Object.values(summary.categories).filter(n => n > 0).length} />
</div>
<div className="grid grid-cols-3 gap-3">
<Stat label="Fehler" value={summary.flags_error} tone="red" />
<Stat label="Warnungen" value={summary.flags_warning} tone="amber" />
<Stat label="Hinweise" value={summary.flags_info} tone="gray" />
</div>
<div>
<h4 className="font-medium text-gray-700 mb-1">Kategorien</h4>
<ul className="text-xs text-gray-600 space-y-0.5">
{config.categories.map(c => (
<li key={c.id}>{c.id}: {c.cookies.length} Cookie(s)</li>
))}
</ul>
</div>
{flags.length > 0 && (
<div>
<h4 className="font-medium text-gray-700 mb-1">Pruefpunkte</h4>
<ul className="text-xs space-y-0.5 max-h-48 overflow-y-auto">
{flags.map((f, i) => (
<li key={i} className={f.level === 'ERROR' ? 'text-red-700' : f.level === 'WARNING' ? 'text-amber-700' : 'text-gray-600'}>
[{f.level}] {f.vendor}: {f.message}
</li>
))}
</ul>
</div>
)}
</div>
)
}
function DocumentPreviewBody({ data }: { data: DocumentPreview }) {
return (
<div className="space-y-4 text-sm">
<div className="text-xs text-gray-600">
{data.vendor_count} Anbieter werden in {Object.keys(data.templates).length} Vorlagen eingespielt.
</div>
{Object.entries(data.templates).map(([key, tpl]) => (
<div key={key} className="border border-gray-200 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-gray-800">{tpl.templateType}</h4>
{tpl.suggested_template_search && (
<span className="text-xs text-gray-500">Vorschlag: {tpl.suggested_template_search}</span>
)}
</div>
<pre className="text-xs bg-gray-50 rounded p-2 max-h-48 overflow-auto whitespace-pre-wrap">
{tpl.initialContent.slice(0, 1200)}{tpl.initialContent.length > 1200 ? '\n…' : ''}
</pre>
</div>
))}
</div>
)
}
function Stat({ label, value, tone = 'gray' }: { label: string; value: number; tone?: 'red' | 'amber' | 'gray' }) {
const color = tone === 'red' ? 'text-red-700' : tone === 'amber' ? 'text-amber-700' : 'text-gray-800'
return (
<div className="border border-gray-200 rounded-lg p-2 text-center">
<div className={`text-lg font-semibold ${color}`}>{value}</div>
<div className="text-xs text-gray-500">{label}</div>
</div>
)
}
@@ -551,6 +551,12 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
},
mc_records=audit_rows,
)
from compliance.services.compliance_audit_log import record_check_payload
record_check_payload(
check_id=check_id,
vendors=cmp_vendors,
profile=extracted_profile,
)
except Exception as e:
logger.warning("Audit persistence skipped: %s", e)
@@ -0,0 +1,123 @@
"""
Migration endpoints: Compliance-Check → Customer Banner / Documents.
After a /compliance/agent/compliance-check run finishes, the user can
migrate the extracted CMP vendor list + extracted profile into:
- their CookieBanner config (admin-compliance /sdk/einwilligungen)
- their Document-Generator (Cookie-Policy / VVT / Privacy-Policy)
These endpoints are read-only previews — the actual write to a tenant's
SDK state is initiated by the frontend with the existing save endpoints
(/sdk/cookie-banner, /sdk/document-generator). We only return the
ready-to-apply payload + flags for manual review.
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, HTTPException, Query
from compliance.services.compliance_audit_log import (
get_check_payload, get_check_run,
)
from compliance.services.migration_to_banner import build_banner_config
from compliance.services.migration_to_document import build_document_prefills
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/compliance/agent/migration", tags=["agent-migration"])
def _load_check_context(check_id: str) -> tuple[list[dict], dict, dict]:
"""Return (vendors, profile, run_meta) for a stored check_id."""
# Prefer the in-memory job cache (richest data, before sidecar trim).
try:
from compliance.api.agent_compliance_check_routes import (
_compliance_check_jobs,
)
job = _compliance_check_jobs.get(check_id)
except Exception:
job = None
if job and (result := job.get("result")):
return (
result.get("cmp_vendors") or [],
result.get("extracted_profile") or {},
{
"site_name": result.get("business_profile", {}).get("siteName", ""),
"base_domain": result.get("business_profile", {}).get("baseUrl", ""),
},
)
payload = get_check_payload(check_id)
if payload is None:
raise HTTPException(404, f"Unknown check_id '{check_id}'")
run = get_check_run(check_id) or {}
return (
payload.get("vendors") or [],
payload.get("profile") or {},
{
"site_name": run.get("site_name", ""),
"base_domain": run.get("base_domain", ""),
},
)
@router.get("/{check_id}/banner-preview")
async def preview_banner_migration(
check_id: str,
language: str = Query("de", pattern="^(de|en)$"),
) -> dict[str, Any]:
"""Build a CookieBannerConfig from a finished compliance-check run.
Returns: { config, flags, summary } — the frontend renders a diff
against the tenant's current banner and lets the user accept.
"""
vendors, _profile, meta = _load_check_context(check_id)
return build_banner_config(
vendors=vendors,
site_name=meta["site_name"] or meta["base_domain"],
privacy_policy_url="",
language=language,
)
@router.get("/{check_id}/document-preview")
async def preview_document_migration(check_id: str) -> dict[str, Any]:
"""Return pre-fills for cookie_policy / vvt_register / privacy_policy."""
vendors, profile, meta = _load_check_context(check_id)
prefills = build_document_prefills(
vendors=vendors,
extracted_profile={"company_profile": profile} if profile else None,
site_name=meta["site_name"] or meta["base_domain"],
privacy_policy_url="",
)
return {
"check_id": check_id,
"site_name": meta["site_name"],
"base_domain": meta["base_domain"],
"vendor_count": len(vendors),
"templates": prefills,
}
@router.get("/{check_id}/summary")
async def migration_summary(check_id: str) -> dict[str, Any]:
"""High-level summary: how many vendors, how many cookies, how many issues."""
vendors, profile, meta = _load_check_context(check_id)
banner = build_banner_config(
vendors=vendors,
site_name=meta["site_name"] or meta["base_domain"],
)
return {
"check_id": check_id,
"site_name": meta["site_name"],
"base_domain": meta["base_domain"],
"company_name": (profile or {}).get("companyName", ""),
"vendor_count": len(vendors),
"banner_summary": banner.get("summary"),
"available_templates": [
"cookie_policy", "vvt_register", "privacy_policy",
],
}
@@ -58,9 +58,63 @@ def _ensure_db() -> None:
);
CREATE INDEX IF NOT EXISTS idx_mc_check ON mc_results(check_id);
CREATE INDEX IF NOT EXISTS idx_mc_reg ON mc_results(regulation, passed);
-- Migration-source payloads (cmp_vendors + extracted_profile),
-- kept as JSON blobs so the /migration/* endpoints can rebuild
-- a banner config or document pre-fill after the in-memory
-- _compliance_check_jobs entry is gone.
CREATE TABLE IF NOT EXISTS check_payloads (
check_id TEXT PRIMARY KEY,
vendors TEXT, -- JSON list[dict]
profile TEXT -- JSON dict
);
""")
def record_check_payload(
check_id: str,
vendors: list[dict] | None,
profile: dict | None,
) -> None:
"""Persist cmp_vendors + extracted_profile for later migration use."""
try:
_ensure_db()
with sqlite3.connect(DB_PATH) as conn:
conn.execute(
"INSERT OR REPLACE INTO check_payloads "
"(check_id, vendors, profile) VALUES (?, ?, ?)",
(
check_id,
json.dumps(vendors or [], ensure_ascii=False),
json.dumps(profile or {}, ensure_ascii=False),
),
)
conn.commit()
except Exception as e:
logger.warning("record_check_payload failed for %s: %s", check_id, e)
def get_check_payload(check_id: str) -> dict | None:
"""Load cmp_vendors + extracted_profile for a previous check."""
try:
_ensure_db()
with sqlite3.connect(DB_PATH) as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT vendors, profile FROM check_payloads WHERE check_id=?",
(check_id,),
).fetchone()
if not row:
return None
return {
"vendors": json.loads(row["vendors"] or "[]"),
"profile": json.loads(row["profile"] or "{}"),
}
except Exception as e:
logger.warning("get_check_payload failed: %s", e)
return None
def record_check_run(
check_id: str,
tenant_id: str,
@@ -0,0 +1,232 @@
"""
Migrate extracted vendor records -> CookieBannerConfig (admin-compliance
schema in einwilligungen/types/cookie-banner.ts).
Input : list[VendorRecord] as produced by vendor_extractor +
vendor_classifier + cookie_link_validator
Output : dict matching CookieBannerConfig shape, ready for the
/sdk/cookie-banner module to import.
The mapper also returns `flags[]` — items that need manual review
before going live (broken opt-out URL, missing expiry, etc.).
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
# ePaaS / OneTrust / etc. category -> CookieBannerCategory enum.
# CookieCategory has only 4 values, so we project marketing onto
# PERSONALIZATION and use EXTERNAL_MEDIA for embedded-content vendors.
_CATEGORY_MAP = {
"necessary": "ESSENTIAL",
"strictlynecessary": "ESSENTIAL",
"essential": "ESSENTIAL",
"functional": "ESSENTIAL", # Cookiebot conflates these
"statistics": "PERFORMANCE",
"analytics": "PERFORMANCE",
"performance": "PERFORMANCE",
"marketing": "PERSONALIZATION",
"advertising": "PERSONALIZATION",
"personalization": "PERSONALIZATION",
}
# Vendor names that indicate embedded external content
_EXTERNAL_MEDIA_HINTS = (
"youtube", "vimeo", "twitch", "google maps", "googlemaps",
"soundcloud", "spotify",
)
_CATEGORY_LABELS = {
"ESSENTIAL": {
"de": "Erforderliche Cookies",
"en": "Essential Cookies",
"desc_de": "Diese Cookies sind fuer den Betrieb der Website "
"unbedingt erforderlich (§25 Abs. 2 TDDDG) und koennen "
"nicht deaktiviert werden.",
"desc_en": "These cookies are strictly necessary for the operation "
"of the website and cannot be disabled.",
},
"PERFORMANCE": {
"de": "Analyse & Performance",
"en": "Analytics & Performance",
"desc_de": "Analyse-Cookies messen die Nutzung unserer Website, "
"um sie kontinuierlich zu verbessern.",
"desc_en": "Analytics cookies measure how visitors use our site so "
"we can improve it.",
},
"PERSONALIZATION": {
"de": "Marketing & Personalisierung",
"en": "Marketing & Personalization",
"desc_de": "Diese Cookies dienen der personalisierten "
"Ansprache und werbebezogenen Auswertung.",
"desc_en": "These cookies support personalised content and "
"marketing measurement.",
},
"EXTERNAL_MEDIA": {
"de": "Externe Medien",
"en": "External Media",
"desc_de": "Eingebettete Inhalte von Drittanbietern (z. B. "
"Videos, Karten, Audio) koennen Cookies setzen.",
"desc_en": "Embedded third-party media (videos, maps, audio) "
"may set cookies.",
},
}
def map_category(vendor_category: str, vendor_name: str) -> str:
"""Resolve a CMP category + vendor name to a CookieCategory enum value."""
name_l = (vendor_name or "").lower()
if any(h in name_l for h in _EXTERNAL_MEDIA_HINTS):
return "EXTERNAL_MEDIA"
return _CATEGORY_MAP.get((vendor_category or "").lower(), "PERSONALIZATION")
def build_banner_config(
vendors: list[dict],
site_name: str = "",
privacy_policy_url: str = "",
language: str = "de",
) -> dict:
"""Produce a CookieBannerConfig + flags from the extracted vendor list.
`vendors` is the list emitted by vendor_extractor.extract_vendors_from_payloads
(+ score_vendors for the compliance_flags). We bucket them by canonical
CookieCategory and build a CookieInfo entry per persistence.
"""
by_cat: dict[str, list[dict]] = {
"ESSENTIAL": [], "PERFORMANCE": [],
"PERSONALIZATION": [], "EXTERNAL_MEDIA": [],
}
flags: list[dict] = []
cookies_total = 0
vendors_with_no_cookies = 0
for v in vendors or []:
cat = map_category(v.get("category", ""), v.get("name", ""))
provider = v.get("name") or "Unbekannt"
cookies = v.get("cookies") or []
if not cookies:
vendors_with_no_cookies += 1
flags.append({
"level": "WARNING",
"vendor": provider,
"issue": "no_cookies_listed",
"message": (
f"Anbieter '{provider}' wurde erfasst, "
"aber keine Cookies sind dokumentiert. Vor "
"Veroeffentlichung manuell ergaenzen."
),
})
continue
for c in cookies:
cname = (c.get("name") or "").strip()
if not cname:
continue
cookies_total += 1
entry = {
"name": cname,
"provider": provider,
"purpose": {language: c.get("purpose") or v.get("purpose") or ""},
"expiry": c.get("expiry") or "",
"type": ("THIRD_PARTY"
if c.get("is_third_party") else "FIRST_PARTY"),
}
by_cat[cat].append(entry)
if not c.get("expiry"):
flags.append({
"level": "INFO",
"vendor": provider,
"issue": "cookie_no_expiry",
"message": (
f"Cookie '{cname}' bei '{provider}' ohne "
"Speicherdauer — fuer DSK-Konformitaet ergaenzen."
),
})
# Vendor-level link validation flags
if v.get("opt_out_url") and v.get("opt_out_ok") is False:
flags.append({
"level": "ERROR",
"vendor": provider,
"issue": "broken_opt_out",
"message": (
f"Opt-Out-Link von '{provider}' antwortet mit "
f"HTTP {v.get('opt_out_status')}"
"Art. 7(3) DSGVO erfordert funktionierenden Widerruf."
),
})
categories: list[dict] = []
for cat_id in ("ESSENTIAL", "PERFORMANCE", "PERSONALIZATION", "EXTERNAL_MEDIA"):
cookies = by_cat[cat_id]
if not cookies and cat_id != "ESSENTIAL":
continue
meta = _CATEGORY_LABELS[cat_id]
categories.append({
"id": cat_id,
"name": {"de": meta["de"], "en": meta["en"]},
"description": {"de": meta["desc_de"], "en": meta["desc_en"]},
"isRequired": cat_id == "ESSENTIAL",
"defaultEnabled": cat_id == "ESSENTIAL",
"dataPointIds": [],
"cookies": cookies,
})
config = {
"id": "", # filled by tenant on apply
"tenantId": "",
"categories": categories,
"styling": {
"position": "BOTTOM",
"theme": "LIGHT",
"primaryColor": "#2563eb",
"borderRadius": 8,
},
"texts": {
"title": {"de": "Wir verwenden Cookies",
"en": "We use cookies"},
"description": {
"de": (f"Auf {site_name or 'unserer Website'} setzen wir "
"Cookies und aehnliche Technologien ein, um die "
"Nutzererfahrung zu verbessern. Sie koennen Ihre "
"Auswahl jederzeit anpassen."),
"en": (f"On {site_name or 'this website'} we use cookies "
"and similar technologies. You can change your "
"selection at any time."),
},
"acceptAll": {"de": "Alle akzeptieren", "en": "Accept all"},
"rejectAll": {"de": "Alle ablehnen", "en": "Reject all"},
"customize": {"de": "Auswahl anpassen", "en": "Customize"},
"save": {"de": "Auswahl speichern", "en": "Save preferences"},
"privacyPolicyLink": {"de": privacy_policy_url or "/datenschutz",
"en": privacy_policy_url or "/privacy"},
},
}
summary = {
"vendors_total": len(vendors or []),
"vendors_with_no_cookies": vendors_with_no_cookies,
"cookies_total": cookies_total,
"categories": {cat_id: len(by_cat[cat_id]) for cat_id in by_cat},
"flags_error": sum(1 for f in flags if f["level"] == "ERROR"),
"flags_warning": sum(1 for f in flags if f["level"] == "WARNING"),
"flags_info": sum(1 for f in flags if f["level"] == "INFO"),
}
logger.info(
"Banner migration prepared: %d vendors -> %d cookies in %d "
"categories. Flags: %d ERROR, %d WARNING, %d INFO.",
summary["vendors_total"], summary["cookies_total"],
len(categories), summary["flags_error"],
summary["flags_warning"], summary["flags_info"],
)
return {
"config": config,
"flags": flags,
"summary": summary,
}
@@ -0,0 +1,260 @@
"""
Migrate extracted vendor records + scorecard -> Document-Generator
pre-fills.
We can pre-fill several templateType candidates:
- cookie_policy : compose a Cookie-Richtlinie text from the vendor
list (one section per category, table per vendor with name, purpose,
expiry, opt-out)
- vvt_register : populate VVT entries (one per vendor, with recipient
category, opt-out URL, etc.)
- privacy_policy: a 'Drittanbieter' section listing vendors as
recipients + transfer mechanism
Output for each: {templateType, placeholderValues, initialContent,
suggested_template_search} that the frontend can drop into
DocumentGeneratorState.
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
_RECIPIENT_TYPE_LABEL = {
"INTERNAL": "Eigene Verarbeitung",
"GROUP_COMPANY": "Konzernunternehmen",
"PROCESSOR": "Auftragsverarbeiter",
"CONTROLLER": "Joint / unabhaengiger Verantwortlicher",
"AUTHORITY": "Behoerde",
"OTHER": "Sonstiger Empfaenger",
}
def build_document_prefills(
vendors: list[dict],
extracted_profile: dict | None = None,
site_name: str = "",
privacy_policy_url: str = "",
) -> dict:
"""Generate pre-fills for cookie_policy + vvt_register + privacy_policy."""
profile = (extracted_profile or {}).get("company_profile", {}) or {}
company_name = (profile.get("companyName") or site_name or "Unbekannt").strip()
address = ", ".join(filter(None, [
profile.get("headquartersStreet"),
profile.get("headquartersZip"),
profile.get("headquartersCity"),
]))
dpo_email = profile.get("dpoEmail") or ""
placeholders_common = {
"company_name": company_name,
"company_address": address,
"dpo_email": dpo_email,
"privacy_policy_url": privacy_policy_url,
"site_name": site_name,
"vendor_count": str(len(vendors or [])),
}
return {
"cookie_policy": _build_cookie_policy(
vendors or [], placeholders_common,
),
"vvt_register": _build_vvt_register(
vendors or [], placeholders_common,
),
"privacy_policy": _build_privacy_policy_section(
vendors or [], placeholders_common,
),
}
# ── cookie_policy ───────────────────────────────────────────────────
def _build_cookie_policy(vendors: list[dict], placeholders: dict) -> dict:
by_cat: dict[str, list[dict]] = {}
for v in vendors:
cat = (v.get("category") or "marketing").lower()
by_cat.setdefault(cat, []).append(v)
parts: list[str] = [
"# Cookie-Richtlinie",
"",
f"Diese Cookie-Richtlinie informiert Sie ueber den Einsatz von Cookies "
f"und aehnlichen Technologien auf den Webseiten der "
f"**{placeholders['company_name']}**.",
"",
]
if placeholders["company_address"]:
parts.append(f"Verantwortlich: {placeholders['company_name']}, "
f"{placeholders['company_address']}.")
parts.append("")
if placeholders["dpo_email"]:
parts.append(f"Datenschutzbeauftragte/r erreichbar unter: "
f"{placeholders['dpo_email']}.")
parts.append("")
cat_order = ("necessary", "strictlynecessary", "functional",
"statistics", "performance", "marketing", "advertising",
"personalization")
for cat in cat_order:
rows = by_cat.get(cat) or []
if not rows:
continue
parts.append("")
parts.append(f"## Kategorie: {_human_cat(cat)}")
parts.append("")
parts.append("| Anbieter | Zweck | Speicherdauer | Opt-Out |")
parts.append("|----------|-------|---------------|---------|")
for v in rows:
name = (v.get("name") or "").replace("|", " ")
purpose = (v.get("purpose") or "").replace("|", " ")[:140]
persistence = ", ".join(
c.get("expiry", "") for c in (v.get("cookies") or [])
if c.get("expiry")
)[:60] or ""
opt = v.get("opt_out_url") or ""
parts.append(f"| {name} | {purpose} | {persistence} | {opt} |")
parts.append("")
parts.append("Stand: automatisch generiert durch BreakPilot. "
"Bitte vor Veroeffentlichung pruefen.")
return {
"templateType": "cookie_policy",
"placeholderValues": placeholders,
"initialContent": "\n".join(parts),
"suggested_template_search": "Cookie-Richtlinie DSGVO TDDDG Deutsch",
}
def _human_cat(cat: str) -> str:
return {
"necessary": "Erforderlich (§25 Abs. 2 TDDDG)",
"strictlynecessary": "Erforderlich (§25 Abs. 2 TDDDG)",
"functional": "Funktional",
"statistics": "Statistik / Analyse",
"performance": "Statistik / Analyse",
"marketing": "Marketing & Werbung",
"advertising": "Marketing & Werbung",
"personalization": "Personalisierung",
}.get(cat, cat.capitalize())
# ── vvt_register ────────────────────────────────────────────────────
def _build_vvt_register(vendors: list[dict], placeholders: dict) -> dict:
"""Generate VVT-Eintraege als JSON, das der vvt-Modul direkt importieren kann.
Schema lehnt sich an admin-compliance/lib/sdk/vvt-types.ts VVTActivity an.
"""
activities: list[dict] = []
for v in vendors:
rtype = (v.get("recipient_type") or "OTHER").upper()
recipient_type = (
"INTERNAL" if rtype == "INTERNAL"
else "GROUP_COMPANY" if rtype == "GROUP_COMPANY"
else "PROCESSOR" if rtype == "PROCESSOR"
else "CONTROLLER" if rtype == "CONTROLLER"
else "OTHER"
)
activities.append({
"name": v.get("name") or "Unbekannte Verarbeitung",
"description": v.get("purpose") or "",
"purposes": [v.get("purpose")] if v.get("purpose") else [],
"businessFunction": _guess_business_function(v),
"recipientCategories": [{
"type": recipient_type,
"name": v.get("name") or "",
"isThirdCountry": bool(v.get("country")) and
v.get("country") not in
("DE", "AT", "BE", "BG", "HR", "CY", "CZ",
"DK", "EE", "FI", "FR", "GR", "HU", "IE",
"IT", "LV", "LT", "LU", "MT", "NL", "PL",
"PT", "RO", "SK", "SI", "ES", "SE", "IS",
"LI", "NO", "CH"),
"country": v.get("country") or "",
}],
"retentionPeriod": {
"description": _summarise_expiry(v.get("cookies") or []),
},
"tomDescription": "Siehe TOM-Anlage (automatisch verlinken).",
"sourceTemplateId": f"breakpilot-cookie-vendor-{v.get('name', '').lower()[:30]}",
})
return {
"templateType": "vvt_register",
"placeholderValues": placeholders,
"initialContent": "",
"activities": activities,
"suggested_template_search": "VVT Art. 30 DSGVO Verarbeitungsverzeichnis",
}
def _guess_business_function(v: dict) -> str:
cat = (v.get("category") or "").lower()
if cat in ("marketing", "advertising", "personalization"):
return "marketing"
if cat in ("statistics", "performance", "analytics"):
return "marketing"
if cat in ("necessary", "strictlynecessary"):
return "it_operations"
return "other"
def _summarise_expiry(cookies: list[dict]) -> str:
exps = sorted({(c.get("expiry") or "").strip()
for c in cookies if c.get("expiry")})
if not exps:
return "Speicherdauer pro Cookie pflegen."
if len(exps) == 1:
return exps[0]
return ", ".join(exps[:5]) + (f" (+{len(exps) - 5} weitere)" if len(exps) > 5 else "")
# ── privacy_policy (Drittanbieter-Block) ───────────────────────────
def _build_privacy_policy_section(vendors: list[dict], placeholders: dict) -> dict:
"""Generate the 'Drittanbieter' section for the privacy_policy template."""
by_rtype: dict[str, list[dict]] = {}
for v in vendors:
rtype = (v.get("recipient_type") or "OTHER").upper()
by_rtype.setdefault(rtype, []).append(v)
parts: list[str] = [
"## Empfaenger personenbezogener Daten",
"",
f"Im Rahmen unseres Webseiten-Betriebs uebermitteln wir "
f"personenbezogene Daten an folgende Kategorien von Empfaengern "
f"(Art. 13 Abs. 1 lit. e DSGVO):",
"",
]
order = ("INTERNAL", "GROUP_COMPANY", "PROCESSOR", "CONTROLLER",
"AUTHORITY", "OTHER")
for rtype in order:
rows = by_rtype.get(rtype) or []
if not rows:
continue
parts.append(f"### {_RECIPIENT_TYPE_LABEL.get(rtype, rtype)}")
for v in rows:
name = v.get("name") or ""
country = v.get("country") or ""
country_str = f" ({country})" if country else ""
line = f"- **{name}**{country_str}"
if v.get("purpose"):
line += f"{v.get('purpose')[:140]}"
if v.get("opt_out_url"):
line += f" [Opt-Out]({v.get('opt_out_url')})"
if v.get("privacy_policy_url"):
line += f" [Datenschutz]({v.get('privacy_policy_url')})"
parts.append(line)
parts.append("")
return {
"templateType": "privacy_policy",
"placeholderValues": placeholders,
"initialContent": "\n".join(parts),
"suggested_template_search": "Datenschutzerklaerung Art. 13 DSGVO Webseite",
}
+2
View File
@@ -50,6 +50,7 @@ from compliance.api.agent_recurring_routes import router as agent_recurring_rout
from compliance.api.agent_compare_routes import router as agent_compare_router
from compliance.api.agent_doc_check_routes import router as agent_doc_check_router
from compliance.api.agent_compliance_check_routes import router as agent_compliance_check_router
from compliance.api.agent_migration_routes import router as agent_migration_router
from compliance.api.vendor_assessment_routes import router as vendor_assessment_router
# Middleware
@@ -155,6 +156,7 @@ app.include_router(agent_recurring_router, prefix="/api")
app.include_router(agent_compare_router, prefix="/api")
app.include_router(agent_doc_check_router, prefix="/api")
app.include_router(agent_compliance_check_router, prefix="/api")
app.include_router(agent_migration_router, prefix="/api")
# Vendor Contract Assessment
app.include_router(vendor_assessment_router, prefix="/api")
@@ -0,0 +1,116 @@
"""
Integration test for the /compliance/agent/migration/* endpoints.
Simulates a finished compliance-check run by persisting cmp_vendors +
extracted_profile via the sidecar audit log, then exercises the FastAPI
TestClient against banner-preview / document-preview / summary.
This is the M5 BMW-scenario in miniature: realistic ePaaS-shaped vendor
records (BMW INTERNAL + 2 third-party PROCESSOR) feed through to a
ready-to-apply banner config and pre-filled documents.
"""
from __future__ import annotations
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def app_client(monkeypatch):
# Isolate the sidecar SQLite so this test never races with /data prod DB
tmp = tempfile.mkdtemp()
monkeypatch.setenv("COMPLIANCE_AUDIT_DB", os.path.join(tmp, "audit.db"))
# Build a minimal app — avoid importing the full main.py which pulls in
# smtp_sender / weasyprint / pydantic-v1 modules not relevant here.
from fastapi import FastAPI
from compliance.api.agent_migration_routes import router
app = FastAPI()
app.include_router(router, prefix="/api")
return TestClient(app)
@pytest.fixture()
def seeded_check_id():
"""Persist a fake compliance check so the migration routes have data."""
from compliance.services.compliance_audit_log import record_check_payload, record_check_run
cid = "bmw-test-check-001"
vendors = [
{
"name": "BMW AG", "category": "necessary",
"recipient_type": "INTERNAL",
"purpose": "Grundfunktionen + Login",
"cookies": [{"name": "JSESSIONID", "expiry": "Session"}],
},
{
"name": "Adobe Analytics", "category": "statistics",
"recipient_type": "PROCESSOR", "country": "US",
"purpose": "Reichweitenmessung",
"opt_out_url": "https://adobe.com/opt-out", "opt_out_ok": True,
"privacy_policy_url": "https://adobe.com/privacy",
"cookies": [{"name": "s_cc", "expiry": "1 Tag",
"is_third_party": True}],
},
{
"name": "YouTube", "category": "marketing",
"recipient_type": "PROCESSOR", "country": "US",
"purpose": "Videos",
"cookies": [{"name": "VISITOR_INFO1_LIVE", "expiry": "6 Monate",
"is_third_party": True}],
},
]
record_check_run(
check_id=cid, tenant_id="t1", site_name="bmw.de",
base_domain="bmw.de", doc_count=4,
scorecard={"totals": {"pct": 75, "passed": 30, "failed": 10,
"total": 40, "skipped": 0}},
)
record_check_payload(check_id=cid, vendors=vendors,
profile={"companyName": "BMW AG",
"headquartersStreet": "Petuelring 130",
"headquartersZip": "80809",
"headquartersCity": "Muenchen",
"dpoEmail": "datenschutz@bmw.de"})
return cid
def test_banner_preview_returns_valid_config(app_client, seeded_check_id):
r = app_client.get(f"/api/compliance/agent/migration/{seeded_check_id}/banner-preview")
assert r.status_code == 200, r.text
body = r.json()
assert body["summary"]["vendors_total"] == 3
cat_ids = {c["id"] for c in body["config"]["categories"]}
assert "ESSENTIAL" in cat_ids
assert "EXTERNAL_MEDIA" in cat_ids # YouTube
# BMW AG (INTERNAL, cookies present) should not raise any flags
assert not any(f.get("vendor") == "BMW AG" for f in body["flags"])
def test_document_preview_includes_all_three_templates(app_client, seeded_check_id):
r = app_client.get(f"/api/compliance/agent/migration/{seeded_check_id}/document-preview")
assert r.status_code == 200, r.text
body = r.json()
assert body["vendor_count"] == 3
assert set(body["templates"].keys()) == {
"cookie_policy", "vvt_register", "privacy_policy",
}
assert "BMW AG" in body["templates"]["cookie_policy"]["initialContent"]
assert "Petuelring" in body["templates"]["cookie_policy"]["initialContent"]
def test_summary_returns_overview(app_client, seeded_check_id):
r = app_client.get(f"/api/compliance/agent/migration/{seeded_check_id}/summary")
assert r.status_code == 200, r.text
body = r.json()
assert body["company_name"] == "BMW AG"
assert body["vendor_count"] == 3
assert body["site_name"] == "bmw.de"
assert "cookie_policy" in body["available_templates"]
def test_unknown_check_id_returns_404(app_client):
r = app_client.get("/api/compliance/agent/migration/nope-not-there/banner-preview")
assert r.status_code == 404
@@ -0,0 +1,138 @@
"""
Unit tests for vendor → CookieBannerConfig and vendor → Document pre-fill
mappers (M1 + M2 of the customer-banner migration feature).
"""
from __future__ import annotations
import pytest
from compliance.services.migration_to_banner import (
build_banner_config, map_category,
)
from compliance.services.migration_to_document import build_document_prefills
@pytest.fixture()
def sample_vendors() -> list[dict]:
return [
{
"name": "BMW AG",
"category": "necessary",
"recipient_type": "INTERNAL",
"purpose": "Sicherstellung der Grundfunktionen",
"cookies": [{"name": "JSESSIONID", "expiry": "Session"}],
},
{
"name": "Google Analytics",
"category": "statistics",
"recipient_type": "PROCESSOR",
"purpose": "Reichweitenmessung",
"country": "US",
"opt_out_url": "https://tools.google.com/dlpage/gaoptout",
"opt_out_ok": True,
"privacy_policy_url": "https://policies.google.com/privacy",
"cookies": [
{"name": "_ga", "expiry": "2 Jahre", "is_third_party": True},
{"name": "_gid", "expiry": "1 Tag", "is_third_party": True},
],
},
{
"name": "YouTube",
"category": "marketing",
"recipient_type": "PROCESSOR",
"purpose": "Eingebettete Videos",
"cookies": [],
},
{
"name": "Broken Pixel",
"category": "marketing",
"recipient_type": "PROCESSOR",
"purpose": "Werbung",
"opt_out_url": "https://example.com/optout",
"opt_out_ok": False,
"opt_out_status": 404,
"cookies": [{"name": "_pix", "expiry": ""}],
},
]
def test_map_category_youtube_routes_to_external_media():
assert map_category("marketing", "YouTube Player") == "EXTERNAL_MEDIA"
def test_map_category_unknown_falls_back_to_personalization():
assert map_category("weird-cat", "Some Vendor") == "PERSONALIZATION"
def test_map_category_necessary_is_essential():
assert map_category("necessary", "JSESSIONID") == "ESSENTIAL"
def test_build_banner_config_buckets_categories(sample_vendors):
out = build_banner_config(sample_vendors, site_name="bmw.de")
cats = {c["id"]: c for c in out["config"]["categories"]}
assert "ESSENTIAL" in cats
assert "PERFORMANCE" in cats
assert any(c["name"] == "_ga" for c in cats["PERFORMANCE"]["cookies"])
# YouTube vendor had no cookies → it should not pollute EXTERNAL_MEDIA
# but should produce a WARNING flag for the missing list
assert any(f["vendor"] == "YouTube" and f["issue"] == "no_cookies_listed"
for f in out["flags"])
def test_build_banner_config_flags_broken_opt_out(sample_vendors):
out = build_banner_config(sample_vendors, site_name="bmw.de")
errors = [f for f in out["flags"] if f["level"] == "ERROR"]
assert any(f["issue"] == "broken_opt_out" and f["vendor"] == "Broken Pixel"
for f in errors)
def test_build_banner_config_summary_counts(sample_vendors):
out = build_banner_config(sample_vendors, site_name="bmw.de")
s = out["summary"]
assert s["vendors_total"] == 4
assert s["vendors_with_no_cookies"] == 1
assert s["cookies_total"] == 4 # JSESSIONID + _ga + _gid + _pix
def test_build_document_prefills_emits_all_three_templates(sample_vendors):
out = build_document_prefills(
sample_vendors,
extracted_profile={
"company_profile": {
"companyName": "BMW AG",
"headquartersStreet": "Petuelring 130",
"headquartersZip": "80809",
"headquartersCity": "Muenchen",
"dpoEmail": "datenschutz@bmw.de",
},
},
site_name="bmw.de",
)
assert set(out.keys()) == {"cookie_policy", "vvt_register", "privacy_policy"}
cp = out["cookie_policy"]
assert cp["templateType"] == "cookie_policy"
assert "BMW AG" in cp["initialContent"]
assert "Google Analytics" in cp["initialContent"]
assert "Petuelring 130" in cp["initialContent"]
def test_vvt_register_marks_third_country_for_us_processor(sample_vendors):
out = build_document_prefills(sample_vendors, site_name="bmw.de")
acts = out["vvt_register"]["activities"]
ga = next(a for a in acts if a["name"] == "Google Analytics")
rcat = ga["recipientCategories"][0]
assert rcat["type"] == "PROCESSOR"
assert rcat["country"] == "US"
assert rcat["isThirdCountry"] is True
def test_privacy_policy_section_groups_by_recipient_type(sample_vendors):
out = build_document_prefills(sample_vendors, site_name="bmw.de")
body = out["privacy_policy"]["initialContent"]
assert "Eigene Verarbeitung" in body
assert "Auftragsverarbeiter" in body
# BMW AG (INTERNAL) must appear under Eigene, not under Auftragsverarbeiter
internal_block = body.split("### Auftragsverarbeiter")[0]
assert "BMW AG" in internal_block