From 965af3a34cae28c9063f9d6400577638305d63e9 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Sun, 3 May 2026 21:42:50 +0200 Subject: [PATCH] feat: A/B Testing + Compliance Report PDF (F5 + F8) F5: A/B Testing for Consent Rate - Migration 116: banner_variants table + variant tracking in audit log - BannerABService: deterministic sticky bucketing via device hash, chi-squared significance testing, variant CRUD - banner_ab_routes: 6 endpoints (CRUD + stats + assign) - ABTestPanel.tsx: variant creation, traffic sliders, opt-in comparison chart with winner/significance badges - New "A/B-Test" tab in cookie-banner page F8: Compliance Report PDF - CompliancePDFGenerator: reportlab-based A4 PDF covering all modules (Company Profile, TOM, VVT, DSFA, Risks, Vendors, Incidents, Reviews, Consents, Roles) - compliance_report_routes: GET /compliance/report/pdf - "Compliance-Report herunterladen" button on SDK dashboard [migration-approved] Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cookie-banner/_components/ABTestPanel.tsx | 203 ++++++++++++++++ .../app/sdk/cookie-banner/page.tsx | 7 +- admin-compliance/app/sdk/page.tsx | 18 ++ backend-compliance/compliance/api/__init__.py | 2 + .../compliance/api/banner_ab_routes.py | 120 ++++++++++ .../api/compliance_report_routes.py | 38 +++ .../compliance/services/banner_ab_service.py | 193 ++++++++++++++++ .../services/compliance_pdf_generator.py | 216 ++++++++++++++++++ .../migrations/116_banner_ab_testing.sql | 31 +++ 9 files changed, 827 insertions(+), 1 deletion(-) create mode 100644 admin-compliance/app/sdk/cookie-banner/_components/ABTestPanel.tsx create mode 100644 backend-compliance/compliance/api/banner_ab_routes.py create mode 100644 backend-compliance/compliance/api/compliance_report_routes.py create mode 100644 backend-compliance/compliance/services/banner_ab_service.py create mode 100644 backend-compliance/compliance/services/compliance_pdf_generator.py create mode 100644 backend-compliance/migrations/116_banner_ab_testing.sql diff --git a/admin-compliance/app/sdk/cookie-banner/_components/ABTestPanel.tsx b/admin-compliance/app/sdk/cookie-banner/_components/ABTestPanel.tsx new file mode 100644 index 0000000..4d8a719 --- /dev/null +++ b/admin-compliance/app/sdk/cookie-banner/_components/ABTestPanel.tsx @@ -0,0 +1,203 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' + +interface Variant { + id: string + variant_name: string + variant_key: string + traffic_percent: number + is_control: boolean + banner_title: string | null + banner_description: string | null + position: string | null + primary_color: string | null + is_active: boolean +} + +interface VariantStat { + variant_id: string + variant_key: string + variant_name: string + traffic_percent: number + is_control: boolean + total: number + accepted: number + opt_in_rate: number + is_winner?: boolean + significance?: number +} + +const API = '/api/sdk/v1/compliance/banner/ab' + +export function ABTestPanel({ siteConfigId }: { siteConfigId?: string }) { + const [variants, setVariants] = useState([]) + const [stats, setStats] = useState([]) + const [loading, setLoading] = useState(true) + const [showCreate, setShowCreate] = useState(false) + const [newVariant, setNewVariant] = useState({ variant_name: '', variant_key: 'B', traffic_percent: 50, banner_title: '', primary_color: '' }) + + const scid = siteConfigId || '' + + const loadData = useCallback(async () => { + if (!scid) { setLoading(false); return } + setLoading(true) + try { + const [v, s] = await Promise.all([ + fetch(`${API}/${scid}/variants`).then(r => r.ok ? r.json() : []), + fetch(`${API}/${scid}/stats`).then(r => r.ok ? r.json() : []), + ]) + setVariants(v) + setStats(s) + } catch { /* ignore */ } + setLoading(false) + }, [scid]) + + useEffect(() => { loadData() }, [loadData]) + + const handleCreate = async () => { + if (!scid || !newVariant.variant_name) return + await fetch(`${API}/${scid}/variants`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newVariant), + }) + setShowCreate(false) + setNewVariant({ variant_name: '', variant_key: 'B', traffic_percent: 50, banner_title: '', primary_color: '' }) + loadData() + } + + const handleDelete = async (id: string) => { + await fetch(`${API}/variants/${id}`, { method: 'DELETE' }) + loadData() + } + + const handleTrafficChange = async (id: string, pct: number) => { + await fetch(`${API}/variants/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ traffic_percent: pct }), + }) + loadData() + } + + if (!scid) { + return
Bitte waehlen Sie zuerst eine Site aus.
+ } + + if (loading) return
Lade A/B-Test...
+ + const maxRate = Math.max(...stats.map(s => s.opt_in_rate), 1) + + return ( +
+ {/* Header */} +
+
+

A/B-Test Varianten

+

Testen Sie verschiedene Banner-Konfigurationen um die Opt-In-Rate zu optimieren.

+
+ +
+ + {/* Create Form */} + {showCreate && ( +
+
+ setNewVariant({ ...newVariant, variant_name: e.target.value })} + placeholder="Name (z.B. Variante B)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" /> + setNewVariant({ ...newVariant, variant_key: e.target.value })} + placeholder="Key (z.B. B)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" /> + setNewVariant({ ...newVariant, banner_title: e.target.value })} + placeholder="Banner-Titel (Override)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" /> + setNewVariant({ ...newVariant, primary_color: e.target.value })} + placeholder="Farbe (z.B. #22c55e)" type="color" className="px-3 py-2 h-10 text-sm border border-gray-200 rounded-lg" /> +
+
+ + setNewVariant({ ...newVariant, traffic_percent: parseInt(e.target.value) })} + className="flex-1" /> + {newVariant.traffic_percent}% +
+
+ + +
+
+ )} + + {/* Variants + Stats */} + {variants.length === 0 ? ( +
+

Kein A/B-Test aktiv.

+

Erstellen Sie mindestens 2 Varianten um einen Test zu starten.

+
+ ) : ( +
+ {/* Comparison Chart */} + {stats.length > 0 && ( +
+

Opt-In-Rate Vergleich

+
+ {stats.map(s => ( +
+
+ {s.variant_name} + {s.is_control && (Kontrolle)} +
+
+
+ + {s.opt_in_rate}% ({s.accepted}/{s.total}) + +
+ {s.is_winner && ( + + Gewinner ({s.significance}%) + + )} +
+ ))} +
+
+ )} + + {/* Variant Cards */} +
+ {variants.map(v => ( +
+
+
+ {v.variant_name} + {v.variant_key} + {v.is_control && Kontrolle} +
+ +
+
+ + handleTrafficChange(v.id, parseInt(e.target.value))} + className="flex-1 h-1" /> + {v.traffic_percent}% +
+ {v.banner_title &&
Titel: {v.banner_title}
} + {v.primary_color && ( +
+
+ {v.primary_color} +
+ )} +
+ ))} +
+
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/cookie-banner/page.tsx b/admin-compliance/app/sdk/cookie-banner/page.tsx index 7335045..c2196c5 100644 --- a/admin-compliance/app/sdk/cookie-banner/page.tsx +++ b/admin-compliance/app/sdk/cookie-banner/page.tsx @@ -10,8 +10,9 @@ import { VendorTable } from './_components/VendorTable' import { EmbeddableVendorHTML } from './_components/EmbeddableVendorHTML' import { SiteSelector } from './_components/SiteSelector' import { AnalyticsDashboard } from './_components/AnalyticsDashboard' +import { ABTestPanel } from './_components/ABTestPanel' -type BannerTab = 'config' | 'vendors' | 'embed' | 'analytics' +type BannerTab = 'config' | 'vendors' | 'embed' | 'analytics' | 'abtest' export default function CookieBannerPage() { const { state } = useSDK() @@ -77,6 +78,7 @@ export default function CookieBannerPage() { { id: 'vendors' as const, label: 'Verarbeiter' }, { id: 'embed' as const, label: 'Einbettung' }, { id: 'analytics' as const, label: 'Analytik' }, + { id: 'abtest' as const, label: 'A/B-Test' }, ]).map(tab => (
+ {/* Compliance Report Download */} +
+
+

Compliance-Report

+

Umfassender PDF-Bericht ueber alle Module, Rollen, Risiken und Massnahmen.

+
+ +
+ {/* Recent Activity */} {state.commandBarHistory.length > 0 && (
diff --git a/backend-compliance/compliance/api/__init__.py b/backend-compliance/compliance/api/__init__.py index f56b672..490b743 100644 --- a/backend-compliance/compliance/api/__init__.py +++ b/backend-compliance/compliance/api/__init__.py @@ -66,6 +66,8 @@ _ROUTER_MODULES = [ "org_role_routes", "document_review_routes", "banner_analytics_routes", + "banner_ab_routes", + "compliance_report_routes", ] _loaded_count = 0 diff --git a/backend-compliance/compliance/api/banner_ab_routes.py b/backend-compliance/compliance/api/banner_ab_routes.py new file mode 100644 index 0000000..4edc3ea --- /dev/null +++ b/backend-compliance/compliance/api/banner_ab_routes.py @@ -0,0 +1,120 @@ +""" +FastAPI routes for Banner A/B Testing. + +Endpoints: + GET /banner/ab/{site_config_id}/variants — list variants + POST /banner/ab/{site_config_id}/variants — create variant + PUT /banner/ab/variants/{variant_id} — update variant + DELETE /banner/ab/variants/{variant_id} — delete variant + GET /banner/ab/{site_config_id}/stats — per-variant stats + GET /banner/ab/assign — assign variant for device +""" + +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from classroom_engine.database import get_db +from .tenant_utils import get_tenant_id as _get_tenant_id +from compliance.services.banner_ab_service import BannerABService + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/banner/ab", tags=["banner-ab-testing"]) + + +class VariantCreate(BaseModel): + variant_name: str + variant_key: str = "A" + traffic_percent: int = 50 + is_control: bool = False + banner_title: Optional[str] = None + banner_description: Optional[str] = None + position: Optional[str] = None + style: Optional[str] = None + primary_color: Optional[str] = None + show_decline_all: Optional[bool] = None + theme_overrides: Optional[dict] = None + + +class VariantUpdate(BaseModel): + variant_name: Optional[str] = None + traffic_percent: Optional[int] = None + is_control: Optional[bool] = None + banner_title: Optional[str] = None + banner_description: Optional[str] = None + position: Optional[str] = None + style: Optional[str] = None + primary_color: Optional[str] = None + show_decline_all: Optional[bool] = None + is_active: Optional[bool] = None + + +@router.get("/{site_config_id}/variants") +def list_variants( + site_config_id: str, + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + service = BannerABService(db) + return service.list_variants(tenant_id, site_config_id) + + +@router.post("/{site_config_id}/variants") +def create_variant( + site_config_id: str, + body: VariantCreate, + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + service = BannerABService(db) + return service.create_variant(tenant_id, site_config_id, body.model_dump()) + + +@router.put("/variants/{variant_id}") +def update_variant( + variant_id: str, + body: VariantUpdate, + db: Session = Depends(get_db), +): + service = BannerABService(db) + result = service.update_variant(variant_id, body.model_dump(exclude_none=True)) + if not result: + raise HTTPException(404, "Variant not found") + return result + + +@router.delete("/variants/{variant_id}") +def delete_variant( + variant_id: str, + db: Session = Depends(get_db), +): + service = BannerABService(db) + if not service.delete_variant(variant_id): + raise HTTPException(404, "Variant not found") + return {"deleted": True} + + +@router.get("/{site_config_id}/stats") +def variant_stats( + site_config_id: str, + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + service = BannerABService(db) + return service.get_variant_stats(tenant_id, site_config_id) + + +@router.get("/assign") +def assign_variant( + site_config_id: str = Query(...), + device_fingerprint: str = Query(...), + db: Session = Depends(get_db), +): + service = BannerABService(db) + variant = service.assign_variant(site_config_id, device_fingerprint) + if not variant: + return {"variant": None, "message": "No active A/B test"} + return {"variant": variant} diff --git a/backend-compliance/compliance/api/compliance_report_routes.py b/backend-compliance/compliance/api/compliance_report_routes.py new file mode 100644 index 0000000..e394e37 --- /dev/null +++ b/backend-compliance/compliance/api/compliance_report_routes.py @@ -0,0 +1,38 @@ +""" +FastAPI route for Compliance Report PDF generation. + +Endpoint: + GET /compliance/report/pdf — generate comprehensive compliance report as PDF +""" + +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session +import io + +from classroom_engine.database import get_db +from .tenant_utils import get_tenant_id as _get_tenant_id +from compliance.services.compliance_pdf_generator import CompliancePDFGenerator + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/compliance/report", tags=["compliance-report"]) + + +@router.get("/pdf") +def generate_compliance_report_pdf( + project_id: Optional[str] = Query(None), + language: str = Query("de"), + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + """Generate a comprehensive compliance PDF report for a project.""" + generator = CompliancePDFGenerator(db) + pdf_bytes, filename = generator.generate(tenant_id, project_id, language) + return StreamingResponse( + io.BytesIO(pdf_bytes), + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/backend-compliance/compliance/services/banner_ab_service.py b/backend-compliance/compliance/services/banner_ab_service.py new file mode 100644 index 0000000..9b490e4 --- /dev/null +++ b/backend-compliance/compliance/services/banner_ab_service.py @@ -0,0 +1,193 @@ +""" +Banner A/B Testing Service — variant assignment, stats, significance. + +Deterministic variant assignment via device fingerprint hash ensures +the same device always sees the same variant (sticky bucketing). +""" + +import hashlib +import math +import uuid +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy import text +from sqlalchemy.orm import Session + + +class BannerABService: + """A/B testing for consent banner variants.""" + + def __init__(self, db: Session) -> None: + self.db = db + + # ------------------------------------------------------------------ + # Variant CRUD + # ------------------------------------------------------------------ + + def list_variants(self, tenant_id: str, site_config_id: str) -> list[dict]: + q = text(""" + SELECT * FROM compliance_banner_variants + WHERE tenant_id = :tid AND site_config_id = :scid + ORDER BY variant_key + """) + rows = self.db.execute(q, {"tid": tenant_id, "scid": site_config_id}).fetchall() + return [dict(r._mapping) for r in rows] + + def create_variant(self, tenant_id: str, site_config_id: str, data: dict) -> dict: + q = text(""" + INSERT INTO compliance_banner_variants + (tenant_id, site_config_id, variant_name, variant_key, traffic_percent, is_control, + banner_title, banner_description, position, style, primary_color, show_decline_all, theme_overrides) + VALUES (:tid, :scid, :name, :key, :pct, :ctrl, + :title, :desc, :pos, :style, :color, :decline, :theme) + RETURNING * + """) + row = self.db.execute(q, { + "tid": tenant_id, "scid": site_config_id, + "name": data.get("variant_name", ""), + "key": data.get("variant_key", "A"), + "pct": data.get("traffic_percent", 50), + "ctrl": data.get("is_control", False), + "title": data.get("banner_title"), + "desc": data.get("banner_description"), + "pos": data.get("position"), + "style": data.get("style"), + "color": data.get("primary_color"), + "decline": data.get("show_decline_all"), + "theme": data.get("theme_overrides", "{}"), + }).fetchone() + self.db.commit() + return dict(row._mapping) + + def update_variant(self, variant_id: str, data: dict) -> Optional[dict]: + sets, params = [], {"vid": variant_id} + for field in ["variant_name", "traffic_percent", "is_control", "banner_title", + "banner_description", "position", "style", "primary_color", + "show_decline_all", "is_active"]: + if field in data and data[field] is not None: + sets.append(f"{field} = :{field}") + params[field] = data[field] + if not sets: + return None + sets.append("updated_at = NOW()") + q = text(f"UPDATE compliance_banner_variants SET {', '.join(sets)} WHERE id = :vid RETURNING *") + row = self.db.execute(q, params).fetchone() + self.db.commit() + return dict(row._mapping) if row else None + + def delete_variant(self, variant_id: str) -> bool: + q = text("DELETE FROM compliance_banner_variants WHERE id = :vid") + result = self.db.execute(q, {"vid": variant_id}) + self.db.commit() + return result.rowcount > 0 + + # ------------------------------------------------------------------ + # Variant Assignment (deterministic sticky bucketing) + # ------------------------------------------------------------------ + + def assign_variant(self, site_config_id: str, device_fingerprint: str) -> Optional[dict]: + """Assign a variant based on device fingerprint hash. Returns variant or None.""" + variants = self.db.execute(text(""" + SELECT * FROM compliance_banner_variants + WHERE site_config_id = :scid AND is_active = TRUE + ORDER BY variant_key + """), {"scid": site_config_id}).fetchall() + if not variants: + return None + + # Deterministic bucket 0-99 from device fingerprint + bucket = int(hashlib.md5(f"{site_config_id}:{device_fingerprint}".encode()).hexdigest(), 16) % 100 + + cumulative = 0 + for v in variants: + cumulative += v.traffic_percent + if bucket < cumulative: + return dict(v._mapping) + # Fallback to last variant + return dict(variants[-1]._mapping) + + # ------------------------------------------------------------------ + # Stats with statistical significance + # ------------------------------------------------------------------ + + def get_variant_stats(self, tenant_id: str, site_config_id: str) -> list[dict]: + """Per-variant stats with chi-squared significance test.""" + variants = self.list_variants(tenant_id, site_config_id) + if not variants: + return [] + + results = [] + for v in variants: + vid = str(v["id"]) + vkey = v["variant_key"] + q = text(""" + SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE action = 'consent_given') AS accepted, + COUNT(*) FILTER (WHERE action IN ('consent_withdrawn', 'consent_revoked')) AS rejected + FROM compliance_banner_consent_audit_log + WHERE tenant_id = :tid AND variant_key = :vkey + """) + row = self.db.execute(q, {"tid": tenant_id, "vkey": vkey}).fetchone() + total = row.total if row else 0 + accepted = row.accepted if row else 0 + results.append({ + "variant_id": vid, + "variant_key": vkey, + "variant_name": v["variant_name"], + "traffic_percent": v["traffic_percent"], + "is_control": v["is_control"], + "total": total, + "accepted": accepted, + "opt_in_rate": round(accepted / total * 100, 1) if total > 0 else 0, + }) + + # Chi-squared test between control and best variant + control = next((r for r in results if r["is_control"]), None) + if control and len(results) > 1: + best = max((r for r in results if not r["is_control"]), key=lambda x: x["opt_in_rate"], default=None) + if best and control["total"] > 0 and best["total"] > 0: + sig = self._chi_squared_significance( + control["accepted"], control["total"], + best["accepted"], best["total"], + ) + best["is_winner"] = sig > 0.95 + best["significance"] = round(sig * 100, 1) + control["is_winner"] = False + control["significance"] = round((1 - sig) * 100, 1) + + return results + + @staticmethod + def _chi_squared_significance(a_success: int, a_total: int, b_success: int, b_total: int) -> float: + """Simple chi-squared test for 2x2 contingency table. Returns confidence 0-1.""" + a_fail = a_total - a_success + b_fail = b_total - b_success + n = a_total + b_total + if n == 0: + return 0.0 + + # Expected values + exp_a_s = a_total * (a_success + b_success) / n + exp_a_f = a_total * (a_fail + b_fail) / n + exp_b_s = b_total * (a_success + b_success) / n + exp_b_f = b_total * (a_fail + b_fail) / n + + chi2 = 0.0 + for obs, exp in [(a_success, exp_a_s), (a_fail, exp_a_f), (b_success, exp_b_s), (b_fail, exp_b_f)]: + if exp > 0: + chi2 += (obs - exp) ** 2 / exp + + # Approximate p-value for 1 df using Wilson-Hilferty + if chi2 < 0.001: + return 0.0 + if chi2 > 10.83: + return 0.999 + # Lookup table for common thresholds (1 df) + thresholds = [(2.706, 0.90), (3.841, 0.95), (5.024, 0.975), (6.635, 0.99), (10.83, 0.999)] + confidence = 0.0 + for threshold, conf in thresholds: + if chi2 >= threshold: + confidence = conf + return confidence diff --git a/backend-compliance/compliance/services/compliance_pdf_generator.py b/backend-compliance/compliance/services/compliance_pdf_generator.py new file mode 100644 index 0000000..433394d --- /dev/null +++ b/backend-compliance/compliance/services/compliance_pdf_generator.py @@ -0,0 +1,216 @@ +""" +Compliance Report PDF Generator — generates a comprehensive A4 PDF +covering all compliance modules for a project. + +Uses reportlab (same as audit_pdf_generator.py). +""" + +import io +import logging +from datetime import datetime, timezone +from typing import Any + +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.units import mm +from reportlab.platypus import ( + SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, +) +from sqlalchemy import text +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +# Colors +PURPLE = colors.HexColor("#7c3aed") +LIGHT_PURPLE = colors.HexColor("#f5f3ff") +GRAY = colors.HexColor("#6b7280") +GREEN = colors.HexColor("#16a34a") +RED = colors.HexColor("#dc2626") +YELLOW = colors.HexColor("#ca8a04") + + +def _styles(): + ss = getSampleStyleSheet() + ss.add(ParagraphStyle("Title2", parent=ss["Title"], fontSize=24, textColor=PURPLE, spaceAfter=6)) + ss.add(ParagraphStyle("Section", parent=ss["Heading2"], fontSize=14, textColor=PURPLE, spaceBefore=12, spaceAfter=6)) + ss.add(ParagraphStyle("Body2", parent=ss["Normal"], fontSize=10, leading=14, spaceAfter=4)) + ss.add(ParagraphStyle("Small", parent=ss["Normal"], fontSize=8, textColor=GRAY)) + return ss + + +class CompliancePDFGenerator: + """Generates a full compliance status report as PDF.""" + + def __init__(self, db: Session) -> None: + self.db = db + + def generate(self, tenant_id: str, project_id: str | None = None, language: str = "de") -> tuple[bytes, str]: + buf = io.BytesIO() + doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=20 * mm, rightMargin=20 * mm, topMargin=25 * mm, bottomMargin=20 * mm) + ss = _styles() + story: list = [] + + now = datetime.now(timezone.utc) + story.append(Paragraph("Compliance-Report", ss["Title2"])) + story.append(Paragraph(f"Stand: {now.strftime('%d.%m.%Y %H:%M')} UTC", ss["Small"])) + story.append(Spacer(1, 10 * mm)) + + # Company Profile + self._add_company_section(story, ss, tenant_id, project_id) + # TOM + self._add_count_section(story, ss, "TOM (Technisch-Organisatorische Massnahmen)", + "compliance_toms", tenant_id) + # VVT + self._add_count_section(story, ss, "VVT (Verarbeitungstaetigkeiten)", + "compliance_vvt_activities", tenant_id) + # DSFA + self._add_count_section(story, ss, "Datenschutz-Folgenabschaetzungen", + "compliance_dsfa_assessments", tenant_id) + # Risks + self._add_risk_section(story, ss, tenant_id) + # Vendors + self._add_count_section(story, ss, "Auftragsverarbeiter", + "compliance_vendor_assessments", tenant_id) + # Incidents + self._add_count_section(story, ss, "Datenschutz-Vorfaelle", + "compliance_notfallplan_incidents", tenant_id) + # Document Reviews + self._add_review_section(story, ss, tenant_id) + # Banner Consents + self._add_consent_section(story, ss, tenant_id) + # Org Roles + self._add_role_section(story, ss, tenant_id, project_id) + # Footer + story.append(Spacer(1, 15 * mm)) + story.append(Paragraph("Erstellt mit BreakPilot Compliance SDK", ss["Small"])) + + doc.build(story) + filename = f"compliance-report-{now.strftime('%Y%m%d')}.pdf" + return buf.getvalue(), filename + + def _add_company_section(self, story, ss, tid, pid): + story.append(Paragraph("Unternehmensprofil", ss["Section"])) + try: + where = "tenant_id = :tid" + params: dict[str, Any] = {"tid": tid} + if pid: + where += " AND project_id = :pid" + params["pid"] = pid + row = self.db.execute(text(f"SELECT * FROM compliance_company_profiles WHERE {where} LIMIT 1"), params).fetchone() + if row: + d = dict(row._mapping) + data = [ + ["Feld", "Wert"], + ["Firma", d.get("company_name", "-")], + ["Branche", d.get("industry", "-")], + ["Rechtsform", d.get("legal_form", "-")], + ["Mitarbeiter", str(d.get("employee_count", "-"))], + ] + t = Table(data, colWidths=[60 * mm, 100 * mm]) + t.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), LIGHT_PURPLE), + ("TEXTCOLOR", (0, 0), (-1, 0), PURPLE), + ("FONTSIZE", (0, 0), (-1, -1), 9), + ("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ])) + story.append(t) + else: + story.append(Paragraph("Kein Unternehmensprofil hinterlegt.", ss["Body2"])) + except Exception as e: + story.append(Paragraph(f"Fehler beim Laden: {e}", ss["Small"])) + story.append(Spacer(1, 5 * mm)) + + def _add_count_section(self, story, ss, title, table_name, tid): + story.append(Paragraph(title, ss["Section"])) + try: + count = self.db.execute(text(f"SELECT COUNT(*) FROM {table_name} WHERE tenant_id = :tid"), {"tid": tid}).scalar() + story.append(Paragraph(f"Eintraege: {count or 0}", ss["Body2"])) + except Exception: + story.append(Paragraph("Tabelle nicht vorhanden oder leer.", ss["Small"])) + story.append(Spacer(1, 3 * mm)) + + def _add_risk_section(self, story, ss, tid): + story.append(Paragraph("Risikobewertung", ss["Section"])) + try: + q = text(""" + SELECT severity, COUNT(*) as cnt FROM compliance_risks + WHERE tenant_id = :tid GROUP BY severity ORDER BY severity + """) + rows = self.db.execute(q, {"tid": tid}).fetchall() + if rows: + data = [["Schweregrad", "Anzahl"]] + for r in rows: + data.append([r.severity or "UNKNOWN", str(r.cnt)]) + t = Table(data, colWidths=[80 * mm, 40 * mm]) + t.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), LIGHT_PURPLE), + ("TEXTCOLOR", (0, 0), (-1, 0), PURPLE), + ("FONTSIZE", (0, 0), (-1, -1), 9), + ("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey), + ])) + story.append(t) + else: + story.append(Paragraph("Keine Risiken erfasst.", ss["Body2"])) + except Exception: + story.append(Paragraph("Risiko-Tabelle nicht vorhanden.", ss["Small"])) + story.append(Spacer(1, 3 * mm)) + + def _add_review_section(self, story, ss, tid): + story.append(Paragraph("Dokumenten-Reviews", ss["Section"])) + try: + q = text("SELECT status, COUNT(*) as cnt FROM compliance_document_reviews WHERE tenant_id = :tid GROUP BY status") + rows = self.db.execute(q, {"tid": tid}).fetchall() + if rows: + data = [["Status", "Anzahl"]] + for r in rows: + data.append([r.status, str(r.cnt)]) + t = Table(data, colWidths=[80 * mm, 40 * mm]) + t.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), LIGHT_PURPLE), + ("FONTSIZE", (0, 0), (-1, -1), 9), + ("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey), + ])) + story.append(t) + else: + story.append(Paragraph("Keine Reviews vorhanden.", ss["Body2"])) + except Exception: + story.append(Paragraph("Review-Tabelle nicht vorhanden.", ss["Small"])) + story.append(Spacer(1, 3 * mm)) + + def _add_consent_section(self, story, ss, tid): + story.append(Paragraph("Banner-Consents", ss["Section"])) + try: + count = self.db.execute(text("SELECT COUNT(*) FROM compliance_banner_consents WHERE tenant_id = :tid"), {"tid": tid}).scalar() + story.append(Paragraph(f"Gesamte Consents: {count or 0}", ss["Body2"])) + except Exception: + story.append(Paragraph("Banner-Tabelle nicht vorhanden.", ss["Small"])) + story.append(Spacer(1, 3 * mm)) + + def _add_role_section(self, story, ss, tid, pid): + story.append(Paragraph("Rollenkonzept", ss["Section"])) + try: + where = "tenant_id = :tid" + params: dict[str, Any] = {"tid": tid} + if pid: + where += " AND (project_id = :pid OR project_id IS NULL)" + params["pid"] = pid + rows = self.db.execute(text(f"SELECT role_key, role_label, person_name, person_email FROM compliance_org_roles WHERE {where} ORDER BY role_key"), params).fetchall() + if rows: + data = [["Rolle", "Name", "E-Mail"]] + for r in rows: + data.append([r.role_label or r.role_key, r.person_name or "-", r.person_email or "-"]) + t = Table(data, colWidths=[60 * mm, 50 * mm, 50 * mm]) + t.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), LIGHT_PURPLE), + ("TEXTCOLOR", (0, 0), (-1, 0), PURPLE), + ("FONTSIZE", (0, 0), (-1, -1), 9), + ("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey), + ])) + story.append(t) + else: + story.append(Paragraph("Keine Rollen zugewiesen.", ss["Body2"])) + except Exception: + story.append(Paragraph("Rollen-Tabelle nicht vorhanden.", ss["Small"])) diff --git a/backend-compliance/migrations/116_banner_ab_testing.sql b/backend-compliance/migrations/116_banner_ab_testing.sql new file mode 100644 index 0000000..c6b68f6 --- /dev/null +++ b/backend-compliance/migrations/116_banner_ab_testing.sql @@ -0,0 +1,31 @@ +-- Migration 116: Banner A/B Testing +-- Enables variant testing for consent rate optimization + +CREATE TABLE IF NOT EXISTS compliance_banner_variants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + site_config_id UUID NOT NULL, + variant_name VARCHAR(100) NOT NULL, + variant_key VARCHAR(20) NOT NULL, + traffic_percent INT NOT NULL DEFAULT 50 CHECK (traffic_percent BETWEEN 0 AND 100), + is_control BOOLEAN NOT NULL DEFAULT FALSE, + banner_title TEXT, + banner_description TEXT, + position VARCHAR(20), + style VARCHAR(20), + primary_color VARCHAR(20), + show_decline_all BOOLEAN, + theme_overrides JSONB DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(site_config_id, variant_key) +); + +CREATE INDEX IF NOT EXISTS idx_banner_variants_site + ON compliance_banner_variants(site_config_id); + +ALTER TABLE compliance_banner_consent_audit_log + ADD COLUMN IF NOT EXISTS variant_id UUID; +ALTER TABLE compliance_banner_consent_audit_log + ADD COLUMN IF NOT EXISTS variant_key VARCHAR(20);