Files
breakpilot-compliance/backend-compliance/compliance/api/einwilligungen_routes.py
Benjamin Admin 393eab6acd
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 36s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 18s
feat: Package 4 Nachbesserungen — History-Tracking, Pagination, Frontend-Fixes
Backend:
- Migration 009: compliance_einwilligungen_consent_history Tabelle
- EinwilligungenConsentHistoryDB Modell (consent_id, action, version, ip, ua, source)
- _record_history() Helper: automatisch bei POST /consents (granted) + PUT /revoke (revoked)
- GET /consents/{id}/history Endpoint (vor revoke platziert für korrektes Routing)
- GET /consents: history-Array pro Eintrag (inline Sub-Query)
- 5 neue Tests (TestConsentHistoryTracking) — 32/32 bestanden

Frontend:
- consent/route.ts: limit+offset aus Frontend-Request weitergeleitet, total-Feld ergänzt
- Neuer Proxy consent/[id]/history/route.ts für GET /consents/{id}/history
- page.tsx: globalStats state + loadStats() (Backend /consents/stats für globale Zahlen)
- page.tsx: Stats-Kacheln auf globalStats umgestellt (nicht mehr page-relativ)
- page.tsx: history-Mapper: created_at→timestamp, consent_version→version
- page.tsx: loadStats() bei Mount + nach Revoke

Dokumentation:
- Developer Portal: neue API-Docs-Seite /api/einwilligungen (Consent + Legal Docs + Cookie Banner)
- developer-portal/app/api/page.tsx: Consent Management Abschnitt
- MkDocs: History-Endpoint, Pagination-Abschnitt, History-Tracking Abschnitt
- Deploy-Skript: scripts/apply_consent_history_migration.sh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 11:54:25 +01:00

456 lines
14 KiB
Python

"""
FastAPI routes for Einwilligungen — Consent-Tracking, Cookie-Banner und Datenpunktkatalog.
Endpoints:
GET /einwilligungen/catalog — Katalog laden
PUT /einwilligungen/catalog — Katalog speichern (Upsert by tenant_id)
GET /einwilligungen/company — Firmeninfo laden
PUT /einwilligungen/company — Firmeninfo speichern (Upsert)
GET /einwilligungen/cookies — Cookie-Banner-Config laden
PUT /einwilligungen/cookies — Cookie-Banner-Config speichern (Upsert)
GET /einwilligungen/consents — Consent-Liste (Pagination + Filter)
POST /einwilligungen/consents — Consent erfassen
PUT /einwilligungen/consents/{id}/revoke — Consent widerrufen
GET /einwilligungen/consents/stats — Statistiken
"""
import logging
from datetime import datetime
from typing import Optional, List, Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Query, Header
from pydantic import BaseModel
from sqlalchemy.orm import Session
from classroom_engine.database import get_db
from ..db.einwilligungen_models import (
EinwilligungenCatalogDB,
EinwilligungenCompanyDB,
EinwilligungenCookiesDB,
EinwilligungenConsentDB,
EinwilligungenConsentHistoryDB,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/einwilligungen", tags=["einwilligungen"])
# ============================================================================
# Pydantic Schemas
# ============================================================================
class CatalogUpsert(BaseModel):
selected_data_point_ids: List[str] = []
custom_data_points: List[Dict[str, Any]] = []
class CompanyUpsert(BaseModel):
data: Dict[str, Any] = {}
class CookiesUpsert(BaseModel):
categories: List[Dict[str, Any]] = []
config: Dict[str, Any] = {}
class ConsentCreate(BaseModel):
user_id: str
data_point_id: str
granted: bool
consent_version: str = '1.0'
source: Optional[str] = None
ip_address: Optional[str] = None
user_agent: Optional[str] = None
# ============================================================================
# Helpers
# ============================================================================
def _get_tenant(x_tenant_id: Optional[str] = Header(None, alias='X-Tenant-ID')) -> str:
if not x_tenant_id:
raise HTTPException(status_code=400, detail="X-Tenant-ID header required")
return x_tenant_id
def _record_history(db: Session, consent: EinwilligungenConsentDB, action: str) -> None:
"""Protokolliert eine Aenderung an einer Einwilligung in der History-Tabelle."""
entry = EinwilligungenConsentHistoryDB(
consent_id=consent.id,
tenant_id=consent.tenant_id,
action=action,
consent_version=consent.consent_version,
ip_address=consent.ip_address,
user_agent=consent.user_agent,
source=consent.source,
)
db.add(entry)
# ============================================================================
# Catalog
# ============================================================================
@router.get("/catalog")
async def get_catalog(
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Load the data point catalog for a tenant."""
record = db.query(EinwilligungenCatalogDB).filter(
EinwilligungenCatalogDB.tenant_id == tenant_id
).first()
if not record:
return {
"tenant_id": tenant_id,
"selected_data_point_ids": [],
"custom_data_points": [],
"updated_at": None,
}
return {
"tenant_id": tenant_id,
"selected_data_point_ids": record.selected_data_point_ids or [],
"custom_data_points": record.custom_data_points or [],
"updated_at": record.updated_at,
}
@router.put("/catalog")
async def upsert_catalog(
request: CatalogUpsert,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Create or update the data point catalog for a tenant."""
record = db.query(EinwilligungenCatalogDB).filter(
EinwilligungenCatalogDB.tenant_id == tenant_id
).first()
if record:
record.selected_data_point_ids = request.selected_data_point_ids
record.custom_data_points = request.custom_data_points
record.updated_at = datetime.utcnow()
else:
record = EinwilligungenCatalogDB(
tenant_id=tenant_id,
selected_data_point_ids=request.selected_data_point_ids,
custom_data_points=request.custom_data_points,
)
db.add(record)
db.commit()
db.refresh(record)
return {
"success": True,
"tenant_id": tenant_id,
"selected_data_point_ids": record.selected_data_point_ids,
"custom_data_points": record.custom_data_points,
"updated_at": record.updated_at,
}
# ============================================================================
# Company Info
# ============================================================================
@router.get("/company")
async def get_company(
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Load company information for DSI generation."""
record = db.query(EinwilligungenCompanyDB).filter(
EinwilligungenCompanyDB.tenant_id == tenant_id
).first()
if not record:
return {"tenant_id": tenant_id, "data": {}, "updated_at": None}
return {"tenant_id": tenant_id, "data": record.data or {}, "updated_at": record.updated_at}
@router.put("/company")
async def upsert_company(
request: CompanyUpsert,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Create or update company information for a tenant."""
record = db.query(EinwilligungenCompanyDB).filter(
EinwilligungenCompanyDB.tenant_id == tenant_id
).first()
if record:
record.data = request.data
record.updated_at = datetime.utcnow()
else:
record = EinwilligungenCompanyDB(tenant_id=tenant_id, data=request.data)
db.add(record)
db.commit()
db.refresh(record)
return {"success": True, "tenant_id": tenant_id, "data": record.data, "updated_at": record.updated_at}
# ============================================================================
# Cookie Banner Config
# ============================================================================
@router.get("/cookies")
async def get_cookies(
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Load cookie banner configuration for a tenant."""
record = db.query(EinwilligungenCookiesDB).filter(
EinwilligungenCookiesDB.tenant_id == tenant_id
).first()
if not record:
return {"tenant_id": tenant_id, "categories": [], "config": {}, "updated_at": None}
return {
"tenant_id": tenant_id,
"categories": record.categories or [],
"config": record.config or {},
"updated_at": record.updated_at,
}
@router.put("/cookies")
async def upsert_cookies(
request: CookiesUpsert,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Create or update cookie banner configuration for a tenant."""
record = db.query(EinwilligungenCookiesDB).filter(
EinwilligungenCookiesDB.tenant_id == tenant_id
).first()
if record:
record.categories = request.categories
record.config = request.config
record.updated_at = datetime.utcnow()
else:
record = EinwilligungenCookiesDB(
tenant_id=tenant_id,
categories=request.categories,
config=request.config,
)
db.add(record)
db.commit()
db.refresh(record)
return {
"success": True,
"tenant_id": tenant_id,
"categories": record.categories,
"config": record.config,
"updated_at": record.updated_at,
}
# ============================================================================
# Consents
# ============================================================================
@router.get("/consents/stats")
async def get_consent_stats(
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Get consent statistics for a tenant."""
all_consents = db.query(EinwilligungenConsentDB).filter(
EinwilligungenConsentDB.tenant_id == tenant_id
).all()
total = len(all_consents)
active = sum(1 for c in all_consents if c.granted and not c.revoked_at)
revoked = sum(1 for c in all_consents if c.revoked_at)
# Unique users
unique_users = len(set(c.user_id for c in all_consents))
users_with_active = len(set(c.user_id for c in all_consents if c.granted and not c.revoked_at))
conversion_rate = round((users_with_active / unique_users * 100), 1) if unique_users > 0 else 0.0
# By data point
by_data_point: Dict[str, Dict] = {}
for c in all_consents:
dp = c.data_point_id
if dp not in by_data_point:
by_data_point[dp] = {"total": 0, "active": 0, "revoked": 0}
by_data_point[dp]["total"] += 1
if c.granted and not c.revoked_at:
by_data_point[dp]["active"] += 1
if c.revoked_at:
by_data_point[dp]["revoked"] += 1
return {
"total_consents": total,
"active_consents": active,
"revoked_consents": revoked,
"unique_users": unique_users,
"conversion_rate": conversion_rate,
"by_data_point": by_data_point,
}
@router.get("/consents")
async def list_consents(
tenant_id: str = Depends(_get_tenant),
user_id: Optional[str] = Query(None),
data_point_id: Optional[str] = Query(None),
granted: Optional[bool] = Query(None),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
):
"""List consent records with optional filters and pagination."""
query = db.query(EinwilligungenConsentDB).filter(
EinwilligungenConsentDB.tenant_id == tenant_id
)
if user_id:
query = query.filter(EinwilligungenConsentDB.user_id == user_id)
if data_point_id:
query = query.filter(EinwilligungenConsentDB.data_point_id == data_point_id)
if granted is not None:
query = query.filter(EinwilligungenConsentDB.granted == granted)
total = query.count()
consents = query.order_by(EinwilligungenConsentDB.created_at.desc()).offset(offset).limit(limit).all()
return {
"total": total,
"offset": offset,
"limit": limit,
"consents": [
{
"id": str(c.id),
"tenant_id": c.tenant_id,
"user_id": c.user_id,
"data_point_id": c.data_point_id,
"granted": c.granted,
"granted_at": c.granted_at,
"revoked_at": c.revoked_at,
"consent_version": c.consent_version,
"source": c.source,
"ip_address": c.ip_address,
"user_agent": c.user_agent,
"created_at": c.created_at,
"history": [
{
"id": str(h.id),
"action": h.action,
"consent_version": h.consent_version,
"ip_address": h.ip_address,
"user_agent": h.user_agent,
"source": h.source,
"created_at": h.created_at,
}
for h in db.query(EinwilligungenConsentHistoryDB)
.filter(EinwilligungenConsentHistoryDB.consent_id == c.id)
.order_by(EinwilligungenConsentHistoryDB.created_at.asc())
.all()
],
}
for c in consents
],
}
@router.post("/consents", status_code=201)
async def create_consent(
request: ConsentCreate,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Record a new consent entry."""
consent = EinwilligungenConsentDB(
tenant_id=tenant_id,
user_id=request.user_id,
data_point_id=request.data_point_id,
granted=request.granted,
granted_at=datetime.utcnow(),
consent_version=request.consent_version,
source=request.source,
ip_address=request.ip_address,
user_agent=request.user_agent,
)
db.add(consent)
_record_history(db, consent, 'granted')
db.commit()
db.refresh(consent)
return {
"success": True,
"id": str(consent.id),
"user_id": consent.user_id,
"data_point_id": consent.data_point_id,
"granted": consent.granted,
"granted_at": consent.granted_at,
}
@router.get("/consents/{consent_id}/history")
async def get_consent_history(
consent_id: str,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Get the change history for a specific consent record."""
entries = (
db.query(EinwilligungenConsentHistoryDB)
.filter(
EinwilligungenConsentHistoryDB.consent_id == consent_id,
EinwilligungenConsentHistoryDB.tenant_id == tenant_id,
)
.order_by(EinwilligungenConsentHistoryDB.created_at.asc())
.all()
)
return [
{
"id": str(e.id),
"consent_id": str(e.consent_id),
"action": e.action,
"consent_version": e.consent_version,
"ip_address": e.ip_address,
"user_agent": e.user_agent,
"source": e.source,
"created_at": e.created_at,
}
for e in entries
]
@router.put("/consents/{consent_id}/revoke")
async def revoke_consent(
consent_id: str,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Revoke an active consent."""
consent = db.query(EinwilligungenConsentDB).filter(
EinwilligungenConsentDB.id == consent_id,
EinwilligungenConsentDB.tenant_id == tenant_id,
).first()
if not consent:
raise HTTPException(status_code=404, detail=f"Consent {consent_id} not found")
if consent.revoked_at:
raise HTTPException(status_code=400, detail="Consent is already revoked")
consent.revoked_at = datetime.utcnow()
_record_history(db, consent, 'revoked')
db.commit()
db.refresh(consent)
return {
"success": True,
"id": str(consent.id),
"revoked_at": consent.revoked_at,
}