663a1c3e38
CI / detect-changes (push) Successful in 9s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Failing after 4s
CI / validate-canonical-controls (push) Successful in 11s
CI / loc-budget (push) Failing after 12s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m16s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 30s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Neue Compliance-Admin-Seite /sdk/document-library: zeigt alle compliance_
legal_documents mit aktueller Version, gruppiert nach Empfehlungs-Klassi-
fikation, filterbar nach Status + Volltextsuche.
Backend (Service + Routes):
- LegalDocumentService.list_documents_with_versions() — JOIN über docs +
latest/published version in einem Roundtrip statt N+1
- GET /api/v1/compliance/legal-documents/documents-with-versions
liefert {documents:[{...doc, latest_version, published_version}]}
Admin-Frontend:
- app/sdk/document-library/page.tsx (350 LOC)
- Lädt Docs + Recommend parallel
- Mapped jedes Doc per .type → Recommend-Item (klassifiziert in
required/recommended/optional/uncategorized)
- 4 Sektionen mit Klassifikations-Chip + Anzahl-Badge
- Tabelle pro Sektion: Titel · Type · Status · Version · Geändert · Override
- Status-Filter (alle / draft / review_internal / review_client /
approved / published / archived / rejected)
- Klick auf Zeile → /sdk/workflow?doc=<uuid>
- Empty state mit Link zum Generator (Bulk-Modus)
- workflow/page.tsx: auto-select bei ?doc=<uuid> URL-Param
- lib/sdk/types/sdk-steps.ts: 'document-library' bei seq=2500 im Paket
'dokumentation' registriert (sichtbar in der SDK-Sidebar)
Workflow-Hookup vervollständigt: Library → click → Workflow öffnet
direkt das gewünschte Dokument im SplitViewEditor, keine manuelle
Selektion über DocumentSelectorBar mehr nötig.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
437 lines
14 KiB
Python
437 lines
14 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.
|
|
|
|
Phase 1 Step 4 refactor: handlers delegate to LegalDocumentService and
|
|
LegalDocumentConsentService. Module-level helpers re-exported for legacy tests.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any, Optional
|
|
|
|
from fastapi import APIRouter, Depends, Header, Query, UploadFile, File
|
|
from sqlalchemy.orm import Session
|
|
|
|
from classroom_engine.database import get_db
|
|
from compliance.api._http_errors import translate_domain_errors
|
|
from compliance.schemas.legal_document import (
|
|
ActionRequest,
|
|
CookieCategoryCreate,
|
|
CookieCategoryUpdate,
|
|
DocumentCreate,
|
|
DocumentResponse,
|
|
UserConsentCreate,
|
|
VersionCreate,
|
|
VersionResponse,
|
|
VersionUpdate,
|
|
)
|
|
from compliance.services.legal_document_consent_service import (
|
|
LegalDocumentConsentService,
|
|
)
|
|
from compliance.services.legal_document_service import (
|
|
LegalDocumentService,
|
|
_doc_to_response, # re-exported for legacy test imports
|
|
_log_approval, # re-exported for legacy test imports
|
|
_transition, # re-exported for legacy test imports
|
|
_version_to_response, # re-exported for legacy test imports
|
|
)
|
|
|
|
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
|
|
|
|
|
|
def _get_doc_service(db: Session = Depends(get_db)) -> LegalDocumentService:
|
|
return LegalDocumentService(db)
|
|
|
|
|
|
def _get_consent_service(
|
|
db: Session = Depends(get_db),
|
|
) -> LegalDocumentConsentService:
|
|
return LegalDocumentConsentService(db)
|
|
|
|
|
|
# ============================================================================
|
|
# Documents
|
|
# ============================================================================
|
|
|
|
|
|
@router.get("/documents", response_model=dict[str, Any])
|
|
async def list_documents(
|
|
tenant_id: Optional[str] = Query(None),
|
|
type: Optional[str] = Query(None),
|
|
service: LegalDocumentService = Depends(_get_doc_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.list_documents(tenant_id, type)
|
|
|
|
|
|
@router.get("/documents-with-versions", response_model=dict[str, Any])
|
|
async def list_documents_with_versions(
|
|
tenant_id: Optional[str] = Query(None),
|
|
type: Optional[str] = Query(None),
|
|
service: LegalDocumentService = Depends(_get_doc_service),
|
|
) -> dict[str, Any]:
|
|
"""Listet Docs inkl. jeweils latest + published Version — fuer Library-UI."""
|
|
with translate_domain_errors():
|
|
return service.list_documents_with_versions(tenant_id, type)
|
|
|
|
|
|
@router.post("/documents", response_model=DocumentResponse, status_code=201)
|
|
async def create_document(
|
|
request: DocumentCreate,
|
|
service: LegalDocumentService = Depends(_get_doc_service),
|
|
) -> DocumentResponse:
|
|
with translate_domain_errors():
|
|
return service.create_document(request)
|
|
|
|
|
|
@router.get("/documents/{document_id}", response_model=DocumentResponse)
|
|
async def get_document(
|
|
document_id: str,
|
|
service: LegalDocumentService = Depends(_get_doc_service),
|
|
) -> DocumentResponse:
|
|
with translate_domain_errors():
|
|
return service.get_document(document_id)
|
|
|
|
|
|
@router.delete("/documents/{document_id}", status_code=204)
|
|
async def delete_document(
|
|
document_id: str,
|
|
service: LegalDocumentService = Depends(_get_doc_service),
|
|
) -> None:
|
|
with translate_domain_errors():
|
|
service.delete_document(document_id)
|
|
|
|
|
|
@router.get("/documents/{document_id}/versions", response_model=list[VersionResponse])
|
|
async def list_versions(
|
|
document_id: str,
|
|
service: LegalDocumentService = Depends(_get_doc_service),
|
|
) -> list[VersionResponse]:
|
|
with translate_domain_errors():
|
|
return service.list_versions_for(document_id)
|
|
|
|
|
|
# ============================================================================
|
|
# Versions
|
|
# ============================================================================
|
|
|
|
|
|
@router.post("/versions", response_model=VersionResponse, status_code=201)
|
|
async def create_version(
|
|
request: VersionCreate,
|
|
service: LegalDocumentService = Depends(_get_doc_service),
|
|
) -> VersionResponse:
|
|
with translate_domain_errors():
|
|
return service.create_version(request)
|
|
|
|
|
|
@router.put("/versions/{version_id}", response_model=VersionResponse)
|
|
async def update_version(
|
|
version_id: str,
|
|
request: VersionUpdate,
|
|
service: LegalDocumentService = Depends(_get_doc_service),
|
|
) -> VersionResponse:
|
|
with translate_domain_errors():
|
|
return service.update_version(version_id, request)
|
|
|
|
|
|
@router.get("/versions/{version_id}", response_model=VersionResponse)
|
|
async def get_version(
|
|
version_id: str,
|
|
service: LegalDocumentService = Depends(_get_doc_service),
|
|
) -> VersionResponse:
|
|
with translate_domain_errors():
|
|
return service.get_version(version_id)
|
|
|
|
|
|
@router.post("/versions/upload-word", response_model=dict[str, Any])
|
|
async def upload_word(
|
|
file: UploadFile = File(...),
|
|
service: LegalDocumentService = Depends(_get_doc_service),
|
|
) -> dict[str, Any]:
|
|
content_bytes = await file.read()
|
|
with translate_domain_errors():
|
|
return await service.upload_word(file.filename, content_bytes)
|
|
|
|
|
|
# ============================================================================
|
|
# Approval Workflow Actions
|
|
# ============================================================================
|
|
|
|
|
|
@router.post("/versions/{version_id}/submit-review", response_model=VersionResponse)
|
|
async def submit_review(
|
|
version_id: str,
|
|
request: ActionRequest,
|
|
service: LegalDocumentService = Depends(_get_doc_service),
|
|
) -> VersionResponse:
|
|
with translate_domain_errors():
|
|
return service.submit_review(version_id, request)
|
|
|
|
|
|
@router.post("/versions/{version_id}/approve", response_model=VersionResponse)
|
|
async def approve_version(
|
|
version_id: str,
|
|
request: ActionRequest,
|
|
service: LegalDocumentService = Depends(_get_doc_service),
|
|
) -> VersionResponse:
|
|
with translate_domain_errors():
|
|
return service.approve(version_id, request)
|
|
|
|
|
|
@router.post("/versions/{version_id}/reject", response_model=VersionResponse)
|
|
async def reject_version(
|
|
version_id: str,
|
|
request: ActionRequest,
|
|
service: LegalDocumentService = Depends(_get_doc_service),
|
|
) -> VersionResponse:
|
|
with translate_domain_errors():
|
|
return service.reject(version_id, request)
|
|
|
|
|
|
@router.post("/versions/{version_id}/publish", response_model=VersionResponse)
|
|
async def publish_version(
|
|
version_id: str,
|
|
request: ActionRequest,
|
|
service: LegalDocumentService = Depends(_get_doc_service),
|
|
) -> VersionResponse:
|
|
with translate_domain_errors():
|
|
return service.publish(version_id, request)
|
|
|
|
|
|
# ============================================================================
|
|
# 5-stage Workflow (draft → review_internal → review_client → approved → published)
|
|
# ============================================================================
|
|
|
|
@router.post(
|
|
"/versions/{version_id}/submit-internal-review",
|
|
response_model=VersionResponse,
|
|
)
|
|
async def submit_internal_review(
|
|
version_id: str,
|
|
request: ActionRequest,
|
|
service: LegalDocumentService = Depends(_get_doc_service),
|
|
) -> VersionResponse:
|
|
"""draft (oder rejected) → review_internal: DSB-Phase beginnt."""
|
|
with translate_domain_errors():
|
|
return service.submit_internal_review(version_id, request)
|
|
|
|
|
|
@router.post(
|
|
"/versions/{version_id}/approve-internal",
|
|
response_model=VersionResponse,
|
|
)
|
|
async def approve_internal(
|
|
version_id: str,
|
|
request: ActionRequest,
|
|
service: LegalDocumentService = Depends(_get_doc_service),
|
|
) -> VersionResponse:
|
|
"""review_internal → review_client: DSB hat freigegeben, Mandant ist dran."""
|
|
with translate_domain_errors():
|
|
return service.approve_internal(version_id, request)
|
|
|
|
|
|
@router.post(
|
|
"/versions/{version_id}/approve-client",
|
|
response_model=VersionResponse,
|
|
)
|
|
async def approve_client(
|
|
version_id: str,
|
|
request: ActionRequest,
|
|
service: LegalDocumentService = Depends(_get_doc_service),
|
|
) -> VersionResponse:
|
|
"""review_client → approved: Mandant hat freigegeben."""
|
|
with translate_domain_errors():
|
|
return service.approve_client(version_id, request)
|
|
|
|
|
|
# ============================================================================
|
|
# Approval History
|
|
# ============================================================================
|
|
|
|
|
|
@router.get("/versions/{version_id}/approval-history")
|
|
async def get_approval_history(
|
|
version_id: str,
|
|
service: LegalDocumentService = Depends(_get_doc_service),
|
|
) -> list[dict[str, Any]]:
|
|
with translate_domain_errors():
|
|
entries = service.approval_history(version_id)
|
|
return [e.dict() for e in entries]
|
|
|
|
|
|
# ============================================================================
|
|
# Public Endpoints (for end users)
|
|
# ============================================================================
|
|
|
|
|
|
@router.get("/public")
|
|
async def list_public_documents(
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: LegalDocumentService = Depends(_get_doc_service),
|
|
) -> list[dict[str, Any]]:
|
|
with translate_domain_errors():
|
|
return service.list_public(tenant_id)
|
|
|
|
|
|
@router.get("/public/{document_type}/latest")
|
|
async def get_latest_published(
|
|
document_type: str,
|
|
language: str = Query("de"),
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: LegalDocumentService = Depends(_get_doc_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.get_latest_published(tenant_id, document_type, language)
|
|
|
|
|
|
# ============================================================================
|
|
# User Consents
|
|
# ============================================================================
|
|
|
|
|
|
@router.post("/consents")
|
|
async def record_consent(
|
|
body: UserConsentCreate,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: LegalDocumentConsentService = Depends(_get_consent_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.record_consent(tenant_id, body)
|
|
|
|
|
|
@router.get("/consents/my")
|
|
async def get_my_consents(
|
|
user_id: str = Query(...),
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: LegalDocumentConsentService = Depends(_get_consent_service),
|
|
) -> list[dict[str, Any]]:
|
|
with translate_domain_errors():
|
|
return service.get_my_consents(tenant_id, user_id)
|
|
|
|
|
|
@router.get("/consents/check/{document_type}")
|
|
async def check_consent(
|
|
document_type: str,
|
|
user_id: str = Query(...),
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: LegalDocumentConsentService = Depends(_get_consent_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.check_consent(tenant_id, document_type, user_id)
|
|
|
|
|
|
@router.delete("/consents/{consent_id}")
|
|
async def withdraw_consent(
|
|
consent_id: str,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: LegalDocumentConsentService = Depends(_get_consent_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.withdraw_consent(tenant_id, consent_id)
|
|
|
|
|
|
# ============================================================================
|
|
# Consent Statistics
|
|
# ============================================================================
|
|
|
|
|
|
@router.get("/stats/consents")
|
|
async def get_consent_stats(
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: LegalDocumentConsentService = Depends(_get_consent_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.get_consent_stats(tenant_id)
|
|
|
|
|
|
# ============================================================================
|
|
# 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),
|
|
service: LegalDocumentConsentService = Depends(_get_consent_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.get_audit_log(
|
|
tenant_id, limit=limit, offset=offset,
|
|
action=action, entity_type=entity_type,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Cookie Categories CRUD
|
|
# ============================================================================
|
|
|
|
|
|
@router.get("/cookie-categories")
|
|
async def list_cookie_categories(
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: LegalDocumentConsentService = Depends(_get_consent_service),
|
|
) -> list[dict[str, Any]]:
|
|
with translate_domain_errors():
|
|
return service.list_cookie_categories(tenant_id)
|
|
|
|
|
|
@router.post("/cookie-categories")
|
|
async def create_cookie_category(
|
|
body: CookieCategoryCreate,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: LegalDocumentConsentService = Depends(_get_consent_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.create_cookie_category(tenant_id, body)
|
|
|
|
|
|
@router.put("/cookie-categories/{category_id}")
|
|
async def update_cookie_category(
|
|
category_id: str,
|
|
body: CookieCategoryUpdate,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: LegalDocumentConsentService = Depends(_get_consent_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.update_cookie_category(tenant_id, category_id, body)
|
|
|
|
|
|
@router.delete("/cookie-categories/{category_id}", status_code=204)
|
|
async def delete_cookie_category(
|
|
category_id: str,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: LegalDocumentConsentService = Depends(_get_consent_service),
|
|
) -> None:
|
|
with translate_domain_errors():
|
|
service.delete_cookie_category(tenant_id, category_id)
|
|
|
|
|
|
# Legacy re-exports so existing tests can still import from this module.
|
|
__all__ = [
|
|
"router",
|
|
"DEFAULT_TENANT",
|
|
"DocumentCreate",
|
|
"VersionCreate",
|
|
"VersionUpdate",
|
|
"ActionRequest",
|
|
"_doc_to_response",
|
|
"_version_to_response",
|
|
"_transition",
|
|
"_log_approval",
|
|
]
|