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>
239 lines
7.8 KiB
Python
239 lines
7.8 KiB
Python
# mypy: disable-error-code="arg-type,assignment"
|
|
# SQLAlchemy 1.x Column() descriptors are Column[T] statically, T at runtime.
|
|
"""
|
|
Banner admin service — site config + category + vendor CRUD.
|
|
|
|
Phase 1 Step 4: extracted from ``compliance.api.banner_routes``.
|
|
Covers the admin surface: site configs and the nested category and
|
|
vendor collections.
|
|
"""
|
|
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from compliance.db.banner_models import (
|
|
BannerCategoryConfigDB,
|
|
BannerSiteConfigDB,
|
|
BannerVendorConfigDB,
|
|
)
|
|
from compliance.domain import ConflictError, NotFoundError, ValidationError
|
|
from compliance.schemas.banner import (
|
|
CategoryConfigCreate,
|
|
SiteConfigCreate,
|
|
SiteConfigUpdate,
|
|
VendorConfigCreate,
|
|
)
|
|
from compliance.services._banner_serializers import (
|
|
category_to_dict,
|
|
site_config_to_dict,
|
|
vendor_to_dict,
|
|
)
|
|
|
|
_UPDATABLE_SITE_FIELDS = (
|
|
"site_name",
|
|
"site_url",
|
|
"banner_title",
|
|
"banner_description",
|
|
"privacy_url",
|
|
"imprint_url",
|
|
"dsb_name",
|
|
"dsb_email",
|
|
"theme",
|
|
"tcf_enabled",
|
|
"is_active",
|
|
)
|
|
|
|
|
|
class BannerAdminService:
|
|
"""Business logic for the banner admin surface."""
|
|
|
|
def __init__(self, db: Session) -> None:
|
|
self.db = db
|
|
|
|
# ------------------------------------------------------------------
|
|
# Internal lookups
|
|
# ------------------------------------------------------------------
|
|
|
|
def _site_or_raise(self, tenant_id: uuid.UUID, site_id: str) -> BannerSiteConfigDB:
|
|
config = (
|
|
self.db.query(BannerSiteConfigDB)
|
|
.filter(
|
|
BannerSiteConfigDB.tenant_id == tenant_id,
|
|
BannerSiteConfigDB.site_id == site_id,
|
|
)
|
|
.first()
|
|
)
|
|
if not config:
|
|
raise NotFoundError("Site config not found")
|
|
return config
|
|
|
|
@staticmethod
|
|
def _parse_uuid(raw: str, label: str) -> uuid.UUID:
|
|
try:
|
|
return uuid.UUID(raw)
|
|
except ValueError as exc:
|
|
raise ValidationError(f"Invalid {label} ID") from exc
|
|
|
|
# ------------------------------------------------------------------
|
|
# Site configs
|
|
# ------------------------------------------------------------------
|
|
|
|
def list_sites(self, tenant_id: str) -> list[dict[str, Any]]:
|
|
tid = uuid.UUID(tenant_id)
|
|
configs = (
|
|
self.db.query(BannerSiteConfigDB)
|
|
.filter(BannerSiteConfigDB.tenant_id == tid)
|
|
.order_by(BannerSiteConfigDB.created_at.desc())
|
|
.all()
|
|
)
|
|
return [site_config_to_dict(c) for c in configs]
|
|
|
|
def create_site(self, tenant_id: str, body: SiteConfigCreate) -> dict[str, Any]:
|
|
tid = uuid.UUID(tenant_id)
|
|
existing = (
|
|
self.db.query(BannerSiteConfigDB)
|
|
.filter(
|
|
BannerSiteConfigDB.tenant_id == tid,
|
|
BannerSiteConfigDB.site_id == body.site_id,
|
|
)
|
|
.first()
|
|
)
|
|
if existing:
|
|
raise ConflictError(
|
|
f"Site config for '{body.site_id}' already exists"
|
|
)
|
|
config = BannerSiteConfigDB(
|
|
tenant_id=tid,
|
|
site_id=body.site_id,
|
|
site_name=body.site_name,
|
|
site_url=body.site_url,
|
|
banner_title=body.banner_title or "Cookie-Einstellungen",
|
|
banner_description=body.banner_description,
|
|
privacy_url=body.privacy_url,
|
|
imprint_url=body.imprint_url,
|
|
dsb_name=body.dsb_name,
|
|
dsb_email=body.dsb_email,
|
|
theme=body.theme or {},
|
|
tcf_enabled=body.tcf_enabled,
|
|
)
|
|
self.db.add(config)
|
|
self.db.commit()
|
|
self.db.refresh(config)
|
|
return site_config_to_dict(config)
|
|
|
|
def update_site(
|
|
self, tenant_id: str, site_id: str, body: SiteConfigUpdate
|
|
) -> dict[str, Any]:
|
|
tid = uuid.UUID(tenant_id)
|
|
config = self._site_or_raise(tid, site_id)
|
|
for field in _UPDATABLE_SITE_FIELDS:
|
|
val = getattr(body, field, None)
|
|
if val is not None:
|
|
setattr(config, field, val)
|
|
config.updated_at = datetime.now(timezone.utc)
|
|
self.db.commit()
|
|
self.db.refresh(config)
|
|
return site_config_to_dict(config)
|
|
|
|
def delete_site(self, tenant_id: str, site_id: str) -> None:
|
|
tid = uuid.UUID(tenant_id)
|
|
config = self._site_or_raise(tid, site_id)
|
|
self.db.delete(config)
|
|
self.db.commit()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Categories
|
|
# ------------------------------------------------------------------
|
|
|
|
def list_categories(self, tenant_id: str, site_id: str) -> list[dict[str, Any]]:
|
|
tid = uuid.UUID(tenant_id)
|
|
config = self._site_or_raise(tid, site_id)
|
|
cats = (
|
|
self.db.query(BannerCategoryConfigDB)
|
|
.filter(BannerCategoryConfigDB.site_config_id == config.id)
|
|
.order_by(BannerCategoryConfigDB.sort_order)
|
|
.all()
|
|
)
|
|
return [category_to_dict(c) for c in cats]
|
|
|
|
def create_category(
|
|
self, tenant_id: str, site_id: str, body: CategoryConfigCreate
|
|
) -> dict[str, Any]:
|
|
tid = uuid.UUID(tenant_id)
|
|
config = self._site_or_raise(tid, site_id)
|
|
cat = BannerCategoryConfigDB(
|
|
site_config_id=config.id,
|
|
category_key=body.category_key,
|
|
name_de=body.name_de,
|
|
name_en=body.name_en,
|
|
description_de=body.description_de,
|
|
description_en=body.description_en,
|
|
is_required=body.is_required,
|
|
sort_order=body.sort_order,
|
|
)
|
|
self.db.add(cat)
|
|
self.db.commit()
|
|
self.db.refresh(cat)
|
|
return category_to_dict(cat)
|
|
|
|
def delete_category(self, category_id: str) -> None:
|
|
cid = self._parse_uuid(category_id, "category")
|
|
cat = (
|
|
self.db.query(BannerCategoryConfigDB)
|
|
.filter(BannerCategoryConfigDB.id == cid)
|
|
.first()
|
|
)
|
|
if not cat:
|
|
raise NotFoundError("Category not found")
|
|
self.db.delete(cat)
|
|
self.db.commit()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Vendors
|
|
# ------------------------------------------------------------------
|
|
|
|
def list_vendors(self, tenant_id: str, site_id: str) -> list[dict[str, Any]]:
|
|
tid = uuid.UUID(tenant_id)
|
|
config = self._site_or_raise(tid, site_id)
|
|
vendors = (
|
|
self.db.query(BannerVendorConfigDB)
|
|
.filter(BannerVendorConfigDB.site_config_id == config.id)
|
|
.all()
|
|
)
|
|
return [vendor_to_dict(v) for v in vendors]
|
|
|
|
def create_vendor(
|
|
self, tenant_id: str, site_id: str, body: VendorConfigCreate
|
|
) -> dict[str, Any]:
|
|
tid = uuid.UUID(tenant_id)
|
|
config = self._site_or_raise(tid, site_id)
|
|
vendor = BannerVendorConfigDB(
|
|
site_config_id=config.id,
|
|
vendor_name=body.vendor_name,
|
|
vendor_url=body.vendor_url,
|
|
category_key=body.category_key,
|
|
description_de=body.description_de,
|
|
description_en=body.description_en,
|
|
cookie_names=body.cookie_names,
|
|
retention_days=body.retention_days,
|
|
)
|
|
self.db.add(vendor)
|
|
self.db.commit()
|
|
self.db.refresh(vendor)
|
|
return vendor_to_dict(vendor)
|
|
|
|
def delete_vendor(self, vendor_id: str) -> None:
|
|
vid = self._parse_uuid(vendor_id, "vendor")
|
|
vendor = (
|
|
self.db.query(BannerVendorConfigDB)
|
|
.filter(BannerVendorConfigDB.id == vid)
|
|
.first()
|
|
)
|
|
if not vendor:
|
|
raise NotFoundError("Vendor not found")
|
|
self.db.delete(vendor)
|
|
self.db.commit()
|