fix(sensor-placement): enforce project write boundaries
Bind every scheme request to ProjectContext, keep viewer access read-only, reject concurrent optimization jobs without blocking worker threads, and cap export/update payload sizes. Run optimization and workbook work off the event loop.
This commit is contained in:
@@ -7,6 +7,7 @@ from app.algorithms.sensor import kmeans as kmeans_sensor
|
|||||||
from app.algorithms.sensor import sensitivity
|
from app.algorithms.sensor import sensitivity
|
||||||
from app.native.wndb.s42_sensor_placement import create_sensor_placement
|
from app.native.wndb.s42_sensor_placement import create_sensor_placement
|
||||||
from app.services.sensor_placement import (
|
from app.services.sensor_placement import (
|
||||||
|
SensorPlacementConflictError,
|
||||||
SensorPlacementValidationError,
|
SensorPlacementValidationError,
|
||||||
validate_sensor_placement_nodes,
|
validate_sensor_placement_nodes,
|
||||||
)
|
)
|
||||||
@@ -31,7 +32,15 @@ def _sensor_inp_lock(name: str):
|
|||||||
inp_path.parent.mkdir(parents=True, exist_ok=True)
|
inp_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
lock_path = inp_path.with_suffix(".sensor.lock")
|
lock_path = inp_path.with_suffix(".sensor.lock")
|
||||||
with lock_path.open("w", encoding="utf-8") as lock_file:
|
with lock_path.open("w", encoding="utf-8") as lock_file:
|
||||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
try:
|
||||||
|
fcntl.flock(
|
||||||
|
lock_file.fileno(),
|
||||||
|
fcntl.LOCK_EX | fcntl.LOCK_NB,
|
||||||
|
)
|
||||||
|
except BlockingIOError as exc:
|
||||||
|
raise SensorPlacementConflictError(
|
||||||
|
"当前项目已有监测点优化任务正在运行,请稍后重试"
|
||||||
|
) from exc
|
||||||
try:
|
try:
|
||||||
yield inp_path
|
yield inp_path
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -41,6 +41,25 @@ def _project_network(network: str, project_context: ProjectContext) -> str:
|
|||||||
return project_context.project_code
|
return project_context.project_code
|
||||||
|
|
||||||
|
|
||||||
|
def _can_modify_project(project_context: ProjectContext, current_user: Any) -> bool:
|
||||||
|
return bool(
|
||||||
|
project_context.project_role in {"owner", "admin", "member"}
|
||||||
|
or getattr(current_user, "role", None) == "admin"
|
||||||
|
or getattr(current_user, "is_superuser", False)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_project_write(
|
||||||
|
project_context: ProjectContext,
|
||||||
|
current_user: Any,
|
||||||
|
) -> None:
|
||||||
|
if not _can_modify_project(project_context, current_user):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="当前项目角色为只读,不能修改监测点方案",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _service_http_error(exc: Exception) -> HTTPException:
|
def _service_http_error(exc: Exception) -> HTTPException:
|
||||||
if isinstance(exc, SensorPlacementNotFoundError):
|
if isinstance(exc, SensorPlacementNotFoundError):
|
||||||
return HTTPException(
|
return HTTPException(
|
||||||
@@ -62,12 +81,16 @@ def _get_scheme_response(
|
|||||||
network: str,
|
network: str,
|
||||||
scheme_id: int,
|
scheme_id: int,
|
||||||
current_user: Any,
|
current_user: Any,
|
||||||
|
project_context: ProjectContext,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
scheme = get_sensor_placement_scheme(network, scheme_id)
|
scheme = get_sensor_placement_scheme(network, scheme_id)
|
||||||
return {
|
return {
|
||||||
**scheme,
|
**scheme,
|
||||||
"can_edit": can_edit_sensor_placement(current_user, scheme),
|
"can_edit": (
|
||||||
|
_can_modify_project(project_context, current_user)
|
||||||
|
and can_edit_sensor_placement(current_user, scheme)
|
||||||
|
),
|
||||||
}
|
}
|
||||||
except (
|
except (
|
||||||
SensorPlacementNotFoundError,
|
SensorPlacementNotFoundError,
|
||||||
@@ -87,6 +110,7 @@ async def optimize_sensor_placement_scheme(
|
|||||||
current_user=Depends(get_current_metadata_user),
|
current_user=Depends(get_current_metadata_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
network = _project_network(payload.network, project_context)
|
network = _project_network(payload.network, project_context)
|
||||||
|
_require_project_write(project_context, current_user)
|
||||||
optimizer = (
|
optimizer = (
|
||||||
pressure_sensor_placement_sensitivity
|
pressure_sensor_placement_sensitivity
|
||||||
if payload.method == "sensitivity"
|
if payload.method == "sensitivity"
|
||||||
@@ -103,7 +127,11 @@ async def optimize_sensor_placement_scheme(
|
|||||||
)
|
)
|
||||||
scheme = get_sensor_placement_scheme(network, int(created["id"]))
|
scheme = get_sensor_placement_scheme(network, int(created["id"]))
|
||||||
return {**scheme, "can_edit": True}
|
return {**scheme, "can_edit": True}
|
||||||
except (SensorPlacementValidationError, ValueError) as exc:
|
except (
|
||||||
|
SensorPlacementConflictError,
|
||||||
|
SensorPlacementValidationError,
|
||||||
|
ValueError,
|
||||||
|
) as exc:
|
||||||
raise _service_http_error(exc) from exc
|
raise _service_http_error(exc) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Sensor placement optimization failed")
|
logger.exception("Sensor placement optimization failed")
|
||||||
@@ -128,6 +156,7 @@ async def get_sensor_placement_scheme_detail(
|
|||||||
_project_network(network, project_context),
|
_project_network(network, project_context),
|
||||||
scheme_id,
|
scheme_id,
|
||||||
current_user,
|
current_user,
|
||||||
|
project_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -144,7 +173,13 @@ async def overwrite_sensor_placement_scheme(
|
|||||||
current_user=Depends(get_current_metadata_user),
|
current_user=Depends(get_current_metadata_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
network = _project_network(network, project_context)
|
network = _project_network(network, project_context)
|
||||||
scheme = _get_scheme_response(network, scheme_id, current_user)
|
_require_project_write(project_context, current_user)
|
||||||
|
scheme = _get_scheme_response(
|
||||||
|
network,
|
||||||
|
scheme_id,
|
||||||
|
current_user,
|
||||||
|
project_context,
|
||||||
|
)
|
||||||
if not scheme["can_edit"]:
|
if not scheme["can_edit"]:
|
||||||
raise HTTPException(status_code=403, detail="无权修改该监测点方案")
|
raise HTTPException(status_code=403, detail="无权修改该监测点方案")
|
||||||
|
|
||||||
@@ -176,7 +211,12 @@ async def export_sensor_placement_excel(
|
|||||||
current_user=Depends(get_current_metadata_user),
|
current_user=Depends(get_current_metadata_user),
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
network = _project_network(network, project_context)
|
network = _project_network(network, project_context)
|
||||||
scheme = _get_scheme_response(network, scheme_id, current_user)
|
scheme = _get_scheme_response(
|
||||||
|
network,
|
||||||
|
scheme_id,
|
||||||
|
current_user,
|
||||||
|
project_context,
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
payload.sensor_location != scheme["sensor_location"]
|
payload.sensor_location != scheme["sensor_location"]
|
||||||
and not scheme["can_edit"]
|
and not scheme["can_edit"]
|
||||||
@@ -184,7 +224,8 @@ async def export_sensor_placement_excel(
|
|||||||
raise HTTPException(status_code=403, detail="无权导出该方案的未保存草稿")
|
raise HTTPException(status_code=403, detail="无权导出该方案的未保存草稿")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
workbook = build_sensor_placement_workbook(
|
workbook = await run_in_threadpool(
|
||||||
|
build_sensor_placement_workbook,
|
||||||
network=network,
|
network=network,
|
||||||
scheme=scheme,
|
scheme=scheme,
|
||||||
sensor_location=payload.sensor_location,
|
sensor_location=payload.sensor_location,
|
||||||
|
|||||||
@@ -39,8 +39,12 @@ class SensorPlacementOptimizeRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SensorPlacementUpdateRequest(BaseModel):
|
class SensorPlacementUpdateRequest(BaseModel):
|
||||||
expected_sensor_location: list[str] = Field(..., min_length=1)
|
expected_sensor_location: list[str] = Field(
|
||||||
sensor_location: list[str] = Field(..., min_length=1)
|
...,
|
||||||
|
min_length=1,
|
||||||
|
max_length=200,
|
||||||
|
)
|
||||||
|
sensor_location: list[str] = Field(..., min_length=1, max_length=200)
|
||||||
|
|
||||||
@field_validator("expected_sensor_location", "sensor_location")
|
@field_validator("expected_sensor_location", "sensor_location")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -49,8 +53,11 @@ class SensorPlacementUpdateRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SensorPlacementExportRequest(BaseModel):
|
class SensorPlacementExportRequest(BaseModel):
|
||||||
sensor_location: list[str] = Field(..., min_length=1)
|
sensor_location: list[str] = Field(..., min_length=1, max_length=200)
|
||||||
adjustment_status: dict[str, AdjustmentStatus] = Field(default_factory=dict)
|
adjustment_status: dict[str, AdjustmentStatus] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
max_length=200,
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("sensor_location")
|
@field_validator("sensor_location")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -81,8 +81,9 @@ def _load_module(monkeypatch):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class ProjectContext:
|
class ProjectContext:
|
||||||
def __init__(self, project_code: str):
|
def __init__(self, project_code: str, project_role: str = "member"):
|
||||||
self.project_code = project_code
|
self.project_code = project_code
|
||||||
|
self.project_role = project_role
|
||||||
|
|
||||||
async def project_context():
|
async def project_context():
|
||||||
return ProjectContext("tjwater")
|
return ProjectContext("tjwater")
|
||||||
@@ -127,7 +128,7 @@ def _load_module(monkeypatch):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _client(module, user=None):
|
def _client(module, user=None, project_role="member"):
|
||||||
app = build_test_app(module.router, "/api/v1")
|
app = build_test_app(module.router, "/api/v1")
|
||||||
if user is None:
|
if user is None:
|
||||||
user = SimpleNamespace(
|
user = SimpleNamespace(
|
||||||
@@ -137,7 +138,7 @@ def _client(module, user=None):
|
|||||||
)
|
)
|
||||||
app.dependency_overrides[module.get_current_metadata_user] = lambda: user
|
app.dependency_overrides[module.get_current_metadata_user] = lambda: user
|
||||||
app.dependency_overrides[module.get_project_context] = lambda: (
|
app.dependency_overrides[module.get_project_context] = lambda: (
|
||||||
module.ProjectContext("tjwater")
|
module.ProjectContext("tjwater", project_role)
|
||||||
)
|
)
|
||||||
return TestClient(app)
|
return TestClient(app)
|
||||||
|
|
||||||
@@ -236,6 +237,73 @@ def test_optimize_rejects_unbounded_sensor_count(monkeypatch):
|
|||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_optimize_rejects_viewer_project_role(monkeypatch):
|
||||||
|
module = _load_module(monkeypatch)
|
||||||
|
response = _client(module, project_role="viewer").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 == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_owner_and_admin_can_optimize(monkeypatch):
|
||||||
|
module = _load_module(monkeypatch)
|
||||||
|
for project_role in ("owner", "admin"):
|
||||||
|
response = _client(module, project_role=project_role).post(
|
||||||
|
"/api/v1/sensor-placement-schemes/optimize",
|
||||||
|
json={
|
||||||
|
"network": "tjwater",
|
||||||
|
"scheme_name": f"{project_role}方案",
|
||||||
|
"sensor_type": "pressure",
|
||||||
|
"method": "kmeans",
|
||||||
|
"sensor_count": 2,
|
||||||
|
"min_diameter": 300,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_optimize_maps_running_project_job_to_409(monkeypatch):
|
||||||
|
module = _load_module(monkeypatch)
|
||||||
|
|
||||||
|
def conflict(**kwargs):
|
||||||
|
raise ConflictError("当前项目已有监测点优化任务正在运行,请稍后重试")
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "pressure_sensor_placement_kmeans", conflict)
|
||||||
|
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 == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewer_reads_scheme_as_non_editable(monkeypatch):
|
||||||
|
module = _load_module(monkeypatch)
|
||||||
|
response = _client(module, project_role="viewer").get(
|
||||||
|
"/api/v1/sensor-placement-schemes/7",
|
||||||
|
params={"network": "tjwater"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["can_edit"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_update_rejects_non_owner(monkeypatch):
|
def test_update_rejects_non_owner(monkeypatch):
|
||||||
module = _load_module(monkeypatch)
|
module = _load_module(monkeypatch)
|
||||||
response = _client(
|
response = _client(
|
||||||
@@ -253,6 +321,20 @@ def test_update_rejects_non_owner(monkeypatch):
|
|||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_rejects_owner_with_viewer_project_role(monkeypatch):
|
||||||
|
module = _load_module(monkeypatch)
|
||||||
|
response = _client(module, project_role="viewer").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):
|
def test_admin_can_overwrite_scheme(monkeypatch):
|
||||||
module = _load_module(monkeypatch)
|
module = _load_module(monkeypatch)
|
||||||
response = _client(
|
response = _client(
|
||||||
|
|||||||
Reference in New Issue
Block a user