""" FastAPI routes for Legal Documents — Rechtliche Texte mit Versionierung und Approval-Workflow. Endpoints: GET /legal-documents/documents — Liste aller Dokumente POST /legal-documents/documents — Dokument erstellen GET /legal-documents/documents/{id}/versions — Versionen eines Dokuments POST /legal-documents/versions — Neue Version erstellen PUT /legal-documents/versions/{id} — Version aktualisieren POST /legal-documents/versions/upload-word — DOCX → HTML POST /legal-documents/versions/{id}/submit-review — Status: draft → review POST /legal-documents/versions/{id}/approve — Status: review → approved POST /legal-documents/versions/{id}/reject — Status: review → rejected POST /legal-documents/versions/{id}/publish — Status: approved → published GET /legal-documents/versions/{id}/approval-history — Approval-Audit-Trail """ import logging from datetime import datetime from typing import Optional, List, Any, Dict from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File from pydantic import BaseModel from sqlalchemy.orm import Session from classroom_engine.database import get_db from ..db.legal_document_models import ( LegalDocumentDB, LegalDocumentVersionDB, LegalDocumentApprovalDB, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/legal-documents", tags=["legal-documents"]) # ============================================================================ # 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 ]