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}" ) }, )