Files
next-tjwater-server/app/api/v1/endpoints/workbench_runtime.py
T

220 lines
7.8 KiB
Python

"""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),
):
analysis_runs: list[dict] | None = None
latest_results: list[dict] | None = None
latest_analysis_map_payload: dict | None = None
analysis_availability = "not_configured"
alert_storage_configured = False
alerts: list[dict] | None = None
schedule_availability = "not_configured"
alert_availability = "not_configured"
try:
async with _projection_transaction(conn):
analysis_runs = await AnalysisRepository.list_recent_runs(conn, limit=100)
if analysis_runs:
latest_results = await AnalysisRepository.list_recent_results(
conn, analysis_runs[0]["run_id"], limit=100
)
analysis_availability = "ready" if analysis_runs else "not_configured"
except Exception:
logger.warning("Workbench analysis projection is unavailable", exc_info=True)
analysis_runs = None
latest_results = None
analysis_availability = "degraded"
if analysis_runs and latest_results:
try:
async with _projection_transaction(conn):
latest_analysis_map_payload = await _sensor_location_map_payload(
conn, latest_results
)
except Exception:
logger.warning(
"Workbench analysis spatial references are unavailable",
exc_info=True,
)
try:
async with _projection_transaction(conn):
operations_availability = await WorkbenchOperationsRepository.availability(conn)
alert_storage_configured = operations_availability.alerts
except Exception:
logger.warning("Workbench operations storage probe is unavailable", exc_info=True)
schedule_availability = "degraded"
alert_availability = "degraded"
if alert_storage_configured:
try:
async with _projection_transaction(conn):
alert_documents = await WorkbenchOperationsRepository.list_alerts(
conn, str(ctx.project_id)
)
alerts, invalid_alerts = _validate_documents(
alert_documents, WorkbenchPersistedAlert, "alert"
)
alert_availability = "degraded" if invalid_alerts else "ready"
except Exception:
alerts = None
alert_availability = "degraded"
logger.warning("Workbench alert projection is unavailable", exc_info=True)
return await build_workbench_runtime_snapshot(
analysis_runs=analysis_runs,
latest_analysis_results=latest_results,
latest_analysis_map_payload=latest_analysis_map_payload,
alert_storage_configured=alert_storage_configured,
persisted_alerts=alerts,
scenario_availability=analysis_availability,
artifact_availability=analysis_availability,
schedule_availability=schedule_availability,
alert_availability=alert_availability,
projection_degraded=False,
)
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