# mypy: disable-error-code="arg-type,assignment,union-attr" """ Legal Document service — documents + versions + approval workflow + public endpoints. Phase 1 Step 4: extracted from ``compliance.api.legal_document_routes``. Consents, audit log, and cookie categories live in ``compliance.services.legal_document_consent_service``. Module-level helpers (_doc_to_response, _version_to_response, _transition, _log_approval) are re-exported from the route module for legacy tests. """ import io from datetime import datetime, timezone from typing import Any, Optional from sqlalchemy.orm import Session from compliance.db.legal_document_models import ( LegalDocumentApprovalDB, LegalDocumentDB, LegalDocumentVersionDB, ) from compliance.domain import NotFoundError, ValidationError from compliance.schemas.legal_document import ( ActionRequest, ApprovalHistoryEntry, DocumentCreate, DocumentResponse, VersionCreate, VersionResponse, VersionUpdate, ) 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 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[str, Any]] = None, ) -> VersionResponse: version = ( db.query(LegalDocumentVersionDB) .filter(LegalDocumentVersionDB.id == version_id) .first() ) if not version: raise NotFoundError(f"Version {version_id} not found") if version.status not in from_statuses: raise ValidationError( f"Cannot perform '{action}' on version with status " f"'{version.status}' (expected: {from_statuses})" ) version.status = to_status version.updated_at = datetime.now(timezone.utc) if extra_updates: for k, val in extra_updates.items(): setattr(version, k, val) _log_approval(db, version.id, action=action, approver=approver, comment=comment) db.commit() db.refresh(version) return _version_to_response(version) class LegalDocumentService: """Business logic for legal documents, versions, and approval workflow.""" def __init__(self, db: Session) -> None: self.db = db # ------------------------------------------------------------------ # Documents # ------------------------------------------------------------------ def list_documents( self, tenant_id: Optional[str], type_filter: Optional[str] ) -> dict[str, Any]: q = self.db.query(LegalDocumentDB) if tenant_id: q = q.filter(LegalDocumentDB.tenant_id == tenant_id) if type_filter: q = q.filter(LegalDocumentDB.type == type_filter) docs = q.order_by(LegalDocumentDB.created_at.desc()).all() return {"documents": [_doc_to_response(d).dict() for d in docs]} def create_document(self, request: DocumentCreate) -> DocumentResponse: doc = LegalDocumentDB( tenant_id=request.tenant_id, type=request.type, name=request.name, description=request.description, mandatory=request.mandatory, ) self.db.add(doc) self.db.commit() self.db.refresh(doc) return _doc_to_response(doc) def _doc_or_raise(self, document_id: str) -> LegalDocumentDB: doc = ( self.db.query(LegalDocumentDB) .filter(LegalDocumentDB.id == document_id) .first() ) if not doc: raise NotFoundError(f"Document {document_id} not found") return doc def get_document(self, document_id: str) -> DocumentResponse: return _doc_to_response(self._doc_or_raise(document_id)) def delete_document(self, document_id: str) -> None: doc = self._doc_or_raise(document_id) self.db.delete(doc) self.db.commit() def list_versions_for(self, document_id: str) -> list[VersionResponse]: self._doc_or_raise(document_id) versions = ( self.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 # ------------------------------------------------------------------ def create_version(self, request: VersionCreate) -> VersionResponse: doc = ( self.db.query(LegalDocumentDB) .filter(LegalDocumentDB.id == request.document_id) .first() ) if not doc: raise NotFoundError(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", ) self.db.add(version) self.db.flush() _log_approval(self.db, version.id, action="created", approver=request.created_by) self.db.commit() self.db.refresh(version) return _version_to_response(version) def update_version( self, version_id: str, request: VersionUpdate ) -> VersionResponse: version = ( self.db.query(LegalDocumentVersionDB) .filter(LegalDocumentVersionDB.id == version_id) .first() ) if not version: raise NotFoundError(f"Version {version_id} not found") if version.status not in ("draft", "rejected"): raise ValidationError( 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) self.db.commit() self.db.refresh(version) return _version_to_response(version) def get_version(self, version_id: str) -> VersionResponse: v = ( self.db.query(LegalDocumentVersionDB) .filter(LegalDocumentVersionDB.id == version_id) .first() ) if not v: raise NotFoundError(f"Version {version_id} not found") return _version_to_response(v) async def upload_word(self, filename: Optional[str], content_bytes: bytes) -> dict[str, Any]: if not filename or not filename.lower().endswith(".docx"): raise ValidationError("Only .docx files are supported") html_content = "" try: import mammoth # noqa: F811 result = mammoth.convert_to_html(io.BytesIO(content_bytes)) html_content = result.value except ImportError: html_content = ( f"

[DOCX-Import: {filename}]

" f"

Bitte installieren Sie 'mammoth' fuer DOCX-Konvertierung.

" ) return {"html": html_content, "filename": filename} # ------------------------------------------------------------------ # Workflow transitions # ------------------------------------------------------------------ def submit_review(self, version_id: str, request: ActionRequest) -> VersionResponse: return _transition( self.db, version_id, ["draft", "rejected"], "review", "submitted", request.approver, request.comment, ) def approve(self, version_id: str, request: ActionRequest) -> VersionResponse: return _transition( self.db, version_id, ["review"], "approved", "approved", request.approver, request.comment, extra_updates={ "approved_by": request.approver, "approved_at": datetime.now(timezone.utc), }, ) def reject(self, version_id: str, request: ActionRequest) -> VersionResponse: return _transition( self.db, version_id, ["review"], "rejected", "rejected", request.approver, request.comment, extra_updates={"rejection_reason": request.comment}, ) def publish(self, version_id: str, request: ActionRequest) -> VersionResponse: return _transition( self.db, version_id, ["approved"], "published", "published", request.approver, request.comment, ) def approval_history(self, version_id: str) -> list[ApprovalHistoryEntry]: version = ( self.db.query(LegalDocumentVersionDB) .filter(LegalDocumentVersionDB.id == version_id) .first() ) if not version: raise NotFoundError(f"Version {version_id} not found") entries = ( self.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 ] # ------------------------------------------------------------------ # Public endpoints (end-user facing) # ------------------------------------------------------------------ def list_public(self, tenant_id: str) -> list[dict[str, Any]]: docs = ( self.db.query(LegalDocumentDB) .filter(LegalDocumentDB.tenant_id == tenant_id) .order_by(LegalDocumentDB.created_at.desc()) .all() ) result: list[dict[str, Any]] = [] for doc in docs: published = ( self.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 def get_latest_published( self, tenant_id: str, document_type: str, language: str ) -> dict[str, Any]: doc = ( self.db.query(LegalDocumentDB) .filter( LegalDocumentDB.tenant_id == tenant_id, LegalDocumentDB.type == document_type, ) .first() ) if not doc: raise NotFoundError(f"No document of type '{document_type}' found") version = ( self.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 NotFoundError( 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, }