""" FastAPI routes for TOM — Technisch-Organisatorische Massnahmen (Art. 32 DSGVO). Endpoints: GET /tom/state — Load TOM generator state for tenant POST /tom/state — Save state (with version check) DELETE /tom/state — Reset/clear state for tenant GET /tom/measures — List measures (filter: category, status, tenant_id) POST /tom/measures — Create single measure PUT /tom/measures/{id} — Update measure POST /tom/measures/bulk — Bulk upsert (for deriveTOMs sync) GET /tom/stats — Statistics GET /tom/export — Export as CSV or JSON GET /tom/measures/{id}/versions — List measure versions GET /tom/measures/{id}/versions/{n} — Get specific version Phase 1 Step 4 refactor: handlers are thin and delegate to TOMService. """ from typing import Any, Optional from uuid import UUID from fastapi import APIRouter, Depends, Query from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from classroom_engine.database import get_db from compliance.api._http_errors import translate_domain_errors from compliance.schemas.tom import ( TOMMeasureBulkBody, TOMMeasureBulkItem, # re-exported for backwards compat (legacy test imports) TOMMeasureCreate, TOMMeasureUpdate, TOMStateBody, ) # Keep the legacy import path ``from compliance.api.tom_routes import TOMMeasureBulkItem`` # working — it was the public name before the Step 3 schemas split. __all__ = [ "router", "TOMMeasureBulkBody", "TOMMeasureBulkItem", "TOMMeasureCreate", "TOMMeasureUpdate", "TOMStateBody", "DEFAULT_TENANT_ID", "_parse_dt", "_measure_to_dict", ] from compliance.services.tom_service import ( DEFAULT_TENANT_ID, TOMService, _measure_to_dict, # re-exported for legacy test imports _parse_dt, # re-exported for legacy test imports ) router = APIRouter(prefix="/tom", tags=["tom"]) def get_tom_service(db: Session = Depends(get_db)) -> TOMService: return TOMService(db) # ============================================================================= # STATE # ============================================================================= @router.get("/state") async def get_tom_state( tenant_id: Optional[str] = Query(None, alias="tenant_id"), tenantId: Optional[str] = Query(None), service: TOMService = Depends(get_tom_service), ) -> dict[str, Any]: """Load TOM generator state for a tenant.""" with translate_domain_errors(): return service.get_state(tenant_id or tenantId or DEFAULT_TENANT_ID) @router.post("/state") async def save_tom_state( body: TOMStateBody, service: TOMService = Depends(get_tom_service), ) -> dict[str, Any]: """Save TOM generator state with optimistic locking (version check).""" with translate_domain_errors(): return service.save_state(body) @router.delete("/state") async def delete_tom_state( tenant_id: Optional[str] = Query(None, alias="tenant_id"), tenantId: Optional[str] = Query(None), service: TOMService = Depends(get_tom_service), ) -> dict[str, Any]: """Clear TOM generator state for a tenant.""" with translate_domain_errors(): return service.delete_state(tenant_id or tenantId) # ============================================================================= # MEASURES # ============================================================================= @router.get("/measures") async def list_measures( tenant_id: Optional[str] = Query(None), category: Optional[str] = Query(None), implementation_status: Optional[str] = Query(None), priority: Optional[str] = Query(None), search: Optional[str] = Query(None), limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), service: TOMService = Depends(get_tom_service), ) -> dict[str, Any]: """List TOM measures with optional filters.""" with translate_domain_errors(): return service.list_measures( tenant_id=tenant_id or DEFAULT_TENANT_ID, category=category, implementation_status=implementation_status, priority=priority, search=search, limit=limit, offset=offset, ) @router.post("/measures", status_code=201) async def create_measure( body: TOMMeasureCreate, tenant_id: Optional[str] = Query(None), service: TOMService = Depends(get_tom_service), ) -> dict[str, Any]: """Create a single TOM measure.""" with translate_domain_errors(): return service.create_measure(tenant_id or DEFAULT_TENANT_ID, body) @router.put("/measures/{measure_id}") async def update_measure( measure_id: UUID, body: TOMMeasureUpdate, service: TOMService = Depends(get_tom_service), ) -> dict[str, Any]: """Update a TOM measure.""" with translate_domain_errors(): return service.update_measure(measure_id, body) @router.post("/measures/bulk") async def bulk_upsert_measures( body: TOMMeasureBulkBody, service: TOMService = Depends(get_tom_service), ) -> dict[str, Any]: """Bulk upsert measures — used by deriveTOMs sync from frontend.""" with translate_domain_errors(): return service.bulk_upsert(body) # ============================================================================= # STATS & EXPORT # ============================================================================= @router.get("/stats") async def get_tom_stats( tenant_id: Optional[str] = Query(None), service: TOMService = Depends(get_tom_service), ) -> dict[str, Any]: """Return TOM statistics for a tenant.""" with translate_domain_errors(): return service.stats(tenant_id or DEFAULT_TENANT_ID) @router.get("/export") async def export_measures( tenant_id: Optional[str] = Query(None), format: str = Query("csv"), service: TOMService = Depends(get_tom_service), ) -> StreamingResponse: """Export TOM measures as CSV (semicolon-separated) or JSON.""" with translate_domain_errors(): return service.export(tenant_id or DEFAULT_TENANT_ID, format) # ============================================================================= # Versioning # ============================================================================= @router.get("/measures/{measure_id}/versions") async def list_measure_versions( measure_id: str, tenant_id: Optional[str] = Query(None, alias="tenant_id"), tenantId: Optional[str] = Query(None, alias="tenantId"), service: TOMService = Depends(get_tom_service), ) -> Any: """List all versions for a TOM measure.""" with translate_domain_errors(): return service.list_versions( measure_id, tenant_id or tenantId or DEFAULT_TENANT_ID ) @router.get("/measures/{measure_id}/versions/{version_number}") async def get_measure_version( measure_id: str, version_number: int, tenant_id: Optional[str] = Query(None, alias="tenant_id"), tenantId: Optional[str] = Query(None, alias="tenantId"), service: TOMService = Depends(get_tom_service), ) -> Any: """Get a specific TOM measure version with full snapshot.""" with translate_domain_errors(): return service.get_version( measure_id, version_number, tenant_id or tenantId or DEFAULT_TENANT_ID )