""" 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, timezone 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.now(timezone.utc) 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.now(timezone.utc) 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.now(timezone.utc) 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.now(timezone.utc), 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.now(timezone.utc) _record_history(db, consent, 'revoked') db.commit() db.refresh(consent) return { "success": True, "id": str(consent.id), "revoked_at": consent.revoked_at, }