import logging from typing import Any from urllib.parse import quote from fastapi import APIRouter, Depends, HTTPException, 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 ( SensorPlacementExportRequest, SensorPlacementOptimizeRequest, SensorPlacementSchemeResponse, SensorPlacementUpdateRequest, ) from app.services.sensor_placement import ( SensorPlacementConflictError, SensorPlacementNotFoundError, SensorPlacementValidationError, build_sensor_placement_workbook, can_edit_sensor_placement, get_sensor_placement_scheme, update_sensor_placement_scheme, ) 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_scheme_response( network: str, scheme_id: int, current_user: Any, project_context: ProjectContext, ) -> dict[str, Any]: try: scheme = get_sensor_placement_scheme(network, scheme_id) return { **scheme, "can_edit": ( _can_modify_project(project_context) and can_edit_sensor_placement(current_user, scheme) ), } except ( SensorPlacementNotFoundError, SensorPlacementValidationError, ) as exc: raise _service_http_error(exc) from exc @router.post( "/sensor-placement-optimization-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.scheme_name, sensor_number=payload.sensor_count, min_diameter=payload.min_diameter, username=current_user.username, ) scheme = get_sensor_placement_scheme(network, int(created["id"])) return {**scheme, "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-schemes/{scheme_id}", response_model=SensorPlacementSchemeResponse, summary="获取监测点方案详情", ) async def get_sensor_placement_scheme_detail( scheme_id: int, 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_scheme_response( _project_network(network, project_context), scheme_id, current_user, project_context, ) @router.put( "/sensor-placement-schemes/{scheme_id}", response_model=SensorPlacementSchemeResponse, summary="覆盖保存监测点方案", ) async def overwrite_sensor_placement_scheme( scheme_id: int, 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) scheme = _get_scheme_response( network, scheme_id, current_user, project_context, ) if not scheme["can_edit"]: raise HTTPException(status_code=403, detail="无权修改该监测点方案") try: updated = update_sensor_placement_scheme( network, scheme_id, expected_sensor_location=payload.expected_sensor_location, sensor_location=payload.sensor_location, ) return {**updated, "can_edit": True} except ( SensorPlacementConflictError, SensorPlacementNotFoundError, SensorPlacementValidationError, ) as exc: raise _service_http_error(exc) from exc @router.post( "/sensor-placement-schemes/{scheme_id}/exports/excel", summary="导出监测点工程清单", ) async def export_sensor_placement_excel( scheme_id: int, 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) scheme = _get_scheme_response( network, scheme_id, current_user, project_context, ) if ( payload.sensor_location != scheme["sensor_location"] and not scheme["can_edit"] ): raise HTTPException(status_code=403, detail="无权导出该方案的未保存草稿") try: workbook = await run_in_threadpool( build_sensor_placement_workbook, network=network, scheme=scheme, sensor_location=payload.sensor_location, adjustment_status=payload.adjustment_status, ) except SensorPlacementValidationError as exc: raise _service_http_error(exc) from exc filename = f"{scheme['scheme_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}" ) }, )