Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Failing after 30s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 17s
- Ruff: 144 auto-fixes (unused imports, == None → is None), F821/F811/F841 manuell - CVEs: python-multipart>=0.0.22, weasyprint>=68.0, pillow>=12.1.1, npm audit fix (0 vulns) - TS: 5 tote Drafting-Engine-Dateien entfernt, allowed-facts/sanitizer/StepHeader/context fixes - Tests: +104 (ISMS 58, Evidence 18, VVT 14, Generation 14) → 1449 passed - Refactoring: collect_ci_evidence (F→A), row_to_response (E→A), extract_requirements (E→A) - Dead Code: pca-platform, 7 Go-Handler, dsr_api.py, duplicate Schemas entfernt Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
934 lines
29 KiB
Python
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
|
|
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.utcnow()
|
|
|
|
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.utcnow()
|
|
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.utcnow()}
|
|
)
|
|
|
|
|
|
@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.utcnow()
|
|
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.utcnow()
|
|
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()
|