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:
@@ -0,0 +1,191 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface TimeSeriesPoint {
|
||||||
|
period: string
|
||||||
|
given: number
|
||||||
|
updated: number
|
||||||
|
withdrawn: number
|
||||||
|
total: number
|
||||||
|
opt_in_rate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryStats {
|
||||||
|
[key: string]: { count: number; total: number; rate: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceStats {
|
||||||
|
desktop: number
|
||||||
|
mobile: number
|
||||||
|
tablet: number
|
||||||
|
unknown: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverviewStats {
|
||||||
|
period_days: number
|
||||||
|
total_interactions: number
|
||||||
|
consents_given: number
|
||||||
|
consents_updated: number
|
||||||
|
consents_withdrawn: number
|
||||||
|
opt_in_rate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERIODS = [
|
||||||
|
{ value: 7, label: '7 Tage' },
|
||||||
|
{ value: 30, label: '30 Tage' },
|
||||||
|
{ value: 90, label: '90 Tage' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CAT_COLORS: Record<string, string> = {
|
||||||
|
necessary: '#22c55e',
|
||||||
|
statistics: '#eab308',
|
||||||
|
marketing: '#ef4444',
|
||||||
|
functional: '#3b82f6',
|
||||||
|
preferences: '#8b5cf6',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalyticsDashboard({ siteId }: { siteId?: string }) {
|
||||||
|
const [days, setDays] = useState(30)
|
||||||
|
const [overview, setOverview] = useState<OverviewStats | null>(null)
|
||||||
|
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([])
|
||||||
|
const [categories, setCategories] = useState<CategoryStats>({})
|
||||||
|
const [devices, setDevices] = useState<DeviceStats>({ desktop: 0, mobile: 0, tablet: 0, unknown: 0 })
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const sid = siteId || 'preview-test-site'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
const base = `/api/sdk/v1/compliance/banner/analytics/${sid}`
|
||||||
|
Promise.all([
|
||||||
|
fetch(`${base}/overview?days=${days}`).then(r => r.ok ? r.json() : null),
|
||||||
|
fetch(`${base}/time-series?days=${days}&period=daily`).then(r => r.ok ? r.json() : []),
|
||||||
|
fetch(`${base}/categories?days=${days}`).then(r => r.ok ? r.json() : {}),
|
||||||
|
fetch(`${base}/devices?days=${days}`).then(r => r.ok ? r.json() : {}),
|
||||||
|
]).then(([o, ts, cats, devs]) => {
|
||||||
|
setOverview(o)
|
||||||
|
setTimeSeries(ts || [])
|
||||||
|
setCategories(cats || {})
|
||||||
|
setDevices(devs || { desktop: 0, mobile: 0, tablet: 0, unknown: 0 })
|
||||||
|
}).catch(() => {}).finally(() => setLoading(false))
|
||||||
|
}, [sid, days])
|
||||||
|
|
||||||
|
const deviceTotal = devices.desktop + devices.mobile + devices.tablet + devices.unknown
|
||||||
|
|
||||||
|
if (loading) return <div className="text-center py-12 text-gray-400">Lade Analytik...</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Period Selector */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{PERIODS.map(p => (
|
||||||
|
<button key={p.value} onClick={() => setDays(p.value)}
|
||||||
|
className={`px-3 py-1.5 text-xs rounded-full border transition-colors ${
|
||||||
|
days === p.value ? 'bg-purple-100 border-purple-300 text-purple-700' : 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'
|
||||||
|
}`}>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview KPIs */}
|
||||||
|
{overview && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div className="text-xs text-gray-500">Opt-In-Rate</div>
|
||||||
|
<div className="text-2xl font-bold text-green-600">{overview.opt_in_rate}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div className="text-xs text-gray-500">Einwilligungen</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900">{overview.consents_given}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div className="text-xs text-gray-500">Aktualisiert</div>
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{overview.consents_updated}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div className="text-xs text-gray-500">Widerrufen</div>
|
||||||
|
<div className="text-2xl font-bold text-red-600">{overview.consents_withdrawn}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Time Series (simple bar visualization) */}
|
||||||
|
{timeSeries.length > 0 && (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Opt-In-Rate im Zeitverlauf</h3>
|
||||||
|
<div className="flex items-end gap-1 h-32">
|
||||||
|
{timeSeries.map((pt, i) => {
|
||||||
|
const height = Math.max(pt.opt_in_rate, 2)
|
||||||
|
const date = new Date(pt.period)
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex-1 flex flex-col items-center gap-1 group relative">
|
||||||
|
<div className="w-full bg-purple-500 rounded-t transition-all hover:bg-purple-600"
|
||||||
|
style={{ height: `${height}%` }}
|
||||||
|
title={`${date.toLocaleDateString('de-DE')}: ${pt.opt_in_rate}% (${pt.total} Interaktionen)`}
|
||||||
|
/>
|
||||||
|
{i % Math.max(1, Math.floor(timeSeries.length / 6)) === 0 && (
|
||||||
|
<span className="text-[8px] text-gray-400">{date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Category Acceptance */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Akzeptanz nach Kategorie</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(categories).map(([cat, stats]) => (
|
||||||
|
<div key={cat}>
|
||||||
|
<div className="flex items-center justify-between text-sm mb-1">
|
||||||
|
<span className="text-gray-700 capitalize">{cat}</span>
|
||||||
|
<span className="font-medium text-gray-900">{stats.rate}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full rounded-full transition-all" style={{ width: `${stats.rate}%`, backgroundColor: CAT_COLORS[cat] || '#9ca3af' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{Object.keys(categories).length === 0 && (
|
||||||
|
<p className="text-xs text-gray-400">Noch keine Daten vorhanden</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device Breakdown */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Geraete-Verteilung</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[
|
||||||
|
{ key: 'desktop', label: 'Desktop', color: 'bg-blue-500' },
|
||||||
|
{ key: 'mobile', label: 'Mobile', color: 'bg-green-500' },
|
||||||
|
{ key: 'tablet', label: 'Tablet', color: 'bg-purple-500' },
|
||||||
|
].map(d => {
|
||||||
|
const count = devices[d.key as keyof DeviceStats]
|
||||||
|
const pct = deviceTotal > 0 ? Math.round(count / deviceTotal * 100) : 0
|
||||||
|
return (
|
||||||
|
<div key={d.key}>
|
||||||
|
<div className="flex items-center justify-between text-sm mb-1">
|
||||||
|
<span className="text-gray-700">{d.label}</span>
|
||||||
|
<span className="font-medium text-gray-900">{pct}% ({count})</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full rounded-full ${d.color}`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{deviceTotal === 0 && (
|
||||||
|
<p className="text-xs text-gray-400">Noch keine Geraetedaten vorhanden</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,8 +9,9 @@ import { CategoryCard } from './_components/CategoryCard'
|
|||||||
import { VendorTable } from './_components/VendorTable'
|
import { VendorTable } from './_components/VendorTable'
|
||||||
import { EmbeddableVendorHTML } from './_components/EmbeddableVendorHTML'
|
import { EmbeddableVendorHTML } from './_components/EmbeddableVendorHTML'
|
||||||
import { SiteSelector } from './_components/SiteSelector'
|
import { SiteSelector } from './_components/SiteSelector'
|
||||||
|
import { AnalyticsDashboard } from './_components/AnalyticsDashboard'
|
||||||
|
|
||||||
type BannerTab = 'config' | 'vendors' | 'embed'
|
type BannerTab = 'config' | 'vendors' | 'embed' | 'analytics'
|
||||||
|
|
||||||
export default function CookieBannerPage() {
|
export default function CookieBannerPage() {
|
||||||
const { state } = useSDK()
|
const { state } = useSDK()
|
||||||
@@ -75,6 +76,7 @@ export default function CookieBannerPage() {
|
|||||||
{ id: 'config' as const, label: 'Konfiguration' },
|
{ id: 'config' as const, label: 'Konfiguration' },
|
||||||
{ id: 'vendors' as const, label: 'Verarbeiter' },
|
{ id: 'vendors' as const, label: 'Verarbeiter' },
|
||||||
{ id: 'embed' as const, label: 'Einbettung' },
|
{ id: 'embed' as const, label: 'Einbettung' },
|
||||||
|
{ id: 'analytics' as const, label: 'Analytik' },
|
||||||
]).map(tab => (
|
]).map(tab => (
|
||||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||||
className={`px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
className={`px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||||||
@@ -91,6 +93,9 @@ export default function CookieBannerPage() {
|
|||||||
{/* Tab: Einbettung */}
|
{/* Tab: Einbettung */}
|
||||||
{activeTab === 'embed' && <EmbeddableVendorHTML siteId={activeSiteId || undefined} />}
|
{activeTab === 'embed' && <EmbeddableVendorHTML siteId={activeSiteId || undefined} />}
|
||||||
|
|
||||||
|
{/* Tab: Analytik */}
|
||||||
|
{activeTab === 'analytics' && <AnalyticsDashboard siteId={activeSiteId || undefined} />}
|
||||||
|
|
||||||
{/* Tab: Konfiguration */}
|
{/* Tab: Konfiguration */}
|
||||||
{activeTab !== 'config' ? null : (<>
|
{activeTab !== 'config' ? null : (<>
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ _ROUTER_MODULES = [
|
|||||||
"assertion_routes",
|
"assertion_routes",
|
||||||
"org_role_routes",
|
"org_role_routes",
|
||||||
"document_review_routes",
|
"document_review_routes",
|
||||||
|
"banner_analytics_routes",
|
||||||
]
|
]
|
||||||
|
|
||||||
_loaded_count = 0
|
_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,
|
device_fingerprint=body.device_fingerprint,
|
||||||
categories=body.categories,
|
categories=body.categories,
|
||||||
vendors=body.vendors,
|
vendors=body.vendors,
|
||||||
|
vendor_consents=body.vendor_consents,
|
||||||
ip_address=body.ip_address,
|
ip_address=body.ip_address,
|
||||||
user_agent=body.user_agent,
|
user_agent=body.user_agent,
|
||||||
consent_string=body.consent_string,
|
consent_string=body.consent_string,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class BannerConsentDB(Base):
|
|||||||
device_fingerprint = Column(Text, nullable=False)
|
device_fingerprint = Column(Text, nullable=False)
|
||||||
categories = Column(JSON, default=list)
|
categories = Column(JSON, default=list)
|
||||||
vendors = Column(JSON, default=list)
|
vendors = Column(JSON, default=list)
|
||||||
|
vendor_consents = Column(JSON, default=dict) # {"vendor_id": true/false}
|
||||||
ip_hash = Column(Text)
|
ip_hash = Column(Text)
|
||||||
user_agent = Column(Text)
|
user_agent = Column(Text)
|
||||||
consent_string = Column(Text)
|
consent_string = Column(Text)
|
||||||
@@ -60,7 +61,9 @@ class BannerConsentAuditLogDB(Base):
|
|||||||
site_id = Column(Text, nullable=False)
|
site_id = Column(Text, nullable=False)
|
||||||
device_fingerprint = Column(Text)
|
device_fingerprint = Column(Text)
|
||||||
categories = Column(JSON, default=list)
|
categories = Column(JSON, default=list)
|
||||||
|
vendor_consents = Column(JSON, default=dict)
|
||||||
ip_hash = Column(Text)
|
ip_hash = Column(Text)
|
||||||
|
user_agent = Column(Text)
|
||||||
banner_config_hash = Column(Text)
|
banner_config_hash = Column(Text)
|
||||||
consent_version = Column(Integer)
|
consent_version = Column(Integer)
|
||||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class ConsentCreate(BaseModel):
|
|||||||
device_fingerprint: str
|
device_fingerprint: str
|
||||||
categories: List[str] = []
|
categories: List[str] = []
|
||||||
vendors: List[str] = []
|
vendors: List[str] = []
|
||||||
|
vendor_consents: dict[str, bool] = {}
|
||||||
ip_address: Optional[str] = None
|
ip_address: Optional[str] = None
|
||||||
user_agent: Optional[str] = None
|
user_agent: Optional[str] = None
|
||||||
consent_string: 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,
|
"device_fingerprint": c.device_fingerprint,
|
||||||
"categories": c.categories or [],
|
"categories": c.categories or [],
|
||||||
"vendors": c.vendors or [],
|
"vendors": c.vendors or [],
|
||||||
|
"vendor_consents": c.vendor_consents or {},
|
||||||
"ip_hash": c.ip_hash,
|
"ip_hash": c.ip_hash,
|
||||||
"consent_string": c.consent_string,
|
"consent_string": c.consent_string,
|
||||||
"linked_email": c.linked_email,
|
"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,
|
ip_hash: Optional[str] = None,
|
||||||
banner_config_hash: Optional[str] = None,
|
banner_config_hash: Optional[str] = None,
|
||||||
consent_version: Optional[int] = None,
|
consent_version: Optional[int] = None,
|
||||||
|
vendor_consents: Optional[dict[str, bool]] = None,
|
||||||
|
user_agent: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
entry = BannerConsentAuditLogDB(
|
entry = BannerConsentAuditLogDB(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -81,7 +83,9 @@ class BannerConsentService:
|
|||||||
site_id=site_id,
|
site_id=site_id,
|
||||||
device_fingerprint=device_fingerprint,
|
device_fingerprint=device_fingerprint,
|
||||||
categories=categories or [],
|
categories=categories or [],
|
||||||
|
vendor_consents=vendor_consents or {},
|
||||||
ip_hash=ip_hash,
|
ip_hash=ip_hash,
|
||||||
|
user_agent=user_agent,
|
||||||
banner_config_hash=banner_config_hash,
|
banner_config_hash=banner_config_hash,
|
||||||
consent_version=consent_version,
|
consent_version=consent_version,
|
||||||
)
|
)
|
||||||
@@ -143,6 +147,7 @@ class BannerConsentService:
|
|||||||
ip_address: Optional[str],
|
ip_address: Optional[str],
|
||||||
user_agent: Optional[str],
|
user_agent: Optional[str],
|
||||||
consent_string: Optional[str],
|
consent_string: Optional[str],
|
||||||
|
vendor_consents: Optional[dict[str, bool]] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Upsert a device consent row for (tenant, site, device_fingerprint).
|
"""Upsert a device consent row for (tenant, site, device_fingerprint).
|
||||||
|
|
||||||
@@ -171,6 +176,7 @@ class BannerConsentService:
|
|||||||
if existing:
|
if existing:
|
||||||
existing.categories = categories
|
existing.categories = categories
|
||||||
existing.vendors = vendors
|
existing.vendors = vendors
|
||||||
|
existing.vendor_consents = vendor_consents or {}
|
||||||
existing.ip_hash = ip_hash
|
existing.ip_hash = ip_hash
|
||||||
existing.user_agent = user_agent
|
existing.user_agent = user_agent
|
||||||
existing.consent_string = consent_string
|
existing.consent_string = consent_string
|
||||||
@@ -180,6 +186,7 @@ class BannerConsentService:
|
|||||||
self._log(
|
self._log(
|
||||||
tid, existing.id, "consent_updated", site_id, device_fingerprint,
|
tid, existing.id, "consent_updated", site_id, device_fingerprint,
|
||||||
categories, ip_hash, config_hash, config_ver,
|
categories, ip_hash, config_hash, config_ver,
|
||||||
|
vendor_consents=vendor_consents, user_agent=user_agent,
|
||||||
)
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(existing)
|
self.db.refresh(existing)
|
||||||
@@ -191,6 +198,7 @@ class BannerConsentService:
|
|||||||
device_fingerprint=device_fingerprint,
|
device_fingerprint=device_fingerprint,
|
||||||
categories=categories,
|
categories=categories,
|
||||||
vendors=vendors,
|
vendors=vendors,
|
||||||
|
vendor_consents=vendor_consents or {},
|
||||||
ip_hash=ip_hash,
|
ip_hash=ip_hash,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
consent_string=consent_string,
|
consent_string=consent_string,
|
||||||
@@ -201,6 +209,7 @@ class BannerConsentService:
|
|||||||
self._log(
|
self._log(
|
||||||
tid, consent.id, "consent_given", site_id, device_fingerprint,
|
tid, consent.id, "consent_given", site_id, device_fingerprint,
|
||||||
categories, ip_hash, config_hash, config_ver,
|
categories, ip_hash, config_hash, config_ver,
|
||||||
|
vendor_consents=vendor_consents, user_agent=user_agent,
|
||||||
)
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(consent)
|
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);
|
||||||
Reference in New Issue
Block a user