feat(workbench): add runtime GIS and sample integrations
This commit is contained in:
@@ -1,47 +1,116 @@
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from psycopg import AsyncConnection
|
||||
|
||||
from app.infra.db.postgresql.analysis import AnalysisRepository
|
||||
from app.auth.project_dependencies import get_project_pg_connection
|
||||
from app.domain.schemas.gis_attributes import (
|
||||
ElementGisProperties,
|
||||
GisElementType,
|
||||
SourceGisFeatureProperties,
|
||||
)
|
||||
from app.infra.db.postgresql.analysis import AnalysisRepository
|
||||
from app.infra.db.postgresql.gis_attributes import GisAttributeRepository
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_database_connection(
|
||||
@router.get(
|
||||
"/source-gis/layers/{layer_key}/features/{source_fid}/properties",
|
||||
response_model=SourceGisFeatureProperties,
|
||||
summary="获取源 GIS 要素台账属性",
|
||||
)
|
||||
async def get_source_gis_feature_properties(
|
||||
layer_key: str,
|
||||
source_fid: int,
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
"""获取数据库连接"""
|
||||
yield conn
|
||||
normalized_layer_key = layer_key.strip()
|
||||
if (
|
||||
not normalized_layer_key
|
||||
or len(normalized_layer_key) > 80
|
||||
or source_fid < 0
|
||||
):
|
||||
raise HTTPException(status_code=422, detail="无效的源 GIS 要素编号")
|
||||
try:
|
||||
group = await GisAttributeRepository.get_source_feature_group(
|
||||
conn,
|
||||
normalized_layer_key,
|
||||
source_fid,
|
||||
)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="源 GIS 要素不存在")
|
||||
return SourceGisFeatureProperties(
|
||||
layer_key=normalized_layer_key,
|
||||
source_fid=source_fid,
|
||||
group=group,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("Source GIS feature properties are unavailable", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="查询源 GIS 要素属性失败") from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/elements/{element_type}/{element_id}/gis-properties",
|
||||
response_model=ElementGisProperties,
|
||||
summary="获取元素 GIS 台账属性",
|
||||
)
|
||||
async def get_element_gis_properties(
|
||||
element_type: GisElementType,
|
||||
element_id: str,
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
"""Return source GIS records linked to one current-project map element."""
|
||||
normalized_id = element_id.strip()
|
||||
if not normalized_id or len(normalized_id) > 160:
|
||||
raise HTTPException(status_code=422, detail="无效的元素编号")
|
||||
try:
|
||||
groups = await GisAttributeRepository.list_element_groups(
|
||||
conn,
|
||||
element_type,
|
||||
normalized_id,
|
||||
)
|
||||
return ElementGisProperties(
|
||||
element_type=element_type,
|
||||
element_id=normalized_id,
|
||||
groups=groups,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Element GIS properties are unavailable", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="查询 GIS 台账属性失败") from exc
|
||||
|
||||
|
||||
@router.get("/analysis/runs", summary="获取分析运行列表")
|
||||
async def get_analysis_runs(
|
||||
conn: AsyncConnection = Depends(get_database_connection),
|
||||
limit: int = Query(default=100, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
"""
|
||||
获取所有方案信息
|
||||
|
||||
返回项目中所有方案的详细信息
|
||||
"""
|
||||
"""分页返回当前项目的持久分析运行。"""
|
||||
try:
|
||||
runs = await AnalysisRepository.list_runs(conn)
|
||||
return {"success": True, "data": runs, "count": len(runs)}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"查询分析运行时发生错误: {str(e)}")
|
||||
runs = await AnalysisRepository.list_runs(conn, limit=limit, offset=offset)
|
||||
return {
|
||||
"success": True,
|
||||
"data": runs,
|
||||
"count": len(runs),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("Analysis run page is unavailable", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="查询分析运行失败") from exc
|
||||
|
||||
|
||||
@router.get("/analysis/runs/{run_id}", summary="获取分析运行")
|
||||
async def get_analysis_run(
|
||||
run_id: UUID,
|
||||
conn: AsyncConnection = Depends(get_database_connection),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
"""
|
||||
获取所有爆管定位结果
|
||||
|
||||
返回项目中所有的爆管定位分析结果
|
||||
"""
|
||||
"""返回当前项目的一条持久分析运行。"""
|
||||
try:
|
||||
run = await AnalysisRepository.get_run(conn, run_id)
|
||||
if run is None:
|
||||
@@ -49,28 +118,31 @@ async def get_analysis_run(
|
||||
return run
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"查询分析运行时发生错误: {str(e)}"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Analysis run is unavailable", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="查询分析运行失败") from exc
|
||||
|
||||
|
||||
@router.get("/analysis/runs/{run_id}/results", summary="获取分析结果")
|
||||
async def get_analysis_results(
|
||||
run_id: UUID,
|
||||
result_type: str | None = Query(default=None, description="结果类型"),
|
||||
conn: AsyncConnection = Depends(get_database_connection),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
"""
|
||||
根据爆管事件ID查询爆管定位结果
|
||||
|
||||
参数:
|
||||
burst_incident: 爆管事件的唯一标识符
|
||||
"""
|
||||
"""分页返回一条分析运行的持久结果。"""
|
||||
try:
|
||||
return await AnalysisRepository.list_results(conn, run_id, result_type)
|
||||
except Exception as e:
|
||||
return await AnalysisRepository.list_results(
|
||||
conn,
|
||||
run_id,
|
||||
result_type,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Analysis result page is unavailable", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"查询分析结果时发生错误: {str(e)}",
|
||||
)
|
||||
detail="查询分析结果失败",
|
||||
) from exc
|
||||
|
||||
@@ -146,6 +146,7 @@ async def optimize_sensor_placement_scheme(
|
||||
sensor_count=payload.sensor_count,
|
||||
min_diameter=payload.min_diameter,
|
||||
created_by=current_user.username,
|
||||
sensor_type=payload.sensor_type,
|
||||
)
|
||||
run = get_sensor_placement_run(network, created["run_id"])
|
||||
return {**run, "can_edit": True}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""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,
|
||||
WorkbenchRuntimeWorkOrder,
|
||||
)
|
||||
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"
|
||||
work_order_storage_configured = False
|
||||
work_orders: list[dict] | None = None
|
||||
alert_storage_configured = False
|
||||
alerts: list[dict] | None = None
|
||||
schedule_availability = "not_configured"
|
||||
work_order_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)
|
||||
work_order_storage_configured = operations_availability.work_orders
|
||||
alert_storage_configured = operations_availability.alerts
|
||||
except Exception:
|
||||
logger.warning("Workbench operations storage probe is unavailable", exc_info=True)
|
||||
schedule_availability = "degraded"
|
||||
work_order_availability = "degraded"
|
||||
alert_availability = "degraded"
|
||||
if work_order_storage_configured:
|
||||
try:
|
||||
async with _projection_transaction(conn):
|
||||
work_order_documents = await WorkbenchOperationsRepository.list_work_orders(
|
||||
conn, str(ctx.project_id)
|
||||
)
|
||||
work_orders, invalid_work_orders = _validate_documents(
|
||||
work_order_documents, WorkbenchRuntimeWorkOrder, "work order"
|
||||
)
|
||||
work_order_availability = (
|
||||
"degraded" if invalid_work_orders else "ready"
|
||||
)
|
||||
except Exception:
|
||||
work_orders = None
|
||||
work_order_availability = "degraded"
|
||||
logger.warning("Workbench work-order projection is unavailable", exc_info=True)
|
||||
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,
|
||||
work_order_storage_configured=work_order_storage_configured,
|
||||
work_orders=work_orders,
|
||||
alert_storage_configured=alert_storage_configured,
|
||||
persisted_alerts=alerts,
|
||||
scenario_availability=analysis_availability,
|
||||
artifact_availability=analysis_availability,
|
||||
schedule_availability=schedule_availability,
|
||||
work_order_availability=work_order_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
|
||||
@@ -17,6 +17,7 @@ from app.api.v1.endpoints import (
|
||||
sensor_placement,
|
||||
simulation,
|
||||
web_search,
|
||||
workbench_runtime,
|
||||
)
|
||||
from app.api.v1.endpoints.components import (
|
||||
controls,
|
||||
@@ -85,6 +86,11 @@ burst_run_access = Depends(require_permission(BURST_RUN))
|
||||
optimization_run_access = Depends(require_permission(OPTIMIZATION_RUN))
|
||||
|
||||
# Core services
|
||||
api_router.include_router(
|
||||
workbench_runtime.router,
|
||||
tags=["Workbench Runtime"],
|
||||
dependencies=[simulation_access],
|
||||
)
|
||||
api_router.include_router(access.router, tags=["Access Control"])
|
||||
api_router.include_router(agent_auth.router, tags=["Agent Auth"])
|
||||
api_router.include_router(
|
||||
|
||||
Reference in New Issue
Block a user