"""Read-only workbench runtime aggregation.""" import logging from contextlib import asynccontextmanager from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, status from psycopg import AsyncConnection from pydantic import ValidationError from app.auth.project_dependencies import ( ProjectContext, get_project_context, get_project_pg_connection, ) from app.domain.schemas.workbench_runtime import ( WorkbenchAnalysisHistoryPage, WorkbenchPersistedAlert, WorkbenchRuntimeLatestAnalysis, WorkbenchRuntimeSnapshot, ) from app.domain.schemas.task_alert import TaskAlertCommand, TaskAlertEvent, TaskAlertState from app.infra.db.postgresql.analysis import AnalysisRepository from app.infra.db.postgresql.workbench_operations import ( WorkbenchOperationsRepository, ) from app.infra.db.postgresql.task_alerts import TaskAlertRepository from app.services.task_alerts import TaskAlertService from app.domain.domain_errors import DomainError from app.services.workbench_runtime import ( build_workbench_runtime_snapshot, project_analysis_history_item, project_latest_analysis, project_sensor_location_map_payload, sensor_location_ids_for_map_projection, ) router = APIRouter() logger = logging.getLogger(__name__) @asynccontextmanager async def _projection_transaction(conn: AsyncConnection): """Isolate an optional projection so one SQL failure cannot poison others.""" transaction = getattr(conn, "transaction", None) if transaction is None: # Lightweight endpoint test doubles. yield return async with transaction(): yield @router.get( "/workbench/analysis-runs", response_model=WorkbenchAnalysisHistoryPage, ) async def workbench_analysis_runs( limit: int = Query(default=20, ge=1, le=100), offset: int = Query(default=0, ge=0), conn: AsyncConnection = Depends(get_project_pg_connection), ): """Return a bounded, sanitized analysis history page for the workbench.""" rows = await AnalysisRepository.list_run_summaries( conn, limit=limit + 1, offset=offset, ) has_more = len(rows) > limit items = rows[:limit] return { "items": [project_analysis_history_item(run) for run in items], "limit": limit, "offset": offset, "next_offset": offset + limit if has_more else None, } @router.get( "/workbench/analysis-runs/{run_id}", response_model=WorkbenchRuntimeLatestAnalysis, ) async def workbench_analysis_run( run_id: UUID, conn: AsyncConnection = Depends(get_project_pg_connection), ): """Return one authorized project's analysis run as a typed workbench artifact.""" run = await AnalysisRepository.get_run(conn, run_id) if run is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Analysis run not found", ) results = await AnalysisRepository.list_recent_results(conn, run_id, limit=100) try: referenced_map_payload = await _sensor_location_map_payload(conn, results) except Exception: referenced_map_payload = None logger.warning( "Workbench analysis spatial references are unavailable", exc_info=True, ) return project_latest_analysis( run, results, referenced_map_payload=referenced_map_payload, ) @router.post("/workbench/task-alert-events", response_model=TaskAlertState) async def push_task_alert_event( command: TaskAlertCommand, ctx: ProjectContext = Depends(get_project_context), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: event = TaskAlertEvent( **command.model_dump(), producer_id=f"principal:{ctx.user_id}", ) return await TaskAlertService( TaskAlertRepository(conn, ctx.project_id), ctx ).push(event) except DomainError as exc: raise HTTPException(status_code=exc.status, detail=exc.message) from exc @router.get("/workbench/runtime", response_model=WorkbenchRuntimeSnapshot) async def workbench_runtime( ctx: ProjectContext = Depends(get_project_context), conn: AsyncConnection = Depends(get_project_pg_connection), ): from app.services.workbench_runtime import read_workbench_runtime return await read_workbench_runtime(ctx, conn) async def _sensor_location_map_payload( conn: AsyncConnection, results: list[dict] ) -> dict | None: node_ids = sensor_location_ids_for_map_projection(results) if not node_ids: return None map_points = await AnalysisRepository.list_junction_map_points(conn, node_ids) return project_sensor_location_map_payload(node_ids, map_points) def _validate_documents(documents, model, label: str) -> tuple[list[dict], int]: valid: list[dict] = [] rejected = 0 for document in documents: try: valid.append(model.model_validate(document).model_dump()) except ValidationError: rejected += 1 logger.warning("Workbench %s document was rejected", label) return valid, rejected