Files
breakpilot-compliance/backend-compliance/compliance/api/legal_document_routes.py
T
Benjamin Admin 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
feat(document-library): zentrale Doc-Übersicht + Workflow-Auto-Select (Phase 3)
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>
2026-06-08 09:32:25 +02:00

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