feat(sensor-placement): add editable scheme APIs
This commit is contained in:
@@ -1,14 +1,68 @@
|
|||||||
import psycopg
|
from contextlib import contextmanager
|
||||||
|
import fcntl
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from app.algorithms.sensor import kmeans as kmeans_sensor
|
from app.algorithms.sensor import kmeans as kmeans_sensor
|
||||||
from app.algorithms.sensor import sensitivity
|
from app.algorithms.sensor import sensitivity
|
||||||
from app.core.config import get_pgconn_string
|
from app.native.wndb.s42_sensor_placement import create_sensor_placement
|
||||||
|
from app.services.sensor_placement import (
|
||||||
|
SensorPlacementValidationError,
|
||||||
|
validate_sensor_placement_nodes,
|
||||||
|
)
|
||||||
from app.services.tjnetwork import dump_inp
|
from app.services.tjnetwork import dump_inp
|
||||||
|
|
||||||
|
|
||||||
|
def _sensor_inp_path(name: str) -> Path:
|
||||||
|
if (
|
||||||
|
not name
|
||||||
|
or name in {".", ".."}
|
||||||
|
or "/" in name
|
||||||
|
or "\\" in name
|
||||||
|
or "\x00" in name
|
||||||
|
):
|
||||||
|
raise SensorPlacementValidationError("管网名称不是有效的项目标识")
|
||||||
|
return Path("db_inp") / f"{name}.db.inp"
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _sensor_inp_lock(name: str):
|
||||||
|
inp_path = _sensor_inp_path(name)
|
||||||
|
inp_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
lock_path = inp_path.with_suffix(".sensor.lock")
|
||||||
|
with lock_path.open("w", encoding="utf-8") as lock_file:
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
yield inp_path
|
||||||
|
finally:
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_validated_placement(
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
scheme_name: str,
|
||||||
|
min_diameter: int,
|
||||||
|
username: str,
|
||||||
|
sensor_location: list[str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
validate_sensor_placement_nodes(name, sensor_location)
|
||||||
|
return create_sensor_placement(
|
||||||
|
name,
|
||||||
|
scheme_name=scheme_name,
|
||||||
|
min_diameter=min_diameter,
|
||||||
|
username=username,
|
||||||
|
sensor_location=sensor_location,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def pressure_sensor_placement_sensitivity(
|
def pressure_sensor_placement_sensitivity(
|
||||||
name: str, scheme_name: str, sensor_number: int, min_diameter: int, username: str
|
name: str,
|
||||||
) -> None:
|
scheme_name: str,
|
||||||
|
sensor_number: int,
|
||||||
|
min_diameter: int,
|
||||||
|
username: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
基于改进灵敏度法进行压力监测点优化布置
|
基于改进灵敏度法进行压力监测点优化布置
|
||||||
:param name: 数据库名称
|
:param name: 数据库名称
|
||||||
@@ -16,41 +70,32 @@ def pressure_sensor_placement_sensitivity(
|
|||||||
:param sensor_number: 传感器数目
|
:param sensor_number: 传感器数目
|
||||||
:param min_diameter: 最小管径
|
:param min_diameter: 最小管径
|
||||||
:param username: 用户名
|
:param username: 用户名
|
||||||
:return:
|
:return: 新建的监测点方案
|
||||||
"""
|
"""
|
||||||
|
with _sensor_inp_lock(name):
|
||||||
sensor_location = sensitivity.get_ID(
|
sensor_location = sensitivity.get_ID(
|
||||||
name=name, sensor_num=sensor_number, min_diameter=min_diameter
|
name=name,
|
||||||
|
sensor_num=sensor_number,
|
||||||
|
min_diameter=min_diameter,
|
||||||
)
|
)
|
||||||
try:
|
return _create_validated_placement(
|
||||||
conn_string = get_pgconn_string(db_name=name)
|
name,
|
||||||
with psycopg.connect(conn_string) as conn:
|
scheme_name=scheme_name,
|
||||||
with conn.cursor() as cur:
|
min_diameter=min_diameter,
|
||||||
sql = """
|
username=username,
|
||||||
INSERT INTO sensor_placement (scheme_name, sensor_number, min_diameter, username, sensor_location)
|
sensor_location=sensor_location,
|
||||||
VALUES (%s, %s, %s, %s, %s)
|
|
||||||
"""
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
sql,
|
|
||||||
(
|
|
||||||
scheme_name,
|
|
||||||
sensor_number,
|
|
||||||
min_diameter,
|
|
||||||
username,
|
|
||||||
sensor_location,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
conn.commit()
|
|
||||||
print("方案信息存储成功!")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"存储方案信息时出错:{e}")
|
|
||||||
|
|
||||||
|
|
||||||
# 2025/08/21
|
# 2025/08/21
|
||||||
# 基于kmeans聚类法进行压力监测点优化布置
|
# 基于kmeans聚类法进行压力监测点优化布置
|
||||||
def pressure_sensor_placement_kmeans(
|
def pressure_sensor_placement_kmeans(
|
||||||
name: str, scheme_name: str, sensor_number: int, min_diameter: int, username: str
|
name: str,
|
||||||
) -> None:
|
scheme_name: str,
|
||||||
|
sensor_number: int,
|
||||||
|
min_diameter: int,
|
||||||
|
username: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
基于聚类法进行压力监测点优化布置
|
基于聚类法进行压力监测点优化布置
|
||||||
:param name: 数据库名称(注意,此处数据库名称也是inp文件名称,inp文件与pg库名要一样)
|
:param name: 数据库名称(注意,此处数据库名称也是inp文件名称,inp文件与pg库名要一样)
|
||||||
@@ -58,34 +103,20 @@ def pressure_sensor_placement_kmeans(
|
|||||||
:param sensor_number: 传感器数目
|
:param sensor_number: 传感器数目
|
||||||
:param min_diameter: 最小管径
|
:param min_diameter: 最小管径
|
||||||
:param username: 用户名
|
:param username: 用户名
|
||||||
:return:
|
:return: 新建的监测点方案
|
||||||
"""
|
"""
|
||||||
# dump_inp
|
# dump_inp
|
||||||
inp_name = f"./db_inp/{name}.db.inp"
|
with _sensor_inp_lock(name) as inp_path:
|
||||||
dump_inp(name, inp_name, "2")
|
dump_inp(name, str(inp_path), "2")
|
||||||
sensor_location = kmeans_sensor.kmeans_sensor_placement(
|
sensor_location = kmeans_sensor.kmeans_sensor_placement(
|
||||||
name=name, sensor_num=sensor_number, min_diameter=min_diameter
|
name=name,
|
||||||
|
sensor_num=sensor_number,
|
||||||
|
min_diameter=min_diameter,
|
||||||
)
|
)
|
||||||
try:
|
return _create_validated_placement(
|
||||||
conn_string = get_pgconn_string(db_name=name)
|
name,
|
||||||
with psycopg.connect(conn_string) as conn:
|
scheme_name=scheme_name,
|
||||||
with conn.cursor() as cur:
|
min_diameter=min_diameter,
|
||||||
sql = """
|
username=username,
|
||||||
INSERT INTO sensor_placement (scheme_name, sensor_number, min_diameter, username, sensor_location)
|
sensor_location=sensor_location,
|
||||||
VALUES (%s, %s, %s, %s, %s)
|
|
||||||
"""
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
sql,
|
|
||||||
(
|
|
||||||
scheme_name,
|
|
||||||
sensor_number,
|
|
||||||
min_diameter,
|
|
||||||
username,
|
|
||||||
sensor_location,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
conn.commit()
|
|
||||||
print("方案信息存储成功!")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"存储方案信息时出错:{e}")
|
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
from fastapi import APIRouter, Request, Depends, Query, Path, Body
|
from typing import Any
|
||||||
from typing import Any, List, Dict, Union
|
|
||||||
|
from fastapi import APIRouter, Query
|
||||||
|
|
||||||
from app.services.tjnetwork import (
|
from app.services.tjnetwork import (
|
||||||
Any,
|
|
||||||
get_all_scada_info,
|
|
||||||
get_major_node_coords,
|
get_major_node_coords,
|
||||||
get_major_pipe_nodes,
|
get_major_pipe_nodes,
|
||||||
get_network_in_extent,
|
get_network_in_extent,
|
||||||
get_network_link_nodes,
|
get_network_link_nodes,
|
||||||
get_network_node_coords,
|
|
||||||
get_node_coord,
|
get_node_coord,
|
||||||
)
|
)
|
||||||
from app.auth.metadata_dependencies import get_current_metadata_user
|
|
||||||
from app.infra.cache.redis_client import redis_client, encode_datetime, decode_datetime
|
|
||||||
import msgpack
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -62,33 +58,6 @@ async def fastapi_get_network_in_extent(
|
|||||||
"""获取地理范围内的网络几何信息。"""
|
"""获取地理范围内的网络几何信息。"""
|
||||||
return get_network_in_extent(network, x1, y1, x2, y2)
|
return get_network_in_extent(network, x1, y1, x2, y2)
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/getnetworkgeometries/",
|
|
||||||
dependencies=[Depends(get_current_metadata_user)],
|
|
||||||
summary="获取完整网络几何信息",
|
|
||||||
description="获取整个水网的所有节点、管线和SCADA点的几何信息(需要身份验证)"
|
|
||||||
)
|
|
||||||
async def fastapi_get_network_geometries(
|
|
||||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""获取完整的网络几何信息,包括所有节点、管线和SCADA点。结果从缓存返回。"""
|
|
||||||
cache_key = f"getnetworkgeometries_{network}"
|
|
||||||
data = redis_client.get(cache_key)
|
|
||||||
if data:
|
|
||||||
loaded_dict = msgpack.unpackb(data, object_hook=decode_datetime)
|
|
||||||
return loaded_dict
|
|
||||||
|
|
||||||
coords = get_network_node_coords(network)
|
|
||||||
nodes = []
|
|
||||||
for node_id, coord in coords.items():
|
|
||||||
nodes.append(f"{node_id}:{coord['type']}:{coord['x']}:{coord['y']}")
|
|
||||||
links = get_network_link_nodes(network)
|
|
||||||
scadas = get_all_scada_info(network)
|
|
||||||
|
|
||||||
results = {"nodes": nodes, "links": links, "scadas": scadas}
|
|
||||||
redis_client.set(cache_key, msgpack.packb(results, default=encode_datetime))
|
|
||||||
return results
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/getmajornodecoords/",
|
"/getmajornodecoords/",
|
||||||
summary="获取主要节点坐标",
|
summary="获取主要节点坐标",
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
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 _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,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
scheme = get_sensor_placement_scheme(network, scheme_id)
|
||||||
|
return {
|
||||||
|
**scheme,
|
||||||
|
"can_edit": can_edit_sensor_placement(current_user, scheme),
|
||||||
|
}
|
||||||
|
except (
|
||||||
|
SensorPlacementNotFoundError,
|
||||||
|
SensorPlacementValidationError,
|
||||||
|
) as exc:
|
||||||
|
raise _service_http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sensor-placement-schemes/optimize",
|
||||||
|
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)
|
||||||
|
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 (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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
scheme = _get_scheme_response(network, scheme_id, current_user)
|
||||||
|
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)
|
||||||
|
if (
|
||||||
|
payload.sensor_location != scheme["sensor_location"]
|
||||||
|
and not scheme["can_edit"]
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="无权导出该方案的未保存草稿")
|
||||||
|
|
||||||
|
try:
|
||||||
|
workbook = 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}"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -5,6 +5,7 @@ from app.api.v1.endpoints import (
|
|||||||
project,
|
project,
|
||||||
simulation,
|
simulation,
|
||||||
scada,
|
scada,
|
||||||
|
sensor_placement,
|
||||||
extension,
|
extension,
|
||||||
snapshots,
|
snapshots,
|
||||||
# data_query,
|
# data_query,
|
||||||
@@ -89,6 +90,7 @@ api_router.include_router(visuals.router, tags=["Visuals"])
|
|||||||
api_router.include_router(simulation.router, tags=["Simulation Control"])
|
api_router.include_router(simulation.router, tags=["Simulation Control"])
|
||||||
# api_router.include_router(data_query.router, tags=["Data Query & InfluxDB"])
|
# api_router.include_router(data_query.router, tags=["Data Query & InfluxDB"])
|
||||||
api_router.include_router(scada.router)
|
api_router.include_router(scada.router)
|
||||||
|
api_router.include_router(sensor_placement.router, tags=["Sensor Placement"])
|
||||||
api_router.include_router(snapshots.router, tags=["Snapshots"])
|
api_router.include_router(snapshots.router, tags=["Snapshots"])
|
||||||
api_router.include_router(users.router, tags=["Users"])
|
api_router.include_router(users.router, tags=["Users"])
|
||||||
api_router.include_router(schemes.router, tags=["Schemes"])
|
api_router.include_router(schemes.router, tags=["Schemes"])
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
AdjustmentStatus = Literal["current", "original", "added", "replaced"]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_location_ids(value: list[str]) -> list[str]:
|
||||||
|
normalized = [str(item).strip() for item in value]
|
||||||
|
if any(not item for item in normalized):
|
||||||
|
raise ValueError("sensor locations cannot contain blank node IDs")
|
||||||
|
if len(set(normalized)) != len(normalized):
|
||||||
|
raise ValueError("sensor locations cannot contain duplicate node IDs")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class SensorPlacementOptimizeRequest(BaseModel):
|
||||||
|
network: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=1,
|
||||||
|
max_length=63,
|
||||||
|
pattern=r"^[^/\\\x00]+$",
|
||||||
|
)
|
||||||
|
scheme_name: str = Field(..., min_length=1, max_length=32)
|
||||||
|
sensor_type: Literal["pressure"]
|
||||||
|
method: Literal["sensitivity", "kmeans"]
|
||||||
|
sensor_count: int = Field(..., gt=0, le=200)
|
||||||
|
min_diameter: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
@field_validator("network")
|
||||||
|
@classmethod
|
||||||
|
def validate_network(cls, value: str) -> str:
|
||||||
|
normalized = value.strip()
|
||||||
|
if normalized in {".", ".."}:
|
||||||
|
raise ValueError("network must be a project identifier")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class SensorPlacementUpdateRequest(BaseModel):
|
||||||
|
expected_sensor_location: list[str] = Field(..., min_length=1)
|
||||||
|
sensor_location: list[str] = Field(..., min_length=1)
|
||||||
|
|
||||||
|
@field_validator("expected_sensor_location", "sensor_location")
|
||||||
|
@classmethod
|
||||||
|
def validate_locations(cls, value: list[str]) -> list[str]:
|
||||||
|
return _normalize_location_ids(value)
|
||||||
|
|
||||||
|
|
||||||
|
class SensorPlacementExportRequest(BaseModel):
|
||||||
|
sensor_location: list[str] = Field(..., min_length=1)
|
||||||
|
adjustment_status: dict[str, AdjustmentStatus] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
@field_validator("sensor_location")
|
||||||
|
@classmethod
|
||||||
|
def validate_locations(cls, value: list[str]) -> list[str]:
|
||||||
|
return _normalize_location_ids(value)
|
||||||
|
|
||||||
|
|
||||||
|
class SensorPointResponse(BaseModel):
|
||||||
|
node_id: str
|
||||||
|
project_x: float
|
||||||
|
project_y: float
|
||||||
|
map_x: float
|
||||||
|
map_y: float
|
||||||
|
longitude: float
|
||||||
|
latitude: float
|
||||||
|
elevation: float
|
||||||
|
|
||||||
|
|
||||||
|
class SensorPlacementSchemeResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
scheme_name: str
|
||||||
|
sensor_number: int
|
||||||
|
min_diameter: int
|
||||||
|
username: str
|
||||||
|
create_time: datetime
|
||||||
|
sensor_location: list[str]
|
||||||
|
sensor_points: list[SensorPointResponse]
|
||||||
|
can_edit: bool = False
|
||||||
@@ -58,6 +58,8 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
|||||||
"/meta/projects",
|
"/meta/projects",
|
||||||
"/api/v1/openproject/",
|
"/api/v1/openproject/",
|
||||||
"/openproject/",
|
"/openproject/",
|
||||||
|
"/api/v1/audit/session-events",
|
||||||
|
"/audit/session-events",
|
||||||
}
|
}
|
||||||
EXCLUDED_PATH_PREFIXES = (
|
EXCLUDED_PATH_PREFIXES = (
|
||||||
)
|
)
|
||||||
@@ -80,27 +82,9 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
|||||||
request_data = None
|
request_data = None
|
||||||
if should_capture_body:
|
if should_capture_body:
|
||||||
try:
|
try:
|
||||||
# 注意:读取 body 后需要重新设置,避免影响后续处理
|
|
||||||
original_receive = request._receive
|
|
||||||
body = await request.body()
|
body = await request.body()
|
||||||
if body:
|
if body:
|
||||||
request_data = json.loads(body.decode())
|
request_data = json.loads(body.decode())
|
||||||
|
|
||||||
# 重新构造请求以供后续使用:仅回放一次,后续回落原始 receive
|
|
||||||
body_sent = False
|
|
||||||
|
|
||||||
async def receive():
|
|
||||||
nonlocal body_sent
|
|
||||||
if not body_sent:
|
|
||||||
body_sent = True
|
|
||||||
return {
|
|
||||||
"type": "http.request",
|
|
||||||
"body": body,
|
|
||||||
"more_body": False,
|
|
||||||
}
|
|
||||||
return await original_receive()
|
|
||||||
|
|
||||||
request._receive = receive
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to read request body for audit: {e}")
|
logger.warning(f"Failed to read request body for audit: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -320,7 +320,11 @@ from .s23_options_util import (
|
|||||||
from .s23_options_util import get_option_v3_schema, get_option_v3
|
from .s23_options_util import get_option_v3_schema, get_option_v3
|
||||||
from .batch_api import set_option_v3_ex
|
from .batch_api import set_option_v3_ex
|
||||||
|
|
||||||
from .s24_coordinates import get_node_coord, get_nodes_in_extent, get_links_in_extent
|
from .s24_coordinates import (
|
||||||
|
get_links_in_extent,
|
||||||
|
get_node_coord,
|
||||||
|
get_nodes_in_extent,
|
||||||
|
)
|
||||||
|
|
||||||
from .s25_vertices import (
|
from .s25_vertices import (
|
||||||
get_vertex_schema,
|
get_vertex_schema,
|
||||||
@@ -468,6 +472,11 @@ from .s41_pipe_risk_probability import (
|
|||||||
get_pipe_risk_probability_geometries,
|
get_pipe_risk_probability_geometries,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .s42_sensor_placement import get_all_sensor_placements
|
from .s42_sensor_placement import (
|
||||||
|
get_all_sensor_placements,
|
||||||
|
get_sensor_placement,
|
||||||
|
get_sensor_placement_nodes,
|
||||||
|
update_sensor_placement,
|
||||||
|
)
|
||||||
|
|
||||||
from .s43_burst_locate_result import get_all_burst_locate_results
|
from .s43_burst_locate_result import get_all_burst_locate_results
|
||||||
|
|||||||
@@ -1,7 +1,109 @@
|
|||||||
from .database import *
|
from typing import Any
|
||||||
from .s0_base import *
|
|
||||||
from .s42_sensor_placement import *
|
|
||||||
import json
|
|
||||||
|
|
||||||
def get_all_sensor_placements(name: str) -> list[dict[Any, Any]]:
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
from .connection import project_connection
|
||||||
|
from .database import read_all
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_sensor_placements(name: str) -> list[dict[str, Any]]:
|
||||||
return read_all(name, "select * from sensor_placement")
|
return read_all(name, "select * from sensor_placement")
|
||||||
|
|
||||||
|
|
||||||
|
def create_sensor_placement(
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
scheme_name: str,
|
||||||
|
min_diameter: int,
|
||||||
|
username: str,
|
||||||
|
sensor_location: list[str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
with project_connection(name) as conn:
|
||||||
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sensor_placement (
|
||||||
|
scheme_name,
|
||||||
|
sensor_number,
|
||||||
|
min_diameter,
|
||||||
|
username,
|
||||||
|
sensor_location
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
scheme_name,
|
||||||
|
len(sensor_location),
|
||||||
|
min_diameter,
|
||||||
|
username,
|
||||||
|
sensor_location,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
created = cur.fetchone()
|
||||||
|
if created is None:
|
||||||
|
raise RuntimeError("监测点方案写入失败")
|
||||||
|
return dict(created)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_placement(name: str, scheme_id: int) -> dict[str, Any] | None:
|
||||||
|
with project_connection(name) as conn:
|
||||||
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT * FROM sensor_placement WHERE id = %s",
|
||||||
|
(scheme_id,),
|
||||||
|
)
|
||||||
|
return cur.fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_placement_nodes(
|
||||||
|
name: str,
|
||||||
|
node_ids: list[str],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if not node_ids:
|
||||||
|
return []
|
||||||
|
with project_connection(name) as conn:
|
||||||
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT ON (gj.id)
|
||||||
|
gj.id AS node_id,
|
||||||
|
gj.elevation,
|
||||||
|
ST_X(c.coord) AS project_x,
|
||||||
|
ST_Y(c.coord) AS project_y,
|
||||||
|
ST_X(gj.geom) AS map_x,
|
||||||
|
ST_Y(gj.geom) AS map_y
|
||||||
|
FROM geo_junctions_mat AS gj
|
||||||
|
JOIN coordinates AS c ON c.node = gj.id
|
||||||
|
WHERE gj.id = ANY(%s)
|
||||||
|
ORDER BY gj.id
|
||||||
|
""",
|
||||||
|
(node_ids,),
|
||||||
|
)
|
||||||
|
return list(cur.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
def update_sensor_placement(
|
||||||
|
name: str,
|
||||||
|
scheme_id: int,
|
||||||
|
*,
|
||||||
|
expected_sensor_location: list[str],
|
||||||
|
sensor_location: list[str],
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
with project_connection(name) as conn:
|
||||||
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE sensor_placement
|
||||||
|
SET sensor_location = %s, sensor_number = %s
|
||||||
|
WHERE id = %s AND sensor_location = %s
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
sensor_location,
|
||||||
|
len(sensor_location),
|
||||||
|
scheme_id,
|
||||||
|
expected_sensor_location,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return cur.fetchone()
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Alignment, Font, PatternFill
|
||||||
|
from openpyxl.worksheet.worksheet import Worksheet
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
from pyproj import Transformer
|
||||||
|
|
||||||
|
from app.native import wndb
|
||||||
|
|
||||||
|
|
||||||
|
class SensorPlacementNotFoundError(LookupError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SensorPlacementValidationError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SensorPlacementConflictError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_to_wgs84 = Transformer.from_crs("EPSG:3857", "EPSG:4326", always_xy=True)
|
||||||
|
_STATUS_LABELS = {
|
||||||
|
"current": "当前方案",
|
||||||
|
"original": "原方案",
|
||||||
|
"added": "新增",
|
||||||
|
"replaced": "替换",
|
||||||
|
}
|
||||||
|
_COORDINATE_DESCRIPTION = (
|
||||||
|
"工程 X/Y: 项目地方坐标系;地图 X/Y: EPSG:3857;经纬度: WGS84"
|
||||||
|
)
|
||||||
|
_LIST_HEADERS = (
|
||||||
|
"序号",
|
||||||
|
"节点 ID",
|
||||||
|
"经度",
|
||||||
|
"纬度",
|
||||||
|
"工程 X",
|
||||||
|
"工程 Y",
|
||||||
|
"地图 X",
|
||||||
|
"地图 Y",
|
||||||
|
"高程",
|
||||||
|
"调整状态",
|
||||||
|
)
|
||||||
|
_LIST_COLUMN_WIDTHS = (8, 20, 16, 16, 18, 18, 18, 18, 14, 14)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_locations(sensor_location: list[str]) -> list[str]:
|
||||||
|
normalized = [str(node_id).strip() for node_id in sensor_location]
|
||||||
|
if not normalized or any(not node_id for node_id in normalized):
|
||||||
|
raise SensorPlacementValidationError("监测点列表不能为空")
|
||||||
|
if len(set(normalized)) != len(normalized):
|
||||||
|
raise SensorPlacementValidationError("监测点列表不能包含重复节点")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _sensor_points(
|
||||||
|
network: str,
|
||||||
|
sensor_location: list[str],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
nodes = wndb.get_sensor_placement_nodes(network, sensor_location)
|
||||||
|
by_id = {str(node["node_id"]): node for node in nodes}
|
||||||
|
missing = [node_id for node_id in sensor_location if node_id not in by_id]
|
||||||
|
if missing:
|
||||||
|
raise SensorPlacementValidationError(
|
||||||
|
f"以下节点不存在或不是 junction: {', '.join(missing)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
points: list[dict[str, Any]] = []
|
||||||
|
for node_id in sensor_location:
|
||||||
|
node = by_id[node_id]
|
||||||
|
project_x = float(node["project_x"])
|
||||||
|
project_y = float(node["project_y"])
|
||||||
|
map_x = float(node["map_x"])
|
||||||
|
map_y = float(node["map_y"])
|
||||||
|
longitude, latitude = _to_wgs84.transform(map_x, map_y)
|
||||||
|
points.append(
|
||||||
|
{
|
||||||
|
"node_id": node_id,
|
||||||
|
"project_x": project_x,
|
||||||
|
"project_y": project_y,
|
||||||
|
"map_x": map_x,
|
||||||
|
"map_y": map_y,
|
||||||
|
"longitude": float(longitude),
|
||||||
|
"latitude": float(latitude),
|
||||||
|
"elevation": float(node["elevation"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def validate_sensor_placement_nodes(
|
||||||
|
network: str,
|
||||||
|
sensor_location: list[str],
|
||||||
|
) -> None:
|
||||||
|
_sensor_points(network, _normalize_locations(sensor_location))
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_placement_scheme(network: str, scheme_id: int) -> dict[str, Any]:
|
||||||
|
scheme = wndb.get_sensor_placement(network, scheme_id)
|
||||||
|
if scheme is None:
|
||||||
|
raise SensorPlacementNotFoundError("监测点方案不存在")
|
||||||
|
|
||||||
|
locations = [str(item) for item in (scheme.get("sensor_location") or [])]
|
||||||
|
return {
|
||||||
|
**scheme,
|
||||||
|
"sensor_number": len(locations),
|
||||||
|
"sensor_location": locations,
|
||||||
|
"sensor_points": _sensor_points(network, locations),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def update_sensor_placement_scheme(
|
||||||
|
network: str,
|
||||||
|
scheme_id: int,
|
||||||
|
*,
|
||||||
|
expected_sensor_location: list[str],
|
||||||
|
sensor_location: list[str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
expected = _normalize_locations(expected_sensor_location)
|
||||||
|
next_locations = _normalize_locations(sensor_location)
|
||||||
|
_sensor_points(network, next_locations)
|
||||||
|
|
||||||
|
updated = wndb.update_sensor_placement(
|
||||||
|
network,
|
||||||
|
scheme_id,
|
||||||
|
expected_sensor_location=expected,
|
||||||
|
sensor_location=next_locations,
|
||||||
|
)
|
||||||
|
if updated is None:
|
||||||
|
if wndb.get_sensor_placement(network, scheme_id) is None:
|
||||||
|
raise SensorPlacementNotFoundError("监测点方案不存在")
|
||||||
|
raise SensorPlacementConflictError("方案已被其他用户修改,请重新加载")
|
||||||
|
return get_sensor_placement_scheme(network, scheme_id)
|
||||||
|
|
||||||
|
|
||||||
|
def can_edit_sensor_placement(user: Any, scheme: dict[str, Any]) -> bool:
|
||||||
|
return bool(
|
||||||
|
getattr(user, "is_superuser", False)
|
||||||
|
or getattr(user, "role", None) == "admin"
|
||||||
|
or getattr(user, "username", None) == scheme.get("username")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_excel_text(value: Any) -> str:
|
||||||
|
text = "" if value is None else str(value)
|
||||||
|
if text.startswith(("=", "+", "-", "@")):
|
||||||
|
return f"'{text}"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _populate_info_sheet(
|
||||||
|
sheet: Worksheet,
|
||||||
|
*,
|
||||||
|
network: str,
|
||||||
|
scheme: dict[str, Any],
|
||||||
|
location_count: int,
|
||||||
|
is_draft: bool,
|
||||||
|
) -> None:
|
||||||
|
created_at = scheme["create_time"]
|
||||||
|
if isinstance(created_at, datetime):
|
||||||
|
created_at = created_at.isoformat(timespec="minutes")
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
("项目", network),
|
||||||
|
("方案名称", scheme["scheme_name"]),
|
||||||
|
("监测点数量", location_count),
|
||||||
|
("最小管径", scheme["min_diameter"]),
|
||||||
|
("创建人", scheme["username"]),
|
||||||
|
("创建时间", created_at),
|
||||||
|
("导出时间", datetime.now().astimezone().isoformat(timespec="minutes")),
|
||||||
|
("文档状态", "未保存草稿" if is_draft else "当前方案"),
|
||||||
|
("坐标说明", _COORDINATE_DESCRIPTION),
|
||||||
|
]
|
||||||
|
for row_index, (label, value) in enumerate(rows, start=1):
|
||||||
|
sheet.cell(row=row_index, column=1, value=label)
|
||||||
|
safe_value = _safe_excel_text(value) if isinstance(value, str) else value
|
||||||
|
sheet.cell(row=row_index, column=2, value=safe_value)
|
||||||
|
sheet.column_dimensions["A"].width = 18
|
||||||
|
sheet.column_dimensions["B"].width = 64
|
||||||
|
|
||||||
|
|
||||||
|
def _populate_list_sheet(
|
||||||
|
sheet: Worksheet,
|
||||||
|
*,
|
||||||
|
points: list[dict[str, Any]],
|
||||||
|
adjustment_status: dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
sheet.append(_LIST_HEADERS)
|
||||||
|
for index, point in enumerate(points, start=1):
|
||||||
|
status = adjustment_status.get(point["node_id"], "current")
|
||||||
|
sheet.append(
|
||||||
|
[
|
||||||
|
index,
|
||||||
|
_safe_excel_text(point["node_id"]),
|
||||||
|
point["longitude"],
|
||||||
|
point["latitude"],
|
||||||
|
point["project_x"],
|
||||||
|
point["project_y"],
|
||||||
|
point["map_x"],
|
||||||
|
point["map_y"],
|
||||||
|
point["elevation"],
|
||||||
|
_STATUS_LABELS.get(status, "当前方案"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
header_fill = PatternFill("solid", fgColor="257DD4")
|
||||||
|
for cell in sheet[1]:
|
||||||
|
cell.fill = header_fill
|
||||||
|
cell.font = Font(color="FFFFFF", bold=True)
|
||||||
|
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||||
|
sheet.freeze_panes = "A2"
|
||||||
|
sheet.auto_filter.ref = sheet.dimensions
|
||||||
|
for index, width in enumerate(_LIST_COLUMN_WIDTHS, start=1):
|
||||||
|
sheet.column_dimensions[get_column_letter(index)].width = width
|
||||||
|
for row in sheet.iter_rows(min_row=2):
|
||||||
|
row[0].alignment = Alignment(horizontal="center")
|
||||||
|
for cell in row[2:9]:
|
||||||
|
cell.number_format = "0.000000"
|
||||||
|
|
||||||
|
|
||||||
|
def build_sensor_placement_workbook(
|
||||||
|
*,
|
||||||
|
network: str,
|
||||||
|
scheme: dict[str, Any],
|
||||||
|
sensor_location: list[str],
|
||||||
|
adjustment_status: dict[str, str],
|
||||||
|
) -> BytesIO:
|
||||||
|
locations = _normalize_locations(sensor_location)
|
||||||
|
points = _sensor_points(network, locations)
|
||||||
|
is_draft = locations != list(scheme["sensor_location"])
|
||||||
|
|
||||||
|
workbook = Workbook()
|
||||||
|
info_sheet = workbook.active
|
||||||
|
info_sheet.title = "方案信息"
|
||||||
|
_populate_info_sheet(
|
||||||
|
info_sheet,
|
||||||
|
network=network,
|
||||||
|
scheme=scheme,
|
||||||
|
location_count=len(locations),
|
||||||
|
is_draft=is_draft,
|
||||||
|
)
|
||||||
|
|
||||||
|
list_sheet = workbook.create_sheet("监测点清单")
|
||||||
|
_populate_list_sheet(
|
||||||
|
list_sheet,
|
||||||
|
points=points,
|
||||||
|
adjustment_status=adjustment_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
output = BytesIO()
|
||||||
|
workbook.save(output)
|
||||||
|
output.seek(0)
|
||||||
|
return output
|
||||||
@@ -1370,4 +1370,3 @@ def get_all_sensor_placements(name: str) -> list[dict[Any, Any]]:
|
|||||||
############################################################
|
############################################################
|
||||||
def get_all_burst_locate_results(name: str) -> list[dict[Any, Any]]:
|
def get_all_burst_locate_results(name: str) -> list[dict[Any, Any]]:
|
||||||
return api.get_all_burst_locate_results(name)
|
return api.get_all_burst_locate_results(name)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.infra.audit import middleware as audit_middleware
|
||||||
|
from app.infra.audit.middleware import AuditMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_streaming_response_survives_audit_body_capture(monkeypatch):
|
||||||
|
log_audit_event = AsyncMock()
|
||||||
|
monkeypatch.setattr(audit_middleware, "log_audit_event", log_audit_event)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_middleware(AuditMiddleware)
|
||||||
|
|
||||||
|
@app.post("/exports")
|
||||||
|
async def export(payload: dict[str, str]) -> StreamingResponse:
|
||||||
|
assert payload == {"format": "xlsx"}
|
||||||
|
return StreamingResponse(
|
||||||
|
BytesIO(b"xlsx"),
|
||||||
|
media_type=(
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = TestClient(app, raise_server_exceptions=False).post(
|
||||||
|
"/exports",
|
||||||
|
json={"format": "xlsx"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.content == b"xlsx"
|
||||||
|
log_audit_event.assert_awaited_once()
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from io import BytesIO
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.conftest import build_test_app, install_stub, load_module_from_path
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(LookupError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _scheme(**overrides):
|
||||||
|
value = {
|
||||||
|
"id": 7,
|
||||||
|
"scheme_name": "北区测压点",
|
||||||
|
"sensor_number": 2,
|
||||||
|
"min_diameter": 300,
|
||||||
|
"username": "alice",
|
||||||
|
"create_time": datetime(2026, 7, 30, 8, 0, tzinfo=timezone.utc),
|
||||||
|
"sensor_location": ["J1", "J2"],
|
||||||
|
"sensor_points": [
|
||||||
|
{
|
||||||
|
"node_id": "J1",
|
||||||
|
"project_x": 13500000.0,
|
||||||
|
"project_y": 3600000.0,
|
||||||
|
"map_x": 13500000.0,
|
||||||
|
"map_y": 3600000.0,
|
||||||
|
"longitude": 121.0,
|
||||||
|
"latitude": 31.0,
|
||||||
|
"elevation": 4.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node_id": "J2",
|
||||||
|
"project_x": 13500100.0,
|
||||||
|
"project_y": 3600100.0,
|
||||||
|
"map_x": 13500100.0,
|
||||||
|
"map_y": 3600100.0,
|
||||||
|
"longitude": 121.001,
|
||||||
|
"latitude": 31.001,
|
||||||
|
"elevation": 5.0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
value.update(overrides)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _load_module(monkeypatch):
|
||||||
|
install_stub(monkeypatch, "app.algorithms", package=True)
|
||||||
|
install_stub(
|
||||||
|
monkeypatch,
|
||||||
|
"app.algorithms.sensor",
|
||||||
|
{
|
||||||
|
"pressure_sensor_placement_kmeans": lambda **kwargs: {"id": 7},
|
||||||
|
"pressure_sensor_placement_sensitivity": lambda **kwargs: {"id": 7},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
install_stub(monkeypatch, "app.auth", package=True)
|
||||||
|
|
||||||
|
async def current_user():
|
||||||
|
return SimpleNamespace(
|
||||||
|
username="alice",
|
||||||
|
role="user",
|
||||||
|
is_superuser=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
install_stub(
|
||||||
|
monkeypatch,
|
||||||
|
"app.auth.metadata_dependencies",
|
||||||
|
{"get_current_metadata_user": current_user},
|
||||||
|
)
|
||||||
|
|
||||||
|
class ProjectContext:
|
||||||
|
def __init__(self, project_code: str):
|
||||||
|
self.project_code = project_code
|
||||||
|
|
||||||
|
async def project_context():
|
||||||
|
return ProjectContext("tjwater")
|
||||||
|
|
||||||
|
install_stub(
|
||||||
|
monkeypatch,
|
||||||
|
"app.auth.project_dependencies",
|
||||||
|
{
|
||||||
|
"ProjectContext": ProjectContext,
|
||||||
|
"get_project_context": project_context,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
install_stub(monkeypatch, "app.services", package=True)
|
||||||
|
install_stub(
|
||||||
|
monkeypatch,
|
||||||
|
"app.services.sensor_placement",
|
||||||
|
{
|
||||||
|
"SensorPlacementConflictError": ConflictError,
|
||||||
|
"SensorPlacementNotFoundError": NotFoundError,
|
||||||
|
"SensorPlacementValidationError": ValidationError,
|
||||||
|
"build_sensor_placement_workbook": lambda **kwargs: BytesIO(b"xlsx"),
|
||||||
|
"can_edit_sensor_placement": (
|
||||||
|
lambda user, scheme: user.username == scheme["username"]
|
||||||
|
or user.role == "admin"
|
||||||
|
or user.is_superuser
|
||||||
|
),
|
||||||
|
"get_sensor_placement_scheme": lambda network, scheme_id: _scheme(
|
||||||
|
id=scheme_id
|
||||||
|
),
|
||||||
|
"update_sensor_placement_scheme": (
|
||||||
|
lambda network, scheme_id, **kwargs: _scheme(
|
||||||
|
id=scheme_id,
|
||||||
|
sensor_location=kwargs["sensor_location"],
|
||||||
|
sensor_number=len(kwargs["sensor_location"]),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return load_module_from_path(
|
||||||
|
"tests_sensor_placement_endpoints_module",
|
||||||
|
"app/api/v1/endpoints/sensor_placement.py",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _client(module, user=None):
|
||||||
|
app = build_test_app(module.router, "/api/v1")
|
||||||
|
if user is None:
|
||||||
|
user = SimpleNamespace(
|
||||||
|
username="alice",
|
||||||
|
role="user",
|
||||||
|
is_superuser=False,
|
||||||
|
)
|
||||||
|
app.dependency_overrides[module.get_current_metadata_user] = lambda: user
|
||||||
|
app.dependency_overrides[module.get_project_context] = lambda: (
|
||||||
|
module.ProjectContext("tjwater")
|
||||||
|
)
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_optimize_returns_created_scheme(monkeypatch):
|
||||||
|
module = _load_module(monkeypatch)
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def optimize(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return {"id": 7}
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "pressure_sensor_placement_kmeans", optimize)
|
||||||
|
response = _client(module).post(
|
||||||
|
"/api/v1/sensor-placement-schemes/optimize",
|
||||||
|
json={
|
||||||
|
"network": "tjwater",
|
||||||
|
"scheme_name": "北区测压点",
|
||||||
|
"sensor_type": "pressure",
|
||||||
|
"method": "kmeans",
|
||||||
|
"sensor_count": 2,
|
||||||
|
"min_diameter": 300,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["sensor_location"] == ["J1", "J2"]
|
||||||
|
assert captured["username"] == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_optimize_rejects_unsupported_sensor_type(monkeypatch):
|
||||||
|
module = _load_module(monkeypatch)
|
||||||
|
response = _client(module).post(
|
||||||
|
"/api/v1/sensor-placement-schemes/optimize",
|
||||||
|
json={
|
||||||
|
"network": "tjwater",
|
||||||
|
"scheme_name": "北区测流点",
|
||||||
|
"sensor_type": "flow",
|
||||||
|
"method": "kmeans",
|
||||||
|
"sensor_count": 2,
|
||||||
|
"min_diameter": 300,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_optimize_rejects_network_outside_project_context(monkeypatch):
|
||||||
|
module = _load_module(monkeypatch)
|
||||||
|
response = _client(module).post(
|
||||||
|
"/api/v1/sensor-placement-schemes/optimize",
|
||||||
|
json={
|
||||||
|
"network": "other_project",
|
||||||
|
"scheme_name": "越权方案",
|
||||||
|
"sensor_type": "pressure",
|
||||||
|
"method": "kmeans",
|
||||||
|
"sensor_count": 2,
|
||||||
|
"min_diameter": 300,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_optimize_rejects_network_path_traversal(monkeypatch):
|
||||||
|
module = _load_module(monkeypatch)
|
||||||
|
response = _client(module).post(
|
||||||
|
"/api/v1/sensor-placement-schemes/optimize",
|
||||||
|
json={
|
||||||
|
"network": "../other_project",
|
||||||
|
"scheme_name": "非法路径",
|
||||||
|
"sensor_type": "pressure",
|
||||||
|
"method": "kmeans",
|
||||||
|
"sensor_count": 2,
|
||||||
|
"min_diameter": 300,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_optimize_rejects_unbounded_sensor_count(monkeypatch):
|
||||||
|
module = _load_module(monkeypatch)
|
||||||
|
response = _client(module).post(
|
||||||
|
"/api/v1/sensor-placement-schemes/optimize",
|
||||||
|
json={
|
||||||
|
"network": "tjwater",
|
||||||
|
"scheme_name": "超大方案",
|
||||||
|
"sensor_type": "pressure",
|
||||||
|
"method": "kmeans",
|
||||||
|
"sensor_count": 201,
|
||||||
|
"min_diameter": 300,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_rejects_non_owner(monkeypatch):
|
||||||
|
module = _load_module(monkeypatch)
|
||||||
|
response = _client(
|
||||||
|
module,
|
||||||
|
SimpleNamespace(username="bob", role="user", is_superuser=False),
|
||||||
|
).put(
|
||||||
|
"/api/v1/sensor-placement-schemes/7",
|
||||||
|
params={"network": "tjwater"},
|
||||||
|
json={
|
||||||
|
"expected_sensor_location": ["J1", "J2"],
|
||||||
|
"sensor_location": ["J1", "J3"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_can_overwrite_scheme(monkeypatch):
|
||||||
|
module = _load_module(monkeypatch)
|
||||||
|
response = _client(
|
||||||
|
module,
|
||||||
|
SimpleNamespace(username="ops", role="admin", is_superuser=False),
|
||||||
|
).put(
|
||||||
|
"/api/v1/sensor-placement-schemes/7",
|
||||||
|
params={"network": "tjwater"},
|
||||||
|
json={
|
||||||
|
"expected_sensor_location": ["J1", "J2"],
|
||||||
|
"sensor_location": ["J1", "J3"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["sensor_number"] == 2
|
||||||
|
assert response.json()["sensor_location"] == ["J1", "J3"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_maps_concurrent_change_to_409(monkeypatch):
|
||||||
|
module = _load_module(monkeypatch)
|
||||||
|
|
||||||
|
def conflict(*args, **kwargs):
|
||||||
|
raise ConflictError("方案已被其他用户修改,请重新加载")
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "update_sensor_placement_scheme", conflict)
|
||||||
|
response = _client(module).put(
|
||||||
|
"/api/v1/sensor-placement-schemes/7",
|
||||||
|
params={"network": "tjwater"},
|
||||||
|
json={
|
||||||
|
"expected_sensor_location": ["J1", "J2"],
|
||||||
|
"sensor_location": ["J1", "J3"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert "重新加载" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_rejects_duplicate_nodes_before_service(monkeypatch):
|
||||||
|
module = _load_module(monkeypatch)
|
||||||
|
response = _client(module).put(
|
||||||
|
"/api/v1/sensor-placement-schemes/7",
|
||||||
|
params={"network": "tjwater"},
|
||||||
|
json={
|
||||||
|
"expected_sensor_location": ["J1", "J2"],
|
||||||
|
"sensor_location": ["J1", "J1"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_returns_xlsx_download(monkeypatch):
|
||||||
|
module = _load_module(monkeypatch)
|
||||||
|
response = _client(module).post(
|
||||||
|
"/api/v1/sensor-placement-schemes/7/exports/excel",
|
||||||
|
params={"network": "tjwater"},
|
||||||
|
json={
|
||||||
|
"sensor_location": ["J1", "J2"],
|
||||||
|
"adjustment_status": {"J1": "original", "J2": "replaced"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.content == b"xlsx"
|
||||||
|
assert response.headers["content-type"].startswith(
|
||||||
|
"application/vnd.openxmlformats-officedocument"
|
||||||
|
)
|
||||||
|
assert "filename*=UTF-8" in response.headers["content-disposition"]
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
from app.native.wndb import s42_sensor_placement
|
||||||
|
from app.services import sensor_placement
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_project_cursor(monkeypatch):
|
||||||
|
cursor = MagicMock()
|
||||||
|
connection = MagicMock()
|
||||||
|
connection.cursor.return_value.__enter__.return_value = cursor
|
||||||
|
connection_context = MagicMock()
|
||||||
|
connection_context.__enter__.return_value = connection
|
||||||
|
monkeypatch.setattr(
|
||||||
|
s42_sensor_placement,
|
||||||
|
"project_connection",
|
||||||
|
lambda _network: connection_context,
|
||||||
|
)
|
||||||
|
return cursor
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_workbook_contains_engineering_columns(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sensor_placement,
|
||||||
|
"_sensor_points",
|
||||||
|
lambda network, locations: [
|
||||||
|
{
|
||||||
|
"node_id": "J1",
|
||||||
|
"project_x": 13500000.0,
|
||||||
|
"project_y": 3600000.0,
|
||||||
|
"map_x": 13500000.0,
|
||||||
|
"map_y": 3600000.0,
|
||||||
|
"longitude": 121.0,
|
||||||
|
"latitude": 31.0,
|
||||||
|
"elevation": 4.5,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
scheme = {
|
||||||
|
"id": 7,
|
||||||
|
"scheme_name": "北区测压点",
|
||||||
|
"sensor_number": 2,
|
||||||
|
"min_diameter": 300,
|
||||||
|
"username": "alice",
|
||||||
|
"create_time": datetime(2026, 7, 30, tzinfo=timezone.utc),
|
||||||
|
"sensor_location": ["J1", "J2"],
|
||||||
|
}
|
||||||
|
|
||||||
|
output = sensor_placement.build_sensor_placement_workbook(
|
||||||
|
network="tjwater",
|
||||||
|
scheme=scheme,
|
||||||
|
sensor_location=["J1"],
|
||||||
|
adjustment_status={"J1": "replaced"},
|
||||||
|
)
|
||||||
|
workbook = load_workbook(output)
|
||||||
|
|
||||||
|
assert workbook.sheetnames == ["方案信息", "监测点清单"]
|
||||||
|
headers = [cell.value for cell in workbook["监测点清单"][1]]
|
||||||
|
assert headers == [
|
||||||
|
"序号",
|
||||||
|
"节点 ID",
|
||||||
|
"经度",
|
||||||
|
"纬度",
|
||||||
|
"工程 X",
|
||||||
|
"工程 Y",
|
||||||
|
"地图 X",
|
||||||
|
"地图 Y",
|
||||||
|
"高程",
|
||||||
|
"调整状态",
|
||||||
|
]
|
||||||
|
assert workbook["监测点清单"]["J2"].value == "替换"
|
||||||
|
assert workbook["方案信息"]["B8"].value == "未保存草稿"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sensor_points_keep_engineering_coordinates_and_transform_map_coordinates(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sensor_placement.wndb,
|
||||||
|
"get_sensor_placement_nodes",
|
||||||
|
lambda network, node_ids: [
|
||||||
|
{
|
||||||
|
"node_id": "J1",
|
||||||
|
"project_x": 3038.94,
|
||||||
|
"project_y": -34446.59,
|
||||||
|
"map_x": 13525191.530279,
|
||||||
|
"map_y": 3622984.760237,
|
||||||
|
"elevation": 4.5,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
point = sensor_placement._sensor_points("tjwater", ["J1"])[0]
|
||||||
|
|
||||||
|
assert point["project_x"] == 3038.94
|
||||||
|
assert point["project_y"] == -34446.59
|
||||||
|
assert point["longitude"] == pytest.approx(121.498863, abs=1e-6)
|
||||||
|
assert point["latitude"] == pytest.approx(30.924784, abs=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_validates_nodes_before_write(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sensor_placement.wndb,
|
||||||
|
"get_sensor_placement_nodes",
|
||||||
|
lambda network, node_ids: [],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
sensor_placement.update_sensor_placement_scheme(
|
||||||
|
"tjwater",
|
||||||
|
7,
|
||||||
|
expected_sensor_location=["J1"],
|
||||||
|
sensor_location=["missing"],
|
||||||
|
)
|
||||||
|
except sensor_placement.SensorPlacementValidationError as exc:
|
||||||
|
assert "missing" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected invalid node to be rejected")
|
||||||
|
|
||||||
|
|
||||||
|
def test_sensor_nodes_use_materialized_web_mercator_geometry(monkeypatch):
|
||||||
|
cursor = _mock_project_cursor(monkeypatch)
|
||||||
|
cursor.fetchall.return_value = []
|
||||||
|
|
||||||
|
s42_sensor_placement.get_sensor_placement_nodes("tjwater", ["J1"])
|
||||||
|
|
||||||
|
query = cursor.execute.call_args.args[0]
|
||||||
|
assert "geo_junctions_mat" in query
|
||||||
|
assert "ST_X(c.coord)" in query
|
||||||
|
assert "ST_Y(c.coord)" in query
|
||||||
|
assert "ST_X(gj.geom)" in query
|
||||||
|
assert "ST_Y(gj.geom)" in query
|
||||||
|
|
||||||
|
|
||||||
|
def test_workbook_escapes_formula_in_scheme_metadata(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sensor_placement,
|
||||||
|
"_sensor_points",
|
||||||
|
lambda network, locations: [
|
||||||
|
{
|
||||||
|
"node_id": "J1",
|
||||||
|
"project_x": 3038.94,
|
||||||
|
"project_y": -34446.59,
|
||||||
|
"map_x": 13525191.53,
|
||||||
|
"map_y": 3622984.76,
|
||||||
|
"longitude": 121.49,
|
||||||
|
"latitude": 30.92,
|
||||||
|
"elevation": 4.5,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
output = sensor_placement.build_sensor_placement_workbook(
|
||||||
|
network="tjwater",
|
||||||
|
scheme={
|
||||||
|
"scheme_name": "=1+1",
|
||||||
|
"sensor_location": ["J1"],
|
||||||
|
"min_diameter": 300,
|
||||||
|
"username": "alice",
|
||||||
|
"create_time": datetime(2026, 7, 30, tzinfo=timezone.utc),
|
||||||
|
},
|
||||||
|
sensor_location=["J1"],
|
||||||
|
adjustment_status={},
|
||||||
|
)
|
||||||
|
|
||||||
|
workbook = load_workbook(output, data_only=False)
|
||||||
|
assert workbook["方案信息"]["B2"].value == "'=1+1"
|
||||||
|
assert workbook["方案信息"]["B2"].data_type == "s"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_sensor_placement_returns_inserted_record(monkeypatch):
|
||||||
|
cursor = _mock_project_cursor(monkeypatch)
|
||||||
|
cursor.fetchone.return_value = {"id": 7, "sensor_location": ["J1", "J2"]}
|
||||||
|
|
||||||
|
created = s42_sensor_placement.create_sensor_placement(
|
||||||
|
"tjwater",
|
||||||
|
scheme_name="北区测压点",
|
||||||
|
min_diameter=300,
|
||||||
|
username="alice",
|
||||||
|
sensor_location=["J1", "J2"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert created["id"] == 7
|
||||||
|
query, parameters = cursor.execute.call_args.args
|
||||||
|
assert "INSERT INTO sensor_placement" in query
|
||||||
|
assert parameters == ("北区测压点", 2, 300, "alice", ["J1", "J2"])
|
||||||
Reference in New Issue
Block a user