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) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-03 21:42:50 +02:00
parent c3fcfe88ee
commit 965af3a34c
9 changed files with 827 additions and 1 deletions
@@ -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<Variant[]>([])
const [stats, setStats] = useState<VariantStat[]>([])
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 <div className="text-center py-8 text-gray-400">Bitte waehlen Sie zuerst eine Site aus.</div>
}
if (loading) return <div className="text-center py-8 text-gray-400">Lade A/B-Test...</div>
const maxRate = Math.max(...stats.map(s => s.opt_in_rate), 1)
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">A/B-Test Varianten</h3>
<p className="text-xs text-gray-500 mt-1">Testen Sie verschiedene Banner-Konfigurationen um die Opt-In-Rate zu optimieren.</p>
</div>
<button onClick={() => setShowCreate(!showCreate)}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">
+ Variante erstellen
</button>
</div>
{/* Create Form */}
{showCreate && (
<div className="bg-gray-50 border border-gray-200 rounded-xl p-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<input value={newVariant.variant_name} onChange={e => 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" />
<input value={newVariant.variant_key} onChange={e => 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" />
<input value={newVariant.banner_title} onChange={e => setNewVariant({ ...newVariant, banner_title: e.target.value })}
placeholder="Banner-Titel (Override)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
<input value={newVariant.primary_color} onChange={e => 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" />
</div>
<div className="flex items-center gap-3">
<label className="text-sm text-gray-600">Traffic:</label>
<input type="range" min={5} max={95} value={newVariant.traffic_percent}
onChange={e => setNewVariant({ ...newVariant, traffic_percent: parseInt(e.target.value) })}
className="flex-1" />
<span className="text-sm font-medium w-12 text-right">{newVariant.traffic_percent}%</span>
</div>
<div className="flex gap-2">
<button onClick={handleCreate} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">Erstellen</button>
<button onClick={() => setShowCreate(false)} className="px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 rounded-lg">Abbrechen</button>
</div>
</div>
)}
{/* Variants + Stats */}
{variants.length === 0 ? (
<div className="text-center py-12 bg-white border border-gray-200 rounded-xl">
<p className="text-gray-400">Kein A/B-Test aktiv.</p>
<p className="text-xs text-gray-400 mt-1">Erstellen Sie mindestens 2 Varianten um einen Test zu starten.</p>
</div>
) : (
<div className="space-y-4">
{/* Comparison Chart */}
{stats.length > 0 && (
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h4 className="font-medium text-gray-900 mb-4">Opt-In-Rate Vergleich</h4>
<div className="space-y-3">
{stats.map(s => (
<div key={s.variant_key} className="flex items-center gap-4">
<div className="w-24 text-sm text-gray-700 truncate">
{s.variant_name}
{s.is_control && <span className="ml-1 text-[10px] text-gray-400">(Kontrolle)</span>}
</div>
<div className="flex-1 h-8 bg-gray-100 rounded-lg overflow-hidden relative">
<div className={`h-full rounded-lg transition-all ${s.is_winner ? 'bg-green-500' : s.is_control ? 'bg-gray-400' : 'bg-purple-500'}`}
style={{ width: `${(s.opt_in_rate / maxRate) * 100}%` }} />
<span className="absolute inset-0 flex items-center px-3 text-xs font-medium text-gray-900">
{s.opt_in_rate}% ({s.accepted}/{s.total})
</span>
</div>
{s.is_winner && (
<span className="px-2 py-0.5 text-[10px] font-medium bg-green-100 text-green-700 rounded-full">
Gewinner ({s.significance}%)
</span>
)}
</div>
))}
</div>
</div>
)}
{/* Variant Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{variants.map(v => (
<div key={v.id} className={`bg-white border rounded-xl p-4 ${v.is_control ? 'border-gray-300' : 'border-purple-200'}`}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-gray-900">{v.variant_name}</span>
<span className="px-1.5 py-0.5 text-[10px] bg-gray-100 text-gray-600 rounded">{v.variant_key}</span>
{v.is_control && <span className="px-1.5 py-0.5 text-[10px] bg-blue-50 text-blue-600 rounded">Kontrolle</span>}
</div>
<button onClick={() => handleDelete(v.id)} className="text-xs text-red-500 hover:text-red-700">Loeschen</button>
</div>
<div className="flex items-center gap-3 mb-2">
<label className="text-xs text-gray-500">Traffic:</label>
<input type="range" min={5} max={95} value={v.traffic_percent}
onChange={e => handleTrafficChange(v.id, parseInt(e.target.value))}
className="flex-1 h-1" />
<span className="text-xs font-medium w-8 text-right">{v.traffic_percent}%</span>
</div>
{v.banner_title && <div className="text-xs text-gray-500">Titel: {v.banner_title}</div>}
{v.primary_color && (
<div className="flex items-center gap-1 mt-1">
<div className="w-3 h-3 rounded-full border" style={{ backgroundColor: v.primary_color }} />
<span className="text-xs text-gray-500">{v.primary_color}</span>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)
}
@@ -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 => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
className={`px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors ${
@@ -96,6 +98,9 @@ export default function CookieBannerPage() {
{/* Tab: Analytik */}
{activeTab === 'analytics' && <AnalyticsDashboard siteId={activeSiteId || undefined} />}
{/* Tab: A/B-Test */}
{activeTab === 'abtest' && <ABTestPanel siteConfigId={activeSiteId || undefined} />}
{/* Tab: Konfiguration */}
{activeTab !== 'config' ? null : (<>
{/* Stats */}
+18
View File
@@ -222,6 +222,24 @@ export default function SDKDashboard() {
</div>
</div>
{/* Compliance Report Download */}
<div className="bg-gradient-to-r from-purple-50 to-indigo-50 border border-purple-200 rounded-xl p-6 flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">Compliance-Report</h3>
<p className="text-sm text-gray-500 mt-1">Umfassender PDF-Bericht ueber alle Module, Rollen, Risiken und Massnahmen.</p>
</div>
<button
onClick={() => {
const url = `/api/sdk/v1/compliance/report/pdf${projectId ? `?project_id=${projectId}` : ''}`
window.open(url, '_blank')
}}
className="px-5 py-2.5 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 transition-colors flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
PDF herunterladen
</button>
</div>
{/* Recent Activity */}
{state.commandBarHistory.length > 0 && (
<div>
@@ -66,6 +66,8 @@ _ROUTER_MODULES = [
"org_role_routes",
"document_review_routes",
"banner_analytics_routes",
"banner_ab_routes",
"compliance_report_routes",
]
_loaded_count = 0
@@ -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}
@@ -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}"'},
)
@@ -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
@@ -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: <b>{count or 0}</b>", 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: <b>{count or 0}</b>", 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"]))
@@ -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);