Files
breakpilot-compliance/backend-compliance/compliance/api/banner_routes.py
Sharang Parnerkar 10073f3ef0 refactor(backend/api): extract BannerConsent + BannerAdmin services (Step 4)
Phase 1 Step 4, file 2 of 18. Same cookbook as audit_routes (4a91814 +
883ef70) applied to banner_routes.py.

compliance/api/banner_routes.py (653 LOC) is decomposed into:

  compliance/api/banner_routes.py                (255) — thin handlers
  compliance/services/banner_consent_service.py  (298) — public SDK surface
  compliance/services/banner_admin_service.py    (238) — site/category/vendor CRUD
  compliance/services/_banner_serializers.py     ( 81) — ORM-to-dict helpers
                                                         shared between the
                                                         two services
  compliance/schemas/banner.py                   ( 85) — Pydantic request models

Split rationale: the SDK-facing endpoints (consent CRUD, config
retrieval, export, stats) and the admin CRUD endpoints (sites +
categories + vendors) have distinct audiences and different auth stories,
and combined they would push the service file over the 500 hard cap.
Two focused services is cleaner than one ~540-line god class.

The shared ORM-to-dict helpers live in a private sibling module
(_banner_serializers) rather than a static method on either service, so
both services can import without a cycle.

Handlers follow the established pattern:
  - Depends(get_consent_service) or Depends(get_admin_service)
  - `with translate_domain_errors():` wrapping the service call
  - Explicit return type annotations
  - ~3-5 lines per handler

Services raise NotFoundError / ConflictError / ValidationError from
compliance.domain; no HTTPException in the service layer.

mypy.ini flips compliance.api.banner_routes from ignore_errors=True to
False, joining audit_routes in the strict scope. The services carry the
same scoped `# mypy: disable-error-code="arg-type,assignment"` header
used by the audit services for the ORM Column[T] issue.

Pydantic schemas moved to compliance.schemas.banner (mirroring the Step 3
schemas split). They were previously defined inline in banner_routes.py
and not referenced by anything outside it, so no backwards-compat shim
is needed.

Verified:
  - 224/224 pytest (173 baseline + 26 audit integration + 25 banner
    integration) pass
  - tests/contracts/test_openapi_baseline.py green (360/484 unchanged)
  - mypy compliance/ -> Success: no issues found in 123 source files
  - All new files under the 300 soft target (largest: 298)
  - banner_routes.py drops from 653 -> 255 LOC (below hard cap)

Hard-cap violations remaining: 16 (was 17).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:52:31 +02:00

256 lines
8.4 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,
SiteConfigCreate,
SiteConfigUpdate,
VendorConfigCreate,
)
from compliance.services.banner_admin_service import BannerAdminService
from compliance.services.banner_consent_service import BannerConsentService
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)
# =============================================================================
# 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,
)
@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)
# =============================================================================
# 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)
# =============================================================================
# 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)