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:
@@ -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 */}
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user