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>
This commit is contained in:
298
backend-compliance/compliance/services/banner_consent_service.py
Normal file
298
backend-compliance/compliance/services/banner_consent_service.py
Normal file
@@ -0,0 +1,298 @@
|
||||
# mypy: disable-error-code="arg-type,assignment"
|
||||
# SQLAlchemy 1.x Column() descriptors are Column[T] statically, T at runtime.
|
||||
"""
|
||||
Banner consent service — SDK-facing endpoints.
|
||||
|
||||
Phase 1 Step 4: extracted from ``compliance.api.banner_routes``.
|
||||
Covers public device consent CRUD, site config retrieval (for banner
|
||||
display), export, and per-site consent statistics.
|
||||
|
||||
Admin-side site/category/vendor management lives in
|
||||
``compliance.services.banner_admin_service.BannerAdminService``.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from compliance.db.banner_models import (
|
||||
BannerCategoryConfigDB,
|
||||
BannerConsentAuditLogDB,
|
||||
BannerConsentDB,
|
||||
BannerSiteConfigDB,
|
||||
BannerVendorConfigDB,
|
||||
)
|
||||
from compliance.domain import NotFoundError, ValidationError
|
||||
from compliance.services._banner_serializers import (
|
||||
category_to_dict,
|
||||
consent_to_dict,
|
||||
site_config_to_dict,
|
||||
vendor_to_dict,
|
||||
)
|
||||
|
||||
|
||||
class BannerConsentService:
|
||||
"""Business logic for public SDK banner consent endpoints."""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _hash_ip(ip: Optional[str]) -> Optional[str]:
|
||||
if not ip:
|
||||
return None
|
||||
return hashlib.sha256(ip.encode()).hexdigest()[:16]
|
||||
|
||||
def _log(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
consent_id: Any,
|
||||
action: str,
|
||||
site_id: str,
|
||||
device_fingerprint: Optional[str] = None,
|
||||
categories: Optional[list[str]] = None,
|
||||
ip_hash: Optional[str] = None,
|
||||
) -> None:
|
||||
entry = BannerConsentAuditLogDB(
|
||||
tenant_id=tenant_id,
|
||||
consent_id=consent_id,
|
||||
action=action,
|
||||
site_id=site_id,
|
||||
device_fingerprint=device_fingerprint,
|
||||
categories=categories or [],
|
||||
ip_hash=ip_hash,
|
||||
)
|
||||
self.db.add(entry)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Consent CRUD (public SDK)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def record_consent(
|
||||
self,
|
||||
tenant_id: str,
|
||||
site_id: str,
|
||||
device_fingerprint: str,
|
||||
categories: list[str],
|
||||
vendors: list[str],
|
||||
ip_address: Optional[str],
|
||||
user_agent: Optional[str],
|
||||
consent_string: Optional[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Upsert a device consent row for (tenant, site, device_fingerprint)."""
|
||||
tid = uuid.UUID(tenant_id)
|
||||
ip_hash = self._hash_ip(ip_address)
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(days=365)
|
||||
|
||||
existing = (
|
||||
self.db.query(BannerConsentDB)
|
||||
.filter(
|
||||
BannerConsentDB.tenant_id == tid,
|
||||
BannerConsentDB.site_id == site_id,
|
||||
BannerConsentDB.device_fingerprint == device_fingerprint,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing:
|
||||
existing.categories = categories
|
||||
existing.vendors = vendors
|
||||
existing.ip_hash = ip_hash
|
||||
existing.user_agent = user_agent
|
||||
existing.consent_string = consent_string
|
||||
existing.expires_at = expires_at
|
||||
existing.updated_at = now
|
||||
self.db.flush()
|
||||
self._log(
|
||||
tid, existing.id, "consent_updated", site_id, device_fingerprint,
|
||||
categories, ip_hash,
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(existing)
|
||||
return consent_to_dict(existing)
|
||||
|
||||
consent = BannerConsentDB(
|
||||
tenant_id=tid,
|
||||
site_id=site_id,
|
||||
device_fingerprint=device_fingerprint,
|
||||
categories=categories,
|
||||
vendors=vendors,
|
||||
ip_hash=ip_hash,
|
||||
user_agent=user_agent,
|
||||
consent_string=consent_string,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
self.db.add(consent)
|
||||
self.db.flush()
|
||||
self._log(
|
||||
tid, consent.id, "consent_given", site_id, device_fingerprint,
|
||||
categories, ip_hash,
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(consent)
|
||||
return consent_to_dict(consent)
|
||||
|
||||
def get_consent(
|
||||
self, tenant_id: str, site_id: str, device_fingerprint: str
|
||||
) -> dict[str, Any]:
|
||||
"""Return the consent envelope for a device, or has_consent=false."""
|
||||
tid = uuid.UUID(tenant_id)
|
||||
consent = (
|
||||
self.db.query(BannerConsentDB)
|
||||
.filter(
|
||||
BannerConsentDB.tenant_id == tid,
|
||||
BannerConsentDB.site_id == site_id,
|
||||
BannerConsentDB.device_fingerprint == device_fingerprint,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not consent:
|
||||
return {"has_consent": False, "consent": None}
|
||||
return {"has_consent": True, "consent": consent_to_dict(consent)}
|
||||
|
||||
def withdraw_consent(self, tenant_id: str, consent_id: str) -> dict[str, Any]:
|
||||
"""Delete a consent row + audit the withdrawal."""
|
||||
tid = uuid.UUID(tenant_id)
|
||||
try:
|
||||
cid = uuid.UUID(consent_id)
|
||||
except ValueError as exc:
|
||||
raise ValidationError("Invalid consent ID") from exc
|
||||
|
||||
consent = (
|
||||
self.db.query(BannerConsentDB)
|
||||
.filter(BannerConsentDB.id == cid, BannerConsentDB.tenant_id == tid)
|
||||
.first()
|
||||
)
|
||||
if not consent:
|
||||
raise NotFoundError("Consent not found")
|
||||
|
||||
self._log(
|
||||
tid, cid, "consent_withdrawn", consent.site_id, consent.device_fingerprint,
|
||||
)
|
||||
self.db.delete(consent)
|
||||
self.db.commit()
|
||||
return {"success": True, "message": "Consent withdrawn"}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Site config retrieval (SDK embed)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_site_config(self, tenant_id: str, site_id: str) -> dict[str, Any]:
|
||||
"""Load site config + active categories + active vendors for banner display."""
|
||||
tid = uuid.UUID(tenant_id)
|
||||
config = (
|
||||
self.db.query(BannerSiteConfigDB)
|
||||
.filter(
|
||||
BannerSiteConfigDB.tenant_id == tid,
|
||||
BannerSiteConfigDB.site_id == site_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not config:
|
||||
return {
|
||||
"site_id": site_id,
|
||||
"banner_title": "Cookie-Einstellungen",
|
||||
"banner_description": (
|
||||
"Wir verwenden Cookies, um Ihnen die bestmoegliche Erfahrung zu bieten."
|
||||
),
|
||||
"categories": [],
|
||||
"vendors": [],
|
||||
}
|
||||
|
||||
categories = (
|
||||
self.db.query(BannerCategoryConfigDB)
|
||||
.filter(
|
||||
BannerCategoryConfigDB.site_config_id == config.id,
|
||||
BannerCategoryConfigDB.is_active,
|
||||
)
|
||||
.order_by(BannerCategoryConfigDB.sort_order)
|
||||
.all()
|
||||
)
|
||||
vendors = (
|
||||
self.db.query(BannerVendorConfigDB)
|
||||
.filter(
|
||||
BannerVendorConfigDB.site_config_id == config.id,
|
||||
BannerVendorConfigDB.is_active,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
result = site_config_to_dict(config)
|
||||
result["categories"] = [category_to_dict(c) for c in categories]
|
||||
result["vendors"] = [vendor_to_dict(v) for v in vendors]
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DSGVO export + stats
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def export_consent(
|
||||
self, tenant_id: str, site_id: str, device_fingerprint: str
|
||||
) -> dict[str, Any]:
|
||||
"""DSGVO export of all consent + audit rows for a device."""
|
||||
tid = uuid.UUID(tenant_id)
|
||||
consents = (
|
||||
self.db.query(BannerConsentDB)
|
||||
.filter(
|
||||
BannerConsentDB.tenant_id == tid,
|
||||
BannerConsentDB.site_id == site_id,
|
||||
BannerConsentDB.device_fingerprint == device_fingerprint,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
audit = (
|
||||
self.db.query(BannerConsentAuditLogDB)
|
||||
.filter(
|
||||
BannerConsentAuditLogDB.tenant_id == tid,
|
||||
BannerConsentAuditLogDB.site_id == site_id,
|
||||
BannerConsentAuditLogDB.device_fingerprint == device_fingerprint,
|
||||
)
|
||||
.order_by(BannerConsentAuditLogDB.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"device_fingerprint": device_fingerprint,
|
||||
"site_id": site_id,
|
||||
"consents": [consent_to_dict(c) for c in consents],
|
||||
"audit_trail": [
|
||||
{
|
||||
"id": str(a.id),
|
||||
"action": a.action,
|
||||
"categories": a.categories or [],
|
||||
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||||
}
|
||||
for a in audit
|
||||
],
|
||||
}
|
||||
|
||||
def get_site_stats(self, tenant_id: str, site_id: str) -> dict[str, Any]:
|
||||
"""Compute consent count + per-category acceptance rates for a site."""
|
||||
tid = uuid.UUID(tenant_id)
|
||||
base = self.db.query(BannerConsentDB).filter(
|
||||
BannerConsentDB.tenant_id == tid,
|
||||
BannerConsentDB.site_id == site_id,
|
||||
)
|
||||
total = base.count()
|
||||
category_stats: dict[str, int] = {}
|
||||
for c in base.all():
|
||||
cats: list[str] = list(c.categories or [])
|
||||
for cat in cats:
|
||||
category_stats[cat] = category_stats.get(cat, 0) + 1
|
||||
return {
|
||||
"site_id": site_id,
|
||||
"total_consents": total,
|
||||
"category_acceptance": {
|
||||
cat: {
|
||||
"count": count,
|
||||
"rate": round(count / total * 100, 1) if total > 0 else 0,
|
||||
}
|
||||
for cat, count in category_stats.items()
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user