Extract consent, audit log, cookie category, and consent stats endpoints from legal_document_routes into LegalDocumentConsentService. The route file is now a thin handler layer delegating to LegalDocumentService and LegalDocumentConsentService with translate_domain_errors(). Legacy helpers (_doc_to_response, _version_to_response, _transition, _log_approval) and schemas are re-exported for existing tests. Two transition tests updated to expect domain errors instead of HTTPException. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
416 lines
13 KiB
Python
416 lines
13 KiB
Python
# 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()
|