Files
breakpilot-compliance/backend-compliance/compliance/api/tom_routes.py
Sharang Parnerkar d571412657 refactor(backend/api): extract TOMService (Step 4 — file 3 of 18)
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>
2026-04-07 19:42:17 +02:00

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
)