# mypy: disable-error-code="arg-type,assignment" """ Banner DSR service — bridges device-based banner consents with user-based DSR (Data Subject Request) processing. Phase 3: Email linking allows correlating anonymous device fingerprints with user emails (e.g. after newsletter signup or account creation). This enables Art. 15 (access), Art. 17 (erasure), and Art. 20 (portability) requests to include/delete banner consent data. Phase 4: Consent sync bridges banner consents (device-based) with Einwilligungen (user-based) for unified consent management. """ import uuid from datetime import datetime, timezone from typing import Any, Optional from sqlalchemy.orm import Session from compliance.db.banner_models import ( BannerConsentAuditLogDB, BannerConsentDB, ) from compliance.db.einwilligungen_models import ( EinwilligungenConsentDB, EinwilligungenConsentHistoryDB, ) from compliance.domain import NotFoundError, ValidationError from compliance.services._banner_serializers import consent_to_dict class BannerDSRService: """Email linking + DSR integration for banner consents.""" def __init__(self, db: Session) -> None: self.db = db # ------------------------------------------------------------------ # Phase 3: Email linking # ------------------------------------------------------------------ def link_email( self, tenant_id: str, site_id: str, device_fingerprint: str, email: str, ) -> dict[str, Any]: """Link an email address to a device fingerprint's consent. Typically called after newsletter signup, account creation, or login — any point where the user's email becomes known. """ tid = uuid.UUID(tenant_id) if not email or "@" not in email: raise ValidationError("Invalid email address") consent = ( self.db.query(BannerConsentDB) .filter( BannerConsentDB.tenant_id == tid, BannerConsentDB.site_id == site_id, BannerConsentDB.device_fingerprint == device_fingerprint, ) .first() ) if not consent: raise NotFoundError("No consent found for this device") consent.linked_email = email.lower().strip() consent.updated_at = datetime.now(timezone.utc) # Audit the linking self.db.add(BannerConsentAuditLogDB( tenant_id=tid, consent_id=consent.id, action="email_linked", site_id=site_id, device_fingerprint=device_fingerprint, categories=consent.categories or [], )) self.db.commit() self.db.refresh(consent) return consent_to_dict(consent) def get_consents_by_email( self, tenant_id: str, email: str, ) -> list[dict[str, Any]]: """Find all banner consents linked to an email (Art. 15 DSGVO).""" tid = uuid.UUID(tenant_id) consents = ( self.db.query(BannerConsentDB) .filter( BannerConsentDB.tenant_id == tid, BannerConsentDB.linked_email == email.lower().strip(), ) .all() ) return [consent_to_dict(c) for c in consents] def delete_consents_by_email( self, tenant_id: str, email: str, ) -> dict[str, Any]: """Delete all banner consents for an email (Art. 17 DSGVO erasure). Creates audit log entries before deletion for compliance proof. """ tid = uuid.UUID(tenant_id) normalized = email.lower().strip() consents = ( self.db.query(BannerConsentDB) .filter( BannerConsentDB.tenant_id == tid, BannerConsentDB.linked_email == normalized, ) .all() ) deleted = 0 for c in consents: self.db.add(BannerConsentAuditLogDB( tenant_id=tid, consent_id=c.id, action="consent_deleted_dsr", site_id=c.site_id, device_fingerprint=c.device_fingerprint, categories=c.categories or [], )) self.db.delete(c) deleted += 1 self.db.commit() return {"deleted": deleted, "email": normalized} def export_for_dsr( self, tenant_id: str, email: str, ) -> dict[str, Any]: """Export all banner consent data for a DSR (Art. 15/20 DSGVO). Returns consent records + audit trail for the email. """ tid = uuid.UUID(tenant_id) normalized = email.lower().strip() consents = ( self.db.query(BannerConsentDB) .filter( BannerConsentDB.tenant_id == tid, BannerConsentDB.linked_email == normalized, ) .all() ) consent_ids = [c.id for c in consents] audit = [] if consent_ids: audit = ( self.db.query(BannerConsentAuditLogDB) .filter(BannerConsentAuditLogDB.consent_id.in_(consent_ids)) .order_by(BannerConsentAuditLogDB.created_at.desc()) .all() ) return { "email": normalized, "banner_consents": [consent_to_dict(c) for c in consents], "audit_trail": [ { "id": str(a.id), "action": a.action, "site_id": a.site_id, "categories": a.categories or [], "banner_config_hash": a.banner_config_hash, "consent_version": a.consent_version, "created_at": a.created_at.isoformat() if a.created_at else None, } for a in audit ], } # ------------------------------------------------------------------ # Phase 4: Consent sync (Banner ↔ Einwilligungen) # ------------------------------------------------------------------ def sync_consent_to_einwilligungen( self, tenant_id: str, device_fingerprint: str, email: str, site_id: str, ) -> dict[str, Any]: """Sync banner consent categories to user-based Einwilligungen. Called when a user logs in and their email becomes known. Creates/updates EinwilligungenConsent entries for each accepted banner category, bridging device-based and user-based systems. """ 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: raise NotFoundError("No banner consent found for this device") # Link email if not already linked normalized = email.lower().strip() if not consent.linked_email: consent.linked_email = normalized consent.updated_at = datetime.now(timezone.utc) synced = 0 categories = consent.categories or [] for cat in categories: data_point_id = f"banner_{cat}" existing = ( self.db.query(EinwilligungenConsentDB) .filter( EinwilligungenConsentDB.tenant_id == tid, EinwilligungenConsentDB.user_id == normalized, EinwilligungenConsentDB.data_point_id == data_point_id, ) .first() ) now = datetime.now(timezone.utc) if existing: if not existing.granted: existing.granted = True existing.granted_at = now existing.revoked_at = None existing.source = "banner_sync" self.db.add(EinwilligungenConsentHistoryDB( consent_id=existing.id, tenant_id=tid, action="granted", source="banner_sync", )) synced += 1 else: new_consent = EinwilligungenConsentDB( tenant_id=tid, user_id=normalized, data_point_id=data_point_id, granted=True, granted_at=now, consent_version="1", source="banner_sync", ) self.db.add(new_consent) self.db.flush() self.db.add(EinwilligungenConsentHistoryDB( consent_id=new_consent.id, tenant_id=tid, action="granted", source="banner_sync", )) synced += 1 self.db.commit() return { "synced": synced, "categories": categories, "email": normalized, }