Files
breakpilot-compliance/backend-compliance/compliance/api/banner_routes.py
T
Benjamin Admin 289ec5f396
Build + Deploy / build-admin-compliance (push) Successful in 2m28s
Build + Deploy / build-backend-compliance (push) Successful in 3m48s
Build + Deploy / build-ai-sdk (push) Failing after 45s
Build + Deploy / build-developer-portal (push) Successful in 1m28s
Build + Deploy / build-tts (push) Successful in 1m48s
Build + Deploy / build-document-crawler (push) Successful in 48s
Build + Deploy / build-dsms-gateway (push) Successful in 34s
Build + Deploy / build-dsms-node (push) Successful in 20s
CI / branch-name (push) Has been skipped
Build + Deploy / trigger-orca (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 24s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m1s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Failing after 49s
CI / test-python-backend (push) Successful in 45s
CI / test-python-document-crawler (push) Successful in 31s
CI / test-python-dsms-gateway (push) Successful in 27s
CI / validate-canonical-controls (push) Successful in 18s
feat(cmp): vendor-agnostic consent data model — 13 new fields
Extend banner consent records with consent_method, banner_version,
banner_config_hash, geo, page_url, referrer, device info, session_id
and consent_scope for full Art. 7 DSGVO proof with any tracking vendor.

Migration 107, backward-compatible (all fields nullable).
Admin detail modal shows tracking context, device info and technical data.
Fix pre-existing str|None → Optional[str] for Python 3.9 compat.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-10 23:12:20 +02:00

389 lines
13 KiB
Python

"""
Banner Consent Routes — Device-basierte Cookie-Consents fuer Kunden-Websites.
Public SDK-Endpoints (fuer Einbettung) + Admin-Endpoints (Konfiguration & Stats).
Phase 1 Step 4 refactor: handlers are thin and delegate to
``BannerConsentService`` (SDK surface) or ``BannerAdminService`` (admin
CRUD). Domain errors raised by the services are translated to
HTTPException via ``translate_domain_errors``. Pydantic request schemas
live in ``compliance.schemas.banner``.
"""
from typing import Any, Optional
from fastapi import APIRouter, Depends, Header, Query
from sqlalchemy.orm import Session
from classroom_engine.database import get_db
from compliance.api._http_errors import translate_domain_errors
from compliance.schemas.banner import (
CategoryConfigCreate,
ConsentCreate,
ConsentSyncRequest,
LinkEmailRequest,
SiteConfigCreate,
SiteConfigUpdate,
VendorConfigCreate,
)
from compliance.services.banner_admin_service import BannerAdminService
from compliance.services.banner_consent_service import BannerConsentService
from compliance.services.banner_dsr_service import BannerDSRService
from compliance.services.vendor_banner_sync import get_banner_vendors_from_registry
router = APIRouter(prefix="/banner", tags=["compliance-banner"])
DEFAULT_TENANT = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
# ----------------------------------------------------------------------
# Dependencies
# ----------------------------------------------------------------------
def _get_tenant(x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID")) -> str:
return x_tenant_id or DEFAULT_TENANT
def get_consent_service(db: Session = Depends(get_db)) -> BannerConsentService:
return BannerConsentService(db)
def get_admin_service(db: Session = Depends(get_db)) -> BannerAdminService:
return BannerAdminService(db)
def get_dsr_service(db: Session = Depends(get_db)) -> BannerDSRService:
return BannerDSRService(db)
# =============================================================================
# Public SDK Endpoints (fuer Einbettung in Kunden-Websites)
# =============================================================================
@router.post("/consent")
async def record_consent(
body: ConsentCreate,
tenant_id: str = Depends(_get_tenant),
service: BannerConsentService = Depends(get_consent_service),
) -> dict[str, Any]:
"""Record device consent (upsert by site_id + device_fingerprint)."""
with translate_domain_errors():
return service.record_consent(
tenant_id=tenant_id,
site_id=body.site_id,
device_fingerprint=body.device_fingerprint,
categories=body.categories,
vendors=body.vendors,
ip_address=body.ip_address,
user_agent=body.user_agent,
consent_string=body.consent_string,
consent_method=body.consent_method,
page_url=body.page_url,
referrer=body.referrer,
device_type=body.device_type,
browser=body.browser,
os=body.os,
screen_resolution=body.screen_resolution,
session_id=body.session_id,
consent_scope=body.consent_scope,
)
@router.get("/consent")
async def get_consent(
site_id: str = Query(...),
device_fingerprint: str = Query(...),
tenant_id: str = Depends(_get_tenant),
service: BannerConsentService = Depends(get_consent_service),
) -> dict[str, Any]:
"""Retrieve consent for a device."""
with translate_domain_errors():
return service.get_consent(tenant_id, site_id, device_fingerprint)
@router.delete("/consent/{consent_id}")
async def withdraw_consent(
consent_id: str,
tenant_id: str = Depends(_get_tenant),
service: BannerConsentService = Depends(get_consent_service),
) -> dict[str, Any]:
"""Withdraw a banner consent."""
with translate_domain_errors():
return service.withdraw_consent(tenant_id, consent_id)
@router.get("/config/{site_id}")
async def get_site_config(
site_id: str,
tenant_id: str = Depends(_get_tenant),
service: BannerConsentService = Depends(get_consent_service),
) -> dict[str, Any]:
"""Load site configuration for banner display."""
with translate_domain_errors():
return service.get_site_config(tenant_id, site_id)
@router.get("/consent/export")
async def export_consent(
site_id: str = Query(...),
device_fingerprint: str = Query(...),
tenant_id: str = Depends(_get_tenant),
service: BannerConsentService = Depends(get_consent_service),
) -> dict[str, Any]:
"""DSGVO export of all consent data for a device."""
with translate_domain_errors():
return service.export_consent(tenant_id, site_id, device_fingerprint)
# =============================================================================
# DSR Integration — Email Linking + Consent Sync (Phase 3 + 4)
# =============================================================================
@router.post("/consent/link-email")
async def link_email(
body: LinkEmailRequest,
tenant_id: str = Depends(_get_tenant),
service: BannerDSRService = Depends(get_dsr_service),
) -> dict[str, Any]:
"""Link an email to a device fingerprint (e.g. after signup/login)."""
with translate_domain_errors():
return service.link_email(
tenant_id, body.site_id, body.device_fingerprint, body.email,
)
@router.get("/consent/by-email/{email}")
async def get_consents_by_email(
email: str,
tenant_id: str = Depends(_get_tenant),
service: BannerDSRService = Depends(get_dsr_service),
) -> list[dict[str, Any]]:
"""Find all banner consents linked to an email (Art. 15 DSGVO)."""
with translate_domain_errors():
return service.get_consents_by_email(tenant_id, email)
@router.delete("/consent/by-email/{email}")
async def delete_consents_by_email(
email: str,
tenant_id: str = Depends(_get_tenant),
service: BannerDSRService = Depends(get_dsr_service),
) -> dict[str, Any]:
"""Delete all banner consents for an email (Art. 17 DSGVO erasure)."""
with translate_domain_errors():
return service.delete_consents_by_email(tenant_id, email)
@router.get("/consent/dsr-export/{email}")
async def export_for_dsr(
email: str,
tenant_id: str = Depends(_get_tenant),
service: BannerDSRService = Depends(get_dsr_service),
) -> dict[str, Any]:
"""Export all banner consent data for DSR (Art. 15/20 DSGVO)."""
with translate_domain_errors():
return service.export_for_dsr(tenant_id, email)
@router.post("/consent/sync")
async def sync_consent(
body: ConsentSyncRequest,
tenant_id: str = Depends(_get_tenant),
service: BannerDSRService = Depends(get_dsr_service),
) -> dict[str, Any]:
"""Sync banner consent to Einwilligungen (Phase 4 — user-based bridge)."""
with translate_domain_errors():
return service.sync_consent_to_einwilligungen(
tenant_id, body.device_fingerprint, body.email, body.site_id,
)
# =============================================================================
# Admin — Stats
# =============================================================================
@router.get("/admin/stats/{site_id}")
async def get_site_stats(
site_id: str,
tenant_id: str = Depends(_get_tenant),
service: BannerConsentService = Depends(get_consent_service),
) -> dict[str, Any]:
"""Consent statistics per site."""
with translate_domain_errors():
return service.get_site_stats(tenant_id, site_id)
@router.get("/admin/consents")
async def list_banner_consents(
site_id: Optional[str] = None,
limit: int = 50,
offset: int = 0,
tenant_id: str = Depends(_get_tenant),
service: BannerConsentService = Depends(get_consent_service),
) -> dict[str, Any]:
"""Paginated list of banner consents for admin dashboard."""
with translate_domain_errors():
return service.list_consents(tenant_id, site_id, limit, offset)
# =============================================================================
# Admin — Sites
# =============================================================================
@router.get("/admin/sites")
async def list_site_configs(
tenant_id: str = Depends(_get_tenant),
service: BannerAdminService = Depends(get_admin_service),
) -> list[dict[str, Any]]:
"""List all site configurations."""
with translate_domain_errors():
return service.list_sites(tenant_id)
@router.post("/admin/sites")
async def create_site_config(
body: SiteConfigCreate,
tenant_id: str = Depends(_get_tenant),
service: BannerAdminService = Depends(get_admin_service),
) -> dict[str, Any]:
"""Create a site configuration."""
with translate_domain_errors():
return service.create_site(tenant_id, body)
@router.put("/admin/sites/{site_id}")
async def update_site_config(
site_id: str,
body: SiteConfigUpdate,
tenant_id: str = Depends(_get_tenant),
service: BannerAdminService = Depends(get_admin_service),
) -> dict[str, Any]:
"""Update a site configuration."""
with translate_domain_errors():
return service.update_site(tenant_id, site_id, body)
@router.delete("/admin/sites/{site_id}", status_code=204)
async def delete_site_config(
site_id: str,
tenant_id: str = Depends(_get_tenant),
service: BannerAdminService = Depends(get_admin_service),
) -> None:
"""Delete a site configuration."""
with translate_domain_errors():
service.delete_site(tenant_id, site_id)
# =============================================================================
# Admin — Categories
# =============================================================================
@router.get("/admin/sites/{site_id}/categories")
async def list_categories(
site_id: str,
tenant_id: str = Depends(_get_tenant),
service: BannerAdminService = Depends(get_admin_service),
) -> list[dict[str, Any]]:
"""List categories for a site."""
with translate_domain_errors():
return service.list_categories(tenant_id, site_id)
@router.post("/admin/sites/{site_id}/categories")
async def create_category(
site_id: str,
body: CategoryConfigCreate,
tenant_id: str = Depends(_get_tenant),
service: BannerAdminService = Depends(get_admin_service),
) -> dict[str, Any]:
"""Create a category for a site."""
with translate_domain_errors():
return service.create_category(tenant_id, site_id, body)
@router.delete("/admin/categories/{category_id}", status_code=204)
async def delete_category(
category_id: str,
service: BannerAdminService = Depends(get_admin_service),
) -> None:
"""Delete a category."""
with translate_domain_errors():
service.delete_category(category_id)
# =============================================================================
# Admin — Vendors
# =============================================================================
@router.get("/admin/sites/{site_id}/vendors")
async def list_vendors(
site_id: str,
tenant_id: str = Depends(_get_tenant),
service: BannerAdminService = Depends(get_admin_service),
) -> list[dict[str, Any]]:
"""List vendors for a site."""
with translate_domain_errors():
return service.list_vendors(tenant_id, site_id)
@router.post("/admin/sites/{site_id}/vendors")
async def create_vendor(
site_id: str,
body: VendorConfigCreate,
tenant_id: str = Depends(_get_tenant),
service: BannerAdminService = Depends(get_admin_service),
) -> dict[str, Any]:
"""Create a vendor for a site."""
with translate_domain_errors():
return service.create_vendor(tenant_id, site_id, body)
@router.delete("/admin/vendors/{vendor_id}", status_code=204)
async def delete_vendor(
vendor_id: str,
service: BannerAdminService = Depends(get_admin_service),
) -> None:
"""Delete a vendor."""
with translate_domain_errors():
service.delete_vendor(vendor_id)
# =============================================================================
# Admin — Vendor Sync from Service Registry (Phase 1)
# =============================================================================
@router.post("/admin/sites/{site_id}/sync-vendors")
async def sync_vendors_from_registry(
site_id: str,
tenant_id: str = Depends(_get_tenant),
service: BannerAdminService = Depends(get_admin_service),
) -> dict[str, Any]:
"""Sync 82+ services from service registry to banner vendor configs."""
with translate_domain_errors():
vendors = get_banner_vendors_from_registry()
created = 0
updated = 0
for v in vendors:
try:
existing = service.list_vendors(tenant_id, site_id)
match = next(
(e for e in existing if e["vendor_name"] == v["vendor_name"]),
None,
)
if match:
updated += 1
else:
from compliance.schemas.banner import VendorConfigCreate
service.create_vendor(tenant_id, site_id, VendorConfigCreate(
vendor_name=v["vendor_name"],
category_key=v["category_key"],
description_de=v["description_de"],
description_en=v["description_en"],
cookie_names=v["cookie_names"],
retention_days=v["retention_days"],
))
created += 1
except Exception:
continue
return {"created": created, "updated": updated, "total": len(vendors)}