Extract consent, audit log, cookie category, and consent stats endpoints from legal_document_routes into LegalDocumentConsentService. The route file is now a thin handler layer delegating to LegalDocumentService and LegalDocumentConsentService with translate_domain_errors(). Legacy helpers (_doc_to_response, _version_to_response, _transition, _log_approval) and schemas are re-exported for existing tests. Two transition tests updated to expect domain errors instead of HTTPException. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
396 lines
13 KiB
Python
396 lines
13 KiB
Python
# 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 # type: ignore
|
|
result = mammoth.convert_to_html(io.BytesIO(content_bytes))
|
|
html_content = result.value
|
|
except ImportError:
|
|
html_content = (
|
|
f"<p>[DOCX-Import: {filename}]</p>"
|
|
f"<p>Bitte installieren Sie 'mammoth' fuer DOCX-Konvertierung.</p>"
|
|
)
|
|
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,
|
|
}
|