Files
breakpilot-compliance/backend-compliance/compliance/api/einwilligungen_routes.py
Benjamin Admin c0b179510d
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 48s
CI / test-python-backend-compliance (push) Successful in 40s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 18s
feat: Package 4 Phase 3 — Finale Fixes + Dokumentation (MkDocs, SDK Flow, StepHeader)
Finale Fixes (5 Bugs):
- workflow/page.tsx: Array-Format-Fix fuer loadVersions — Array.isArray statt data.versions
- einwilligungen_routes.py: ip_address + user_agent in GET /consents Response ergaenzt
- consent/page.tsx: Bearbeiten/Vorschau/Veroeffentlichen + Quick Actions verdrahtet (useRouter + Preview Modal)
- cookie-banner/page.tsx: BannerTexts State + Controlled Inputs + DB-Persistenz (banner_texts)
- embed-code/route.ts: In-Memory configStorage → DB-fetch aus Backend, embed_code Key korrigiert

Dokumentation:
- docs-src/services/sdk-modules/rechtliche-texte.md: Neue MkDocs-Seite fuer Paket 4
  (Einwilligungen, Rechtliche Vorlagen, Cookie Banner, Document Workflow)
- mkdocs.yml: Nav-Eintrag 'Rechtliche Texte (Paket 4)' ergaenzt
- dokumentations-module.md: Datenfluss-Diagramm um Paket-4-Module erweitert
- flow-data.ts: Paket-4-Steps mit korrekten dbTables/dbMode und aktualisierten Beschreibungen
- StepHeader.tsx: cookie-banner + workflow STEP_EXPLANATIONS auf Persistenz und Funktionsumfang aktualisiert

Tests: 24/24 bestanden (test_einwilligungen_routes.py)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 10:37:41 +01:00

393 lines
12 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,
)
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
# ============================================================================
# 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,
}
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)
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.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()
db.commit()
db.refresh(consent)
return {
"success": True,
"id": str(consent.id),
"revoked_at": consent.revoked_at,
}