feat: Vendor-level consent + Consent analytics (F4 + F6)

F4: Granular Vendor-Level Consent
- Migration 113: vendor_consents JSONB on banner_consents + audit_log
- ConsentCreate schema + BannerConsentDB model extended
- banner_consent_service stores vendor_consents alongside categories
- Audit trail includes vendor-level decisions + user_agent

F6: Consent Rate Analytics
- Migration 114: user_agent on audit_log + time-series index
- BannerAnalyticsService: time series, category breakdown, device stats
- banner_analytics_routes: 4 endpoints (overview, time-series, categories, devices)
- AnalyticsDashboard.tsx: KPIs, bar chart, category bars, device breakdown
- New "Analytik" tab in cookie-banner page

[migration-approved]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-03 20:58:06 +02:00
parent 36d9f929c6
commit c3fcfe88ee
12 changed files with 432 additions and 1 deletions
@@ -65,6 +65,7 @@ _ROUTER_MODULES = [
"assertion_routes",
"org_role_routes",
"document_review_routes",
"banner_analytics_routes",
]
_loaded_count = 0
@@ -0,0 +1,67 @@
"""
FastAPI routes for Banner Consent Analytics.
Endpoints:
GET /banner/analytics/{site_id}/overview — high-level stats
GET /banner/analytics/{site_id}/time-series — opt-in rate over time
GET /banner/analytics/{site_id}/categories — acceptance per category
GET /banner/analytics/{site_id}/devices — mobile/desktop/tablet breakdown
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from classroom_engine.database import get_db
from .tenant_utils import get_tenant_id as _get_tenant_id
from compliance.services.banner_analytics_service import BannerAnalyticsService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/banner/analytics", tags=["banner-analytics"])
@router.get("/{site_id}/overview")
def analytics_overview(
site_id: str,
days: int = Query(30, le=365),
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant_id),
):
service = BannerAnalyticsService(db)
return service.get_overview_stats(tenant_id, site_id, days)
@router.get("/{site_id}/time-series")
def analytics_time_series(
site_id: str,
period: str = Query("daily"),
days: int = Query(30, le=365),
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant_id),
):
service = BannerAnalyticsService(db)
return service.get_time_series(tenant_id, site_id, period, days)
@router.get("/{site_id}/categories")
def analytics_categories(
site_id: str,
days: int = Query(30, le=365),
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant_id),
):
service = BannerAnalyticsService(db)
return service.get_category_breakdown(tenant_id, site_id, days)
@router.get("/{site_id}/devices")
def analytics_devices(
site_id: str,
days: int = Query(30, le=365),
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant_id),
):
service = BannerAnalyticsService(db)
return service.get_device_breakdown(tenant_id, site_id, days)
@@ -74,6 +74,7 @@ async def record_consent(
device_fingerprint=body.device_fingerprint,
categories=body.categories,
vendors=body.vendors,
vendor_consents=body.vendor_consents,
ip_address=body.ip_address,
user_agent=body.user_agent,
consent_string=body.consent_string,
@@ -31,6 +31,7 @@ class BannerConsentDB(Base):
device_fingerprint = Column(Text, nullable=False)
categories = Column(JSON, default=list)
vendors = Column(JSON, default=list)
vendor_consents = Column(JSON, default=dict) # {"vendor_id": true/false}
ip_hash = Column(Text)
user_agent = Column(Text)
consent_string = Column(Text)
@@ -60,7 +61,9 @@ class BannerConsentAuditLogDB(Base):
site_id = Column(Text, nullable=False)
device_fingerprint = Column(Text)
categories = Column(JSON, default=list)
vendor_consents = Column(JSON, default=dict)
ip_hash = Column(Text)
user_agent = Column(Text)
banner_config_hash = Column(Text)
consent_version = Column(Integer)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
@@ -16,6 +16,7 @@ class ConsentCreate(BaseModel):
device_fingerprint: str
categories: List[str] = []
vendors: List[str] = []
vendor_consents: dict[str, bool] = {}
ip_address: Optional[str] = None
user_agent: Optional[str] = None
consent_string: Optional[str] = None
@@ -23,6 +23,7 @@ def consent_to_dict(c: BannerConsentDB) -> dict[str, Any]:
"device_fingerprint": c.device_fingerprint,
"categories": c.categories or [],
"vendors": c.vendors or [],
"vendor_consents": c.vendor_consents or {},
"ip_hash": c.ip_hash,
"consent_string": c.consent_string,
"linked_email": c.linked_email,
@@ -0,0 +1,135 @@
"""
Banner consent analytics — time-series, device breakdown, bounce rate.
Reads from BannerConsentAuditLogDB for aggregated analytics.
"""
import re
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from sqlalchemy import text
from sqlalchemy.orm import Session
class BannerAnalyticsService:
"""Provides aggregated consent analytics for a site."""
def __init__(self, db: Session) -> None:
self.db = db
def get_time_series(
self,
tenant_id: str,
site_id: str,
period: str = "daily",
days: int = 30,
) -> list[dict[str, Any]]:
"""Opt-in rate per day/week over the last N days."""
trunc = "day" if period == "daily" else "week"
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
q = text(f"""
SELECT DATE_TRUNC(:trunc, created_at) AS period,
COUNT(*) FILTER (WHERE action = 'consent_given') AS given,
COUNT(*) FILTER (WHERE action = 'consent_updated') AS updated,
COUNT(*) FILTER (WHERE action IN ('consent_withdrawn', 'consent_revoked')) AS withdrawn,
COUNT(*) AS total
FROM compliance_banner_consent_audit_log
WHERE tenant_id = :tid AND site_id = :sid AND created_at >= :cutoff
GROUP BY 1 ORDER BY 1
""")
rows = self.db.execute(q, {"tid": tenant_id, "sid": site_id, "cutoff": cutoff, "trunc": trunc}).fetchall()
return [
{
"period": r.period.isoformat() if r.period else None,
"given": r.given,
"updated": r.updated,
"withdrawn": r.withdrawn,
"total": r.total,
"opt_in_rate": round((r.given + r.updated) / r.total * 100, 1) if r.total > 0 else 0,
}
for r in rows
]
def get_category_breakdown(
self,
tenant_id: str,
site_id: str,
days: int = 30,
) -> dict[str, dict[str, int]]:
"""Acceptance count per category."""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
q = text("""
SELECT categories FROM compliance_banner_consent_audit_log
WHERE tenant_id = :tid AND site_id = :sid AND created_at >= :cutoff
AND action IN ('consent_given', 'consent_updated')
""")
rows = self.db.execute(q, {"tid": tenant_id, "sid": site_id, "cutoff": cutoff}).fetchall()
counts: dict[str, int] = {}
total = len(rows)
for r in rows:
cats = r.categories if isinstance(r.categories, list) else []
for cat in cats:
counts[cat] = counts.get(cat, 0) + 1
return {
cat: {"count": count, "total": total, "rate": round(count / total * 100, 1) if total > 0 else 0}
for cat, count in sorted(counts.items())
}
def get_device_breakdown(
self,
tenant_id: str,
site_id: str,
days: int = 30,
) -> dict[str, int]:
"""Mobile/Desktop/Tablet classification from user_agent."""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
q = text("""
SELECT user_agent FROM compliance_banner_consent_audit_log
WHERE tenant_id = :tid AND site_id = :sid AND created_at >= :cutoff
AND user_agent IS NOT NULL
""")
rows = self.db.execute(q, {"tid": tenant_id, "sid": site_id, "cutoff": cutoff}).fetchall()
result = {"desktop": 0, "mobile": 0, "tablet": 0, "unknown": 0}
mobile_re = re.compile(r"Mobile|Android|iPhone|iPod", re.IGNORECASE)
tablet_re = re.compile(r"iPad|Tablet|PlayBook|Silk", re.IGNORECASE)
for r in rows:
ua = r.user_agent or ""
if tablet_re.search(ua):
result["tablet"] += 1
elif mobile_re.search(ua):
result["mobile"] += 1
elif ua:
result["desktop"] += 1
else:
result["unknown"] += 1
return result
def get_overview_stats(
self,
tenant_id: str,
site_id: str,
days: int = 30,
) -> dict[str, Any]:
"""High-level stats: total consents, active, withdrawn, opt-in rate."""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
q = text("""
SELECT
COUNT(*) FILTER (WHERE action = 'consent_given') AS given,
COUNT(*) FILTER (WHERE action = 'consent_updated') AS updated,
COUNT(*) FILTER (WHERE action IN ('consent_withdrawn', 'consent_revoked')) AS withdrawn,
COUNT(*) AS total
FROM compliance_banner_consent_audit_log
WHERE tenant_id = :tid AND site_id = :sid AND created_at >= :cutoff
""")
r = self.db.execute(q, {"tid": tenant_id, "sid": site_id, "cutoff": cutoff}).fetchone()
total = r.total if r else 0
given = (r.given or 0) + (r.updated or 0) if r else 0
return {
"period_days": days,
"total_interactions": total,
"consents_given": r.given if r else 0,
"consents_updated": r.updated if r else 0,
"consents_withdrawn": r.withdrawn if r else 0,
"opt_in_rate": round(given / total * 100, 1) if total > 0 else 0,
}
@@ -73,6 +73,8 @@ class BannerConsentService:
ip_hash: Optional[str] = None,
banner_config_hash: Optional[str] = None,
consent_version: Optional[int] = None,
vendor_consents: Optional[dict[str, bool]] = None,
user_agent: Optional[str] = None,
) -> None:
entry = BannerConsentAuditLogDB(
tenant_id=tenant_id,
@@ -81,7 +83,9 @@ class BannerConsentService:
site_id=site_id,
device_fingerprint=device_fingerprint,
categories=categories or [],
vendor_consents=vendor_consents or {},
ip_hash=ip_hash,
user_agent=user_agent,
banner_config_hash=banner_config_hash,
consent_version=consent_version,
)
@@ -143,6 +147,7 @@ class BannerConsentService:
ip_address: Optional[str],
user_agent: Optional[str],
consent_string: Optional[str],
vendor_consents: Optional[dict[str, bool]] = None,
) -> dict[str, Any]:
"""Upsert a device consent row for (tenant, site, device_fingerprint).
@@ -171,6 +176,7 @@ class BannerConsentService:
if existing:
existing.categories = categories
existing.vendors = vendors
existing.vendor_consents = vendor_consents or {}
existing.ip_hash = ip_hash
existing.user_agent = user_agent
existing.consent_string = consent_string
@@ -180,6 +186,7 @@ class BannerConsentService:
self._log(
tid, existing.id, "consent_updated", site_id, device_fingerprint,
categories, ip_hash, config_hash, config_ver,
vendor_consents=vendor_consents, user_agent=user_agent,
)
self.db.commit()
self.db.refresh(existing)
@@ -191,6 +198,7 @@ class BannerConsentService:
device_fingerprint=device_fingerprint,
categories=categories,
vendors=vendors,
vendor_consents=vendor_consents or {},
ip_hash=ip_hash,
user_agent=user_agent,
consent_string=consent_string,
@@ -201,6 +209,7 @@ class BannerConsentService:
self._log(
tid, consent.id, "consent_given", site_id, device_fingerprint,
categories, ip_hash, config_hash, config_ver,
vendor_consents=vendor_consents, user_agent=user_agent,
)
self.db.commit()
self.db.refresh(consent)
@@ -0,0 +1,9 @@
-- Migration 113: Granularer Vendor-Level Consent
-- Erweitert Banner-Consents um vendor_consents JSONB ({"vendor_id": true/false})
-- Additiv und rueckwaerts-kompatibel
ALTER TABLE compliance_banner_consents
ADD COLUMN IF NOT EXISTS vendor_consents JSONB DEFAULT '{}';
ALTER TABLE compliance_banner_consent_audit_log
ADD COLUMN IF NOT EXISTS vendor_consents JSONB DEFAULT '{}';
@@ -0,0 +1,8 @@
-- Migration 114: Banner Analytics Enrichment
-- Adds user_agent to audit log for device classification + time-series index
ALTER TABLE compliance_banner_consent_audit_log
ADD COLUMN IF NOT EXISTS user_agent TEXT;
CREATE INDEX IF NOT EXISTS idx_banner_audit_timeseries
ON compliance_banner_consent_audit_log(tenant_id, site_id, action, created_at);