# mypy: disable-error-code="arg-type,assignment,union-attr" # SQLAlchemy 1.x Column() descriptors are Column[T] statically, T at runtime. """ Legal document consent service — user consents, audit log, cookie categories, and consent statistics. Phase 1 Step 4: extracted from ``compliance.api.legal_document_routes``. Document/version/approval workflow lives in ``compliance.services.legal_document_service.LegalDocumentService``. """ import uuid as uuid_mod from datetime import datetime, timezone from typing import Any, Optional from sqlalchemy import func from sqlalchemy.orm import Session from compliance.db.legal_document_extend_models import ( ConsentAuditLogDB, CookieCategoryDB, UserConsentDB, ) from compliance.db.legal_document_models import LegalDocumentDB from compliance.domain import NotFoundError, ValidationError from compliance.schemas.legal_document import ( CookieCategoryCreate, CookieCategoryUpdate, UserConsentCreate, ) # ============================================================================ # Serialisation helpers # ============================================================================ def _log_consent_audit( db: Session, tenant_id: Any, action: str, entity_type: str, entity_id: Any = None, user_id: Optional[str] = None, details: Optional[dict[str, Any]] = None, ip_address: Optional[str] = None, ) -> ConsentAuditLogDB: entry = ConsentAuditLogDB( tenant_id=tenant_id, action=action, entity_type=entity_type, entity_id=entity_id, user_id=user_id, details=details or {}, ip_address=ip_address, ) db.add(entry) return entry def _consent_to_dict(c: UserConsentDB) -> dict[str, Any]: return { "id": str(c.id), "tenant_id": str(c.tenant_id), "user_id": c.user_id, "document_id": str(c.document_id), "document_version_id": str(c.document_version_id) if c.document_version_id else None, "document_type": c.document_type, "consented": c.consented, "ip_address": c.ip_address, "user_agent": c.user_agent, "consented_at": c.consented_at.isoformat() if c.consented_at else None, "withdrawn_at": c.withdrawn_at.isoformat() if c.withdrawn_at else None, "created_at": c.created_at.isoformat() if c.created_at else None, } def _cookie_cat_to_dict(c: CookieCategoryDB) -> dict[str, Any]: return { "id": str(c.id), "tenant_id": str(c.tenant_id), "name_de": c.name_de, "name_en": c.name_en, "description_de": c.description_de, "description_en": c.description_en, "is_required": c.is_required, "sort_order": c.sort_order, "is_active": c.is_active, "created_at": c.created_at.isoformat() if c.created_at else None, "updated_at": c.updated_at.isoformat() if c.updated_at else None, } # ============================================================================ # Service # ============================================================================ class LegalDocumentConsentService: """Business logic for user consents, audit log, and cookie categories.""" def __init__(self, db: Session) -> None: self.db = db # ------------------------------------------------------------------ # User consents # ------------------------------------------------------------------ def record_consent( self, tenant_id: str, body: UserConsentCreate ) -> dict[str, Any]: tid = uuid_mod.UUID(tenant_id) doc_id = uuid_mod.UUID(body.document_id) doc = ( self.db.query(LegalDocumentDB) .filter(LegalDocumentDB.id == doc_id) .first() ) if not doc: raise NotFoundError("Document not found") consent = UserConsentDB( tenant_id=tid, user_id=body.user_id, document_id=doc_id, document_version_id=( uuid_mod.UUID(body.document_version_id) if body.document_version_id else None ), document_type=body.document_type, consented=body.consented, ip_address=body.ip_address, user_agent=body.user_agent, ) self.db.add(consent) self.db.flush() _log_consent_audit( self.db, tid, "consent_given", "user_consent", entity_id=consent.id, user_id=body.user_id, details={ "document_type": body.document_type, "document_id": body.document_id, }, ip_address=body.ip_address, ) self.db.commit() self.db.refresh(consent) return _consent_to_dict(consent) def get_my_consents( self, tenant_id: str, user_id: str ) -> list[dict[str, Any]]: tid = uuid_mod.UUID(tenant_id) consents = ( self.db.query(UserConsentDB) .filter( UserConsentDB.tenant_id == tid, UserConsentDB.user_id == user_id, UserConsentDB.withdrawn_at.is_(None), ) .order_by(UserConsentDB.consented_at.desc()) .all() ) return [_consent_to_dict(c) for c in consents] def check_consent( self, tenant_id: str, document_type: str, user_id: str ) -> dict[str, Any]: tid = uuid_mod.UUID(tenant_id) consent = ( self.db.query(UserConsentDB) .filter( UserConsentDB.tenant_id == tid, UserConsentDB.user_id == user_id, UserConsentDB.document_type == document_type, UserConsentDB.consented.is_(True), UserConsentDB.withdrawn_at.is_(None), ) .order_by(UserConsentDB.consented_at.desc()) .first() ) return { "has_consent": consent is not None, "consent": _consent_to_dict(consent) if consent else None, } def withdraw_consent( self, tenant_id: str, consent_id: str ) -> dict[str, Any]: tid = uuid_mod.UUID(tenant_id) try: cid = uuid_mod.UUID(consent_id) except ValueError: raise ValidationError("Invalid consent ID") consent = ( self.db.query(UserConsentDB) .filter( UserConsentDB.id == cid, UserConsentDB.tenant_id == tid, ) .first() ) if not consent: raise NotFoundError("Consent not found") if consent.withdrawn_at: raise ValidationError("Consent already withdrawn") consent.withdrawn_at = datetime.now(timezone.utc) consent.consented = False _log_consent_audit( self.db, tid, "consent_withdrawn", "user_consent", entity_id=cid, user_id=consent.user_id, details={"document_type": consent.document_type}, ) self.db.commit() self.db.refresh(consent) return _consent_to_dict(consent) # ------------------------------------------------------------------ # Consent statistics # ------------------------------------------------------------------ def get_consent_stats(self, tenant_id: str) -> dict[str, Any]: tid = uuid_mod.UUID(tenant_id) base = self.db.query(UserConsentDB).filter( UserConsentDB.tenant_id == tid ) total = base.count() active = base.filter( UserConsentDB.consented.is_(True), UserConsentDB.withdrawn_at.is_(None), ).count() withdrawn = base.filter( UserConsentDB.withdrawn_at.isnot(None), ).count() by_type: dict[str, int] = {} type_counts = ( self.db.query(UserConsentDB.document_type, func.count(UserConsentDB.id)) .filter(UserConsentDB.tenant_id == tid) .group_by(UserConsentDB.document_type) .all() ) for dtype, count in type_counts: by_type[dtype] = count unique_users = ( self.db.query(func.count(func.distinct(UserConsentDB.user_id))) .filter(UserConsentDB.tenant_id == tid) .scalar() ) or 0 return { "total": total, "active": active, "withdrawn": withdrawn, "unique_users": unique_users, "by_type": by_type, } # ------------------------------------------------------------------ # Audit log # ------------------------------------------------------------------ def get_audit_log( self, tenant_id: str, limit: int = 50, offset: int = 0, action: Optional[str] = None, entity_type: Optional[str] = None, ) -> dict[str, Any]: tid = uuid_mod.UUID(tenant_id) query = self.db.query(ConsentAuditLogDB).filter( ConsentAuditLogDB.tenant_id == tid ) if action: query = query.filter(ConsentAuditLogDB.action == action) if entity_type: query = query.filter(ConsentAuditLogDB.entity_type == entity_type) total = query.count() entries = ( query.order_by(ConsentAuditLogDB.created_at.desc()) .offset(offset) .limit(limit) .all() ) return { "entries": [ { "id": str(e.id), "action": e.action, "entity_type": e.entity_type, "entity_id": str(e.entity_id) if e.entity_id else None, "user_id": e.user_id, "details": e.details or {}, "ip_address": e.ip_address, "created_at": ( e.created_at.isoformat() if e.created_at else None ), } for e in entries ], "total": total, "limit": limit, "offset": offset, } # ------------------------------------------------------------------ # Cookie categories # ------------------------------------------------------------------ def list_cookie_categories( self, tenant_id: str ) -> list[dict[str, Any]]: tid = uuid_mod.UUID(tenant_id) cats = ( self.db.query(CookieCategoryDB) .filter(CookieCategoryDB.tenant_id == tid) .order_by(CookieCategoryDB.sort_order) .all() ) return [_cookie_cat_to_dict(c) for c in cats] def create_cookie_category( self, tenant_id: str, body: CookieCategoryCreate ) -> dict[str, Any]: tid = uuid_mod.UUID(tenant_id) cat = CookieCategoryDB( tenant_id=tid, 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 _cookie_cat_to_dict(cat) def update_cookie_category( self, tenant_id: str, category_id: str, body: CookieCategoryUpdate ) -> dict[str, Any]: tid = uuid_mod.UUID(tenant_id) try: cid = uuid_mod.UUID(category_id) except ValueError: raise ValidationError("Invalid category ID") cat = ( self.db.query(CookieCategoryDB) .filter( CookieCategoryDB.id == cid, CookieCategoryDB.tenant_id == tid, ) .first() ) if not cat: raise NotFoundError("Cookie category not found") for field in [ "name_de", "name_en", "description_de", "description_en", "is_required", "sort_order", "is_active", ]: val = getattr(body, field, None) if val is not None: setattr(cat, field, val) cat.updated_at = datetime.now(timezone.utc) self.db.commit() self.db.refresh(cat) return _cookie_cat_to_dict(cat) def delete_cookie_category( self, tenant_id: str, category_id: str ) -> None: tid = uuid_mod.UUID(tenant_id) try: cid = uuid_mod.UUID(category_id) except ValueError: raise ValidationError("Invalid category ID") cat = ( self.db.query(CookieCategoryDB) .filter( CookieCategoryDB.id == cid, CookieCategoryDB.tenant_id == tid, ) .first() ) if not cat: raise NotFoundError("Cookie category not found") self.db.delete(cat) self.db.commit()