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
@@ -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}"'},
)