Files
breakpilot-compliance/backend-compliance/compliance/api/legal_document_routes.py
Sharang Parnerkar 3320ef94fc refactor: phase 0 guardrails + phase 1 step 2 (models.py split)
Squash of branch refactor/phase0-guardrails-and-models-split — 4 commits,
81 files, 173/173 pytest green, OpenAPI contract preserved (360 paths /
484 operations).

## Phase 0 — Architecture guardrails

Three defense-in-depth layers to keep the architecture rules enforced
regardless of who opens Claude Code in this repo:

  1. .claude/settings.json PreToolUse hook on Write/Edit blocks any file
     that would exceed the 500-line hard cap. Auto-loads in every Claude
     session in this repo.
  2. scripts/githooks/pre-commit (install via scripts/install-hooks.sh)
     enforces the LOC cap locally, freezes migrations/ without
     [migration-approved], and protects guardrail files without
     [guardrail-change].
  3. .gitea/workflows/ci.yaml gains loc-budget + guardrail-integrity +
     sbom-scan (syft+grype) jobs, adds mypy --strict for the new Python
     packages (compliance/{services,repositories,domain,schemas}), and
     tsc --noEmit for admin-compliance + developer-portal.

Per-language conventions documented in AGENTS.python.md, AGENTS.go.md,
AGENTS.typescript.md at the repo root — layering, tooling, and explicit
"what you may NOT do" lists. Root CLAUDE.md is prepended with the six
non-negotiable rules. Each of the 10 services gets a README.md.

scripts/check-loc.sh enforces soft 300 / hard 500 and surfaces the
current baseline of 205 hard + 161 soft violations so Phases 1-4 can
drain it incrementally. CI gates only CHANGED files in PRs so the
legacy baseline does not block unrelated work.

## Deprecation sweep

47 files. Pydantic V1 regex= -> pattern= (2 sites), class Config ->
ConfigDict in source_policy_router.py (schemas.py intentionally skipped;
it is the Phase 1 Step 3 split target). datetime.utcnow() ->
datetime.now(timezone.utc) everywhere including SQLAlchemy default=
callables. All DB columns already declare timezone=True, so this is a
latent-bug fix at the Python side, not a schema change.

DeprecationWarning count dropped from 158 to 35.

## Phase 1 Step 1 — Contract test harness

tests/contracts/test_openapi_baseline.py diffs the live FastAPI /openapi.json
against tests/contracts/openapi.baseline.json on every test run. Fails on
removed paths, removed status codes, or new required request body fields.
Regenerate only via tests/contracts/regenerate_baseline.py after a
consumer-updated contract change. This is the safety harness for all
subsequent refactor commits.

## Phase 1 Step 2 — models.py split (1466 -> 85 LOC shim)

compliance/db/models.py is decomposed into seven sibling aggregate modules
following the existing repo pattern (dsr_models.py, vvt_models.py, ...):

  regulation_models.py       (134) — Regulation, Requirement
  control_models.py          (279) — Control, Mapping, Evidence, Risk
  ai_system_models.py        (141) — AISystem, AuditExport
  service_module_models.py   (176) — ServiceModule, ModuleRegulation, ModuleRisk
  audit_session_models.py    (177) — AuditSession, AuditSignOff
  isms_governance_models.py  (323) — ISMSScope, Context, Policy, Objective, SoA
  isms_audit_models.py       (468) — Finding, CAPA, MgmtReview, InternalAudit,
                                     AuditTrail, Readiness

models.py becomes an 85-line re-export shim in dependency order so
existing imports continue to work unchanged. Schema is byte-identical:
__tablename__, column definitions, relationship strings, back_populates,
cascade directives all preserved.

All new sibling files are under the 500-line hard cap; largest is
isms_audit_models.py at 468. No file in compliance/db/ now exceeds
the hard cap.

## Phase 1 Step 3 — infrastructure only

backend-compliance/compliance/{schemas,domain,repositories}/ packages
are created as landing zones with docstrings. compliance/domain/
exports DomainError / NotFoundError / ConflictError / ValidationError /
PermissionError — the base classes services will use to raise
domain-level errors instead of HTTPException.

PHASE1_RUNBOOK.md at backend-compliance/PHASE1_RUNBOOK.md documents
the nine-step execution plan for Phase 1: snapshot baseline,
characterization tests, split models.py (this commit), split schemas.py
(next), extract services, extract repositories, mypy --strict, coverage.

## Verification

  backend-compliance/.venv-phase1: uv python install 3.12 + pip -r requirements.txt
  PYTHONPATH=. pytest compliance/tests/ tests/contracts/
  -> 173 passed, 0 failed, 35 warnings, OpenAPI 360/484 unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:18:29 +02:00

934 lines
29 KiB
Python

"""
FastAPI routes for Legal Documents — Rechtliche Texte mit Versionierung und Approval-Workflow.
Extended with: Public endpoints, User Consents, Consent Audit Log, Cookie Categories.
"""
import uuid as uuid_mod
import logging
from datetime import datetime, timezone
from typing import Optional, List, Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Query, Header, UploadFile, File
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func
from classroom_engine.database import get_db
from ..db.legal_document_models import (
LegalDocumentDB,
LegalDocumentVersionDB,
LegalDocumentApprovalDB,
)
from ..db.legal_document_extend_models import (
UserConsentDB,
ConsentAuditLogDB,
CookieCategoryDB,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/legal-documents", tags=["legal-documents"])
DEFAULT_TENANT = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
def _get_tenant(x_tenant_id: Optional[str] = Header(None, alias='X-Tenant-ID')) -> str:
return x_tenant_id or DEFAULT_TENANT
# ============================================================================
# Pydantic Schemas
# ============================================================================
class DocumentCreate(BaseModel):
type: str
name: str
description: Optional[str] = None
mandatory: bool = False
tenant_id: Optional[str] = None
class DocumentResponse(BaseModel):
id: str
tenant_id: Optional[str]
type: str
name: str
description: Optional[str]
mandatory: bool
created_at: datetime
updated_at: Optional[datetime]
class VersionCreate(BaseModel):
document_id: str
version: str
language: str = 'de'
title: str
content: str
summary: Optional[str] = None
created_by: Optional[str] = None
class VersionUpdate(BaseModel):
title: Optional[str] = None
content: Optional[str] = None
summary: Optional[str] = None
version: Optional[str] = None
language: Optional[str] = None
class VersionResponse(BaseModel):
id: str
document_id: str
version: str
language: str
title: str
content: str
summary: Optional[str]
status: str
created_by: Optional[str]
approved_by: Optional[str]
approved_at: Optional[datetime]
rejection_reason: Optional[str]
created_at: datetime
updated_at: Optional[datetime]
class ApprovalHistoryEntry(BaseModel):
id: str
version_id: str
action: str
approver: Optional[str]
comment: Optional[str]
created_at: datetime
class ActionRequest(BaseModel):
approver: Optional[str] = None
comment: Optional[str] = None
# ============================================================================
# Helpers
# ============================================================================
def _doc_to_response(doc: LegalDocumentDB) -> DocumentResponse:
return DocumentResponse(
id=str(doc.id),
tenant_id=doc.tenant_id,
type=doc.type,
name=doc.name,
description=doc.description,
mandatory=doc.mandatory or False,
created_at=doc.created_at,
updated_at=doc.updated_at,
)
def _version_to_response(v: LegalDocumentVersionDB) -> VersionResponse:
return VersionResponse(
id=str(v.id),
document_id=str(v.document_id),
version=v.version,
language=v.language or 'de',
title=v.title,
content=v.content,
summary=v.summary,
status=v.status or 'draft',
created_by=v.created_by,
approved_by=v.approved_by,
approved_at=v.approved_at,
rejection_reason=v.rejection_reason,
created_at=v.created_at,
updated_at=v.updated_at,
)
def _log_approval(
db: Session,
version_id: Any,
action: str,
approver: Optional[str] = None,
comment: Optional[str] = None,
) -> LegalDocumentApprovalDB:
entry = LegalDocumentApprovalDB(
version_id=version_id,
action=action,
approver=approver,
comment=comment,
)
db.add(entry)
return entry
# ============================================================================
# Documents
# ============================================================================
@router.get("/documents", response_model=Dict[str, Any])
async def list_documents(
tenant_id: Optional[str] = Query(None),
type: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""List all legal documents, optionally filtered by tenant or type."""
query = db.query(LegalDocumentDB)
if tenant_id:
query = query.filter(LegalDocumentDB.tenant_id == tenant_id)
if type:
query = query.filter(LegalDocumentDB.type == type)
docs = query.order_by(LegalDocumentDB.created_at.desc()).all()
return {"documents": [_doc_to_response(d).dict() for d in docs]}
@router.post("/documents", response_model=DocumentResponse, status_code=201)
async def create_document(
request: DocumentCreate,
db: Session = Depends(get_db),
):
"""Create a new legal document type."""
doc = LegalDocumentDB(
tenant_id=request.tenant_id,
type=request.type,
name=request.name,
description=request.description,
mandatory=request.mandatory,
)
db.add(doc)
db.commit()
db.refresh(doc)
return _doc_to_response(doc)
@router.get("/documents/{document_id}", response_model=DocumentResponse)
async def get_document(document_id: str, db: Session = Depends(get_db)):
"""Get a single legal document by ID."""
doc = db.query(LegalDocumentDB).filter(LegalDocumentDB.id == document_id).first()
if not doc:
raise HTTPException(status_code=404, detail=f"Document {document_id} not found")
return _doc_to_response(doc)
@router.delete("/documents/{document_id}", status_code=204)
async def delete_document(document_id: str, db: Session = Depends(get_db)):
"""Delete a legal document and all its versions."""
doc = db.query(LegalDocumentDB).filter(LegalDocumentDB.id == document_id).first()
if not doc:
raise HTTPException(status_code=404, detail=f"Document {document_id} not found")
db.delete(doc)
db.commit()
@router.get("/documents/{document_id}/versions", response_model=List[VersionResponse])
async def list_versions(document_id: str, db: Session = Depends(get_db)):
"""List all versions for a legal document."""
doc = db.query(LegalDocumentDB).filter(LegalDocumentDB.id == document_id).first()
if not doc:
raise HTTPException(status_code=404, detail=f"Document {document_id} not found")
versions = (
db.query(LegalDocumentVersionDB)
.filter(LegalDocumentVersionDB.document_id == document_id)
.order_by(LegalDocumentVersionDB.created_at.desc())
.all()
)
return [_version_to_response(v) for v in versions]
# ============================================================================
# Versions
# ============================================================================
@router.post("/versions", response_model=VersionResponse, status_code=201)
async def create_version(
request: VersionCreate,
db: Session = Depends(get_db),
):
"""Create a new version for a legal document."""
doc = db.query(LegalDocumentDB).filter(LegalDocumentDB.id == request.document_id).first()
if not doc:
raise HTTPException(status_code=404, detail=f"Document {request.document_id} not found")
version = LegalDocumentVersionDB(
document_id=request.document_id,
version=request.version,
language=request.language,
title=request.title,
content=request.content,
summary=request.summary,
created_by=request.created_by,
status='draft',
)
db.add(version)
db.flush()
_log_approval(db, version.id, action='created', approver=request.created_by)
db.commit()
db.refresh(version)
return _version_to_response(version)
@router.put("/versions/{version_id}", response_model=VersionResponse)
async def update_version(
version_id: str,
request: VersionUpdate,
db: Session = Depends(get_db),
):
"""Update a draft legal document version."""
version = db.query(LegalDocumentVersionDB).filter(LegalDocumentVersionDB.id == version_id).first()
if not version:
raise HTTPException(status_code=404, detail=f"Version {version_id} not found")
if version.status not in ('draft', 'rejected'):
raise HTTPException(status_code=400, detail=f"Only draft/rejected versions can be edited (current: {version.status})")
for field, value in request.dict(exclude_none=True).items():
setattr(version, field, value)
version.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(version)
return _version_to_response(version)
@router.get("/versions/{version_id}", response_model=VersionResponse)
async def get_version(version_id: str, db: Session = Depends(get_db)):
"""Get a single version by ID."""
v = db.query(LegalDocumentVersionDB).filter(LegalDocumentVersionDB.id == version_id).first()
if not v:
raise HTTPException(status_code=404, detail=f"Version {version_id} not found")
return _version_to_response(v)
@router.post("/versions/upload-word", response_model=Dict[str, Any])
async def upload_word(file: UploadFile = File(...)):
"""Convert DOCX to HTML using mammoth (if available) or return raw text."""
if not file.filename or not file.filename.lower().endswith('.docx'):
raise HTTPException(status_code=400, detail="Only .docx files are supported")
content_bytes = await file.read()
html_content = ""
try:
import mammoth # type: ignore
import io
result = mammoth.convert_to_html(io.BytesIO(content_bytes))
html_content = result.value
except ImportError:
# Fallback: return placeholder if mammoth not installed
html_content = f"<p>[DOCX-Import: {file.filename}]</p><p>Bitte installieren Sie 'mammoth' fuer DOCX-Konvertierung.</p>"
return {"html": html_content, "filename": file.filename}
# ============================================================================
# Approval Workflow Actions
# ============================================================================
def _transition(
db: Session,
version_id: str,
from_statuses: List[str],
to_status: str,
action: str,
approver: Optional[str],
comment: Optional[str],
extra_updates: Optional[Dict] = None,
) -> VersionResponse:
version = db.query(LegalDocumentVersionDB).filter(LegalDocumentVersionDB.id == version_id).first()
if not version:
raise HTTPException(status_code=404, detail=f"Version {version_id} not found")
if version.status not in from_statuses:
raise HTTPException(
status_code=400,
detail=f"Cannot perform '{action}' on version with status '{version.status}' (expected: {from_statuses})"
)
version.status = to_status
version.updated_at = datetime.now(timezone.utc)
if extra_updates:
for k, v in extra_updates.items():
setattr(version, k, v)
_log_approval(db, version.id, action=action, approver=approver, comment=comment)
db.commit()
db.refresh(version)
return _version_to_response(version)
@router.post("/versions/{version_id}/submit-review", response_model=VersionResponse)
async def submit_review(
version_id: str,
request: ActionRequest,
db: Session = Depends(get_db),
):
"""Submit a draft version for review."""
return _transition(db, version_id, ['draft', 'rejected'], 'review', 'submitted', request.approver, request.comment)
@router.post("/versions/{version_id}/approve", response_model=VersionResponse)
async def approve_version(
version_id: str,
request: ActionRequest,
db: Session = Depends(get_db),
):
"""Approve a version under review."""
return _transition(
db, version_id, ['review'], 'approved', 'approved',
request.approver, request.comment,
extra_updates={'approved_by': request.approver, 'approved_at': datetime.now(timezone.utc)}
)
@router.post("/versions/{version_id}/reject", response_model=VersionResponse)
async def reject_version(
version_id: str,
request: ActionRequest,
db: Session = Depends(get_db),
):
"""Reject a version under review."""
return _transition(
db, version_id, ['review'], 'rejected', 'rejected',
request.approver, request.comment,
extra_updates={'rejection_reason': request.comment}
)
@router.post("/versions/{version_id}/publish", response_model=VersionResponse)
async def publish_version(
version_id: str,
request: ActionRequest,
db: Session = Depends(get_db),
):
"""Publish an approved version."""
return _transition(db, version_id, ['approved'], 'published', 'published', request.approver, request.comment)
# ============================================================================
# Approval History
# ============================================================================
@router.get("/versions/{version_id}/approval-history", response_model=List[ApprovalHistoryEntry])
async def get_approval_history(version_id: str, db: Session = Depends(get_db)):
"""Get the full approval audit trail for a version."""
version = db.query(LegalDocumentVersionDB).filter(LegalDocumentVersionDB.id == version_id).first()
if not version:
raise HTTPException(status_code=404, detail=f"Version {version_id} not found")
entries = (
db.query(LegalDocumentApprovalDB)
.filter(LegalDocumentApprovalDB.version_id == version_id)
.order_by(LegalDocumentApprovalDB.created_at.asc())
.all()
)
return [
ApprovalHistoryEntry(
id=str(e.id),
version_id=str(e.version_id),
action=e.action,
approver=e.approver,
comment=e.comment,
created_at=e.created_at,
)
for e in entries
]
# ============================================================================
# Extended Schemas
# ============================================================================
class UserConsentCreate(BaseModel):
user_id: str
document_id: str
document_version_id: Optional[str] = None
document_type: str
consented: bool = True
ip_address: Optional[str] = None
user_agent: Optional[str] = None
class CookieCategoryCreate(BaseModel):
name_de: str
name_en: Optional[str] = None
description_de: Optional[str] = None
description_en: Optional[str] = None
is_required: bool = False
sort_order: int = 0
class CookieCategoryUpdate(BaseModel):
name_de: Optional[str] = None
name_en: Optional[str] = None
description_de: Optional[str] = None
description_en: Optional[str] = None
is_required: Optional[bool] = None
sort_order: Optional[int] = None
is_active: Optional[bool] = None
# ============================================================================
# Extended Helpers
# ============================================================================
def _log_consent_audit(
db: Session,
tenant_id,
action: str,
entity_type: str,
entity_id=None,
user_id: Optional[str] = None,
details: Optional[dict] = None,
ip_address: Optional[str] = None,
):
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:
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:
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,
}
# ============================================================================
# Public Endpoints (for end users)
# ============================================================================
@router.get("/public")
async def list_public_documents(
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Active documents for end-user display."""
docs = (
db.query(LegalDocumentDB)
.filter(LegalDocumentDB.tenant_id == tenant_id)
.order_by(LegalDocumentDB.created_at.desc())
.all()
)
result = []
for doc in docs:
# Find latest published version
published = (
db.query(LegalDocumentVersionDB)
.filter(
LegalDocumentVersionDB.document_id == doc.id,
LegalDocumentVersionDB.status == "published",
)
.order_by(LegalDocumentVersionDB.created_at.desc())
.first()
)
if published:
result.append({
"id": str(doc.id),
"type": doc.type,
"name": doc.name,
"version": published.version,
"title": published.title,
"content": published.content,
"language": published.language,
"published_at": published.approved_at.isoformat() if published.approved_at else None,
})
return result
@router.get("/public/{document_type}/latest")
async def get_latest_published(
document_type: str,
language: str = Query("de"),
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Get the latest published version of a document type."""
doc = (
db.query(LegalDocumentDB)
.filter(
LegalDocumentDB.tenant_id == tenant_id,
LegalDocumentDB.type == document_type,
)
.first()
)
if not doc:
raise HTTPException(status_code=404, detail=f"No document of type '{document_type}' found")
version = (
db.query(LegalDocumentVersionDB)
.filter(
LegalDocumentVersionDB.document_id == doc.id,
LegalDocumentVersionDB.status == "published",
LegalDocumentVersionDB.language == language,
)
.order_by(LegalDocumentVersionDB.created_at.desc())
.first()
)
if not version:
raise HTTPException(status_code=404, detail=f"No published version for type '{document_type}' in language '{language}'")
return {
"document_id": str(doc.id),
"type": doc.type,
"name": doc.name,
"version_id": str(version.id),
"version": version.version,
"title": version.title,
"content": version.content,
"language": version.language,
}
# ============================================================================
# User Consents
# ============================================================================
@router.post("/consents")
async def record_consent(
body: UserConsentCreate,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Record user consent for a legal document."""
tid = uuid_mod.UUID(tenant_id)
doc_id = uuid_mod.UUID(body.document_id)
doc = db.query(LegalDocumentDB).filter(LegalDocumentDB.id == doc_id).first()
if not doc:
raise HTTPException(status_code=404, detail="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,
)
db.add(consent)
db.flush()
_log_consent_audit(
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,
)
db.commit()
db.refresh(consent)
return _consent_to_dict(consent)
@router.get("/consents/my")
async def get_my_consents(
user_id: str = Query(...),
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Get all consents for a specific user."""
tid = uuid_mod.UUID(tenant_id)
consents = (
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]
@router.get("/consents/check/{document_type}")
async def check_consent(
document_type: str,
user_id: str = Query(...),
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Check if user has active consent for a document type."""
tid = uuid_mod.UUID(tenant_id)
consent = (
db.query(UserConsentDB)
.filter(
UserConsentDB.tenant_id == tid,
UserConsentDB.user_id == user_id,
UserConsentDB.document_type == document_type,
UserConsentDB.consented,
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,
}
@router.delete("/consents/{consent_id}")
async def withdraw_consent(
consent_id: str,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Withdraw a consent (DSGVO Art. 7 Abs. 3)."""
tid = uuid_mod.UUID(tenant_id)
try:
cid = uuid_mod.UUID(consent_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid consent ID")
consent = db.query(UserConsentDB).filter(
UserConsentDB.id == cid,
UserConsentDB.tenant_id == tid,
).first()
if not consent:
raise HTTPException(status_code=404, detail="Consent not found")
if consent.withdrawn_at:
raise HTTPException(status_code=400, detail="Consent already withdrawn")
consent.withdrawn_at = datetime.now(timezone.utc)
consent.consented = False
_log_consent_audit(
db, tid, "consent_withdrawn", "user_consent",
entity_id=cid, user_id=consent.user_id,
details={"document_type": consent.document_type},
)
db.commit()
db.refresh(consent)
return _consent_to_dict(consent)
# ============================================================================
# Consent Statistics
# ============================================================================
@router.get("/stats/consents")
async def get_consent_stats(
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Consent statistics for dashboard."""
tid = uuid_mod.UUID(tenant_id)
base = db.query(UserConsentDB).filter(UserConsentDB.tenant_id == tid)
total = base.count()
active = base.filter(
UserConsentDB.consented,
UserConsentDB.withdrawn_at is None,
).count()
withdrawn = base.filter(UserConsentDB.withdrawn_at is not None).count()
# By document type
by_type = {}
type_counts = (
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
unique_users = (
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
# ============================================================================
@router.get("/audit-log")
async def get_audit_log(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
action: Optional[str] = Query(None),
entity_type: Optional[str] = Query(None),
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Consent audit trail (paginated)."""
tid = uuid_mod.UUID(tenant_id)
query = 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 CRUD
# ============================================================================
@router.get("/cookie-categories")
async def list_cookie_categories(
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""List all cookie categories."""
tid = uuid_mod.UUID(tenant_id)
cats = (
db.query(CookieCategoryDB)
.filter(CookieCategoryDB.tenant_id == tid)
.order_by(CookieCategoryDB.sort_order)
.all()
)
return [_cookie_cat_to_dict(c) for c in cats]
@router.post("/cookie-categories")
async def create_cookie_category(
body: CookieCategoryCreate,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Create a cookie category."""
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,
)
db.add(cat)
db.commit()
db.refresh(cat)
return _cookie_cat_to_dict(cat)
@router.put("/cookie-categories/{category_id}")
async def update_cookie_category(
category_id: str,
body: CookieCategoryUpdate,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Update a cookie category."""
tid = uuid_mod.UUID(tenant_id)
try:
cid = uuid_mod.UUID(category_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid category ID")
cat = db.query(CookieCategoryDB).filter(
CookieCategoryDB.id == cid,
CookieCategoryDB.tenant_id == tid,
).first()
if not cat:
raise HTTPException(status_code=404, detail="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)
db.commit()
db.refresh(cat)
return _cookie_cat_to_dict(cat)
@router.delete("/cookie-categories/{category_id}", status_code=204)
async def delete_cookie_category(
category_id: str,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Delete a cookie category."""
tid = uuid_mod.UUID(tenant_id)
try:
cid = uuid_mod.UUID(category_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid category ID")
cat = db.query(CookieCategoryDB).filter(
CookieCategoryDB.id == cid,
CookieCategoryDB.tenant_id == tid,
).first()
if not cat:
raise HTTPException(status_code=404, detail="Cookie category not found")
db.delete(cat)
db.commit()