""" 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"

[DOCX-Import: {file.filename}]

Bitte installieren Sie 'mammoth' fuer DOCX-Konvertierung.

" 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()