Files
TJWaterServerBinary/app/api/v1/endpoints/sensor_placement.py
T
jiang fa188af0b1 refactor(db)!: adopt project-routed pooled databases
Reorganize WNDB by responsibility and remove legacy scheme endpoints.\n\nRoute analysis and time-series access through project pools, preserve transactional realtime replacement, and refresh GIS materialized views after writes.\n\nAdd database architecture documentation, live pooling coverage, API contract updates, and executable container verification.\n\nBREAKING CHANGE: legacy scheme APIs and flat app.native.wndb module imports are removed.
2026-08-25 18:35:05 +08:00

282 lines
8.6 KiB
Python

import logging
from typing import Any
from urllib.parse import quote
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from fastapi.responses import StreamingResponse
from starlette.concurrency import run_in_threadpool
from app.algorithms.sensor import (
pressure_sensor_placement_kmeans,
pressure_sensor_placement_sensitivity,
)
from app.auth.metadata_dependencies import get_current_metadata_user
from app.auth.project_dependencies import ProjectContext, get_project_context
from app.domain.schemas.sensor_placement import (
SensorPointResponse,
SensorPlacementExportRequest,
SensorPlacementOptimizeRequest,
SensorPlacementSchemeResponse,
SensorPlacementUpdateRequest,
)
from app.services.sensor_placement import (
SensorPlacementConflictError,
SensorPlacementNotFoundError,
SensorPlacementValidationError,
build_sensor_placement_workbook,
can_edit_sensor_placement,
get_sensor_placement_candidate,
get_sensor_placement_run,
list_sensor_placement_runs,
update_sensor_placement_run,
)
router = APIRouter()
logger = logging.getLogger(__name__)
def _project_network(network: str, project_context: ProjectContext) -> str:
if network != project_context.project_code:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="请求的管网不属于当前项目",
)
return project_context.project_code
def _can_modify_project(project_context: ProjectContext) -> bool:
return project_context.project_role == "member"
def _require_project_write(
project_context: ProjectContext,
) -> None:
if not _can_modify_project(project_context):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="当前项目角色为只读,不能修改监测点方案",
)
def _service_http_error(exc: Exception) -> HTTPException:
if isinstance(exc, SensorPlacementNotFoundError):
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
if isinstance(exc, SensorPlacementConflictError):
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
)
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
)
def _get_run_response(
network: str,
run_id: UUID,
current_user: Any,
project_context: ProjectContext,
) -> dict[str, Any]:
try:
run = get_sensor_placement_run(network, run_id)
return {
**run,
"can_edit": (
_can_modify_project(project_context)
and can_edit_sensor_placement(current_user, run)
),
}
except (
SensorPlacementNotFoundError,
SensorPlacementValidationError,
) as exc:
raise _service_http_error(exc) from exc
@router.get(
"/sensor-placement-candidates/{node_id}",
response_model=SensorPointResponse,
summary="获取监测点候选节点详情",
)
async def get_sensor_placement_candidate_detail(
node_id: str = Path(..., min_length=1, max_length=32),
project_context: ProjectContext = Depends(get_project_context),
) -> dict[str, Any]:
try:
return await run_in_threadpool(
get_sensor_placement_candidate,
project_context.project_code,
node_id,
)
except SensorPlacementValidationError as exc:
raise _service_http_error(exc) from exc
@router.post(
"/sensor-placement-runs",
response_model=SensorPlacementSchemeResponse,
summary="创建并返回监测点优化方案",
)
async def optimize_sensor_placement_scheme(
payload: SensorPlacementOptimizeRequest,
project_context: ProjectContext = Depends(get_project_context),
current_user=Depends(get_current_metadata_user),
) -> dict[str, Any]:
network = _project_network(payload.network, project_context)
_require_project_write(project_context)
optimizer = (
pressure_sensor_placement_sensitivity
if payload.method == "sensitivity"
else pressure_sensor_placement_kmeans
)
try:
created = await run_in_threadpool(
optimizer,
name=network,
scheme_name=payload.run_name,
sensor_number=payload.sensor_count,
min_diameter=payload.min_diameter,
username=current_user.username,
)
run = get_sensor_placement_run(network, created["run_id"])
return {**run, "can_edit": True}
except (
SensorPlacementConflictError,
SensorPlacementValidationError,
ValueError,
) as exc:
raise _service_http_error(exc) from exc
except Exception as exc:
logger.exception("Sensor placement optimization failed")
raise HTTPException(
status_code=500,
detail="监测点优化失败,请稍后重试",
) from exc
@router.get(
"/sensor-placement-runs",
response_model=list[SensorPlacementSchemeResponse],
summary="获取监测点优化运行",
)
async def get_sensor_placement_runs(
project_context: ProjectContext = Depends(get_project_context),
) -> list[dict[str, Any]]:
return await run_in_threadpool(
list_sensor_placement_runs,
project_context.project_code,
)
@router.get(
"/sensor-placement-runs/{run_id}",
response_model=SensorPlacementSchemeResponse,
summary="获取监测点方案详情",
)
async def get_sensor_placement_run_detail(
run_id: UUID,
network: str = Query(..., min_length=1),
project_context: ProjectContext = Depends(get_project_context),
current_user=Depends(get_current_metadata_user),
) -> dict[str, Any]:
return _get_run_response(
_project_network(network, project_context),
run_id,
current_user,
project_context,
)
@router.put(
"/sensor-placement-runs/{run_id}",
response_model=SensorPlacementSchemeResponse,
summary="覆盖保存监测点方案",
)
async def overwrite_sensor_placement_run(
run_id: UUID,
payload: SensorPlacementUpdateRequest,
network: str = Query(..., min_length=1),
project_context: ProjectContext = Depends(get_project_context),
current_user=Depends(get_current_metadata_user),
) -> dict[str, Any]:
network = _project_network(network, project_context)
_require_project_write(project_context)
run = _get_run_response(
network,
run_id,
current_user,
project_context,
)
if not run["can_edit"]:
raise HTTPException(status_code=403, detail="无权修改该监测点优化运行")
try:
updated = update_sensor_placement_run(
network,
run_id,
expected_sensor_locations=payload.expected_sensor_locations,
sensor_locations=payload.sensor_locations,
)
return {**updated, "can_edit": True}
except (
SensorPlacementConflictError,
SensorPlacementNotFoundError,
SensorPlacementValidationError,
) as exc:
raise _service_http_error(exc) from exc
@router.post(
"/sensor-placement-runs/{run_id}/exports/excel",
summary="导出监测点工程清单",
)
async def export_sensor_placement_excel(
run_id: UUID,
payload: SensorPlacementExportRequest,
network: str = Query(..., min_length=1),
project_context: ProjectContext = Depends(get_project_context),
current_user=Depends(get_current_metadata_user),
) -> StreamingResponse:
network = _project_network(network, project_context)
run = _get_run_response(
network,
run_id,
current_user,
project_context,
)
if (
payload.sensor_locations != run["sensor_locations"]
and not run["can_edit"]
):
raise HTTPException(status_code=403, detail="无权导出该方案的未保存草稿")
try:
workbook = await run_in_threadpool(
build_sensor_placement_workbook,
network=network,
scheme=run,
sensor_location=payload.sensor_locations,
adjustment_status=payload.adjustment_status,
)
except SensorPlacementValidationError as exc:
raise _service_http_error(exc) from exc
filename = f"{run['name']}_监测点清单.xlsx"
encoded_filename = quote(filename)
return StreamingResponse(
workbook,
media_type=(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
),
headers={
"Content-Disposition": (
f"attachment; filename*=UTF-8''{encoded_filename}"
)
},
)