compliance/api/tom_routes.py (609 LOC) -> 215 LOC thin routes + 434-line TOMService. Request bodies (TOMStateBody, TOMMeasureCreate, TOMMeasureUpdate, TOMMeasureBulkItem, TOMMeasureBulkBody) moved to compliance/schemas/tom.py (joining the existing response models from the Step 3 split). Single-service split (not two like banner): state, measures CRUD + bulk upsert, stats, export, and version lookups are all tightly coupled around the TOMMeasureDB aggregate, so splitting would create artificial boundaries. TOMService is 434 LOC — comfortably under the 500 hard cap. Domain error mapping: - ConflictError -> 409 (version conflict on state save; duplicate control_id on create) - NotFoundError -> 404 (missing measure on update; missing version) - ValidationError -> 400 (missing tenant_id on DELETE /state) Legacy test compat: the existing tests/test_tom_routes.py imports TOMMeasureBulkItem, _parse_dt, _measure_to_dict, and DEFAULT_TENANT_ID directly from compliance.api.tom_routes. All re-exported via __all__ so the 44-test file runs unchanged. mypy.ini flips compliance.api.tom_routes from ignore_errors=True to False. TOMService carries the scoped Column[T] header. Verified: - 217/217 pytest (173 baseline + 44 TOM) pass - OpenAPI 360/484 unchanged - mypy compliance/ -> Success on 124 source files - tom_routes.py 609 -> 215 LOC - Hard-cap violations: 16 -> 15 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
216 lines
7.3 KiB
Python
216 lines
7.3 KiB
Python
"""
|
|
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
|
|
)
|