feat(sensor-placement): add editable scheme APIs
This commit is contained in:
@@ -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