diff --git a/app/algorithms/sensor/__init__.py b/app/algorithms/sensor/__init__.py index 998016b..b500fc4 100644 --- a/app/algorithms/sensor/__init__.py +++ b/app/algorithms/sensor/__init__.py @@ -7,6 +7,7 @@ from app.algorithms.sensor import kmeans as kmeans_sensor from app.algorithms.sensor import sensitivity from app.native.wndb.s42_sensor_placement import create_sensor_placement from app.services.sensor_placement import ( + SensorPlacementConflictError, SensorPlacementValidationError, validate_sensor_placement_nodes, ) @@ -31,7 +32,15 @@ def _sensor_inp_lock(name: str): 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: + fcntl.flock( + lock_file.fileno(), + fcntl.LOCK_EX | fcntl.LOCK_NB, + ) + except BlockingIOError as exc: + raise SensorPlacementConflictError( + "当前项目已有监测点优化任务正在运行,请稍后重试" + ) from exc try: yield inp_path finally: diff --git a/app/api/v1/endpoints/sensor_placement.py b/app/api/v1/endpoints/sensor_placement.py index 454da49..0f0afc3 100644 --- a/app/api/v1/endpoints/sensor_placement.py +++ b/app/api/v1/endpoints/sensor_placement.py @@ -41,6 +41,25 @@ def _project_network(network: str, project_context: ProjectContext) -> str: 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: if isinstance(exc, SensorPlacementNotFoundError): return HTTPException( @@ -62,12 +81,16 @@ 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_edit_sensor_placement(current_user, scheme), + "can_edit": ( + _can_modify_project(project_context, current_user) + and can_edit_sensor_placement(current_user, scheme) + ), } except ( SensorPlacementNotFoundError, @@ -87,6 +110,7 @@ async def optimize_sensor_placement_scheme( current_user=Depends(get_current_metadata_user), ) -> dict[str, Any]: network = _project_network(payload.network, project_context) + _require_project_write(project_context, current_user) optimizer = ( pressure_sensor_placement_sensitivity if payload.method == "sensitivity" @@ -103,7 +127,11 @@ async def optimize_sensor_placement_scheme( ) scheme = get_sensor_placement_scheme(network, int(created["id"])) return {**scheme, "can_edit": True} - except (SensorPlacementValidationError, ValueError) as exc: + except ( + SensorPlacementConflictError, + SensorPlacementValidationError, + ValueError, + ) as exc: raise _service_http_error(exc) from exc except Exception as exc: logger.exception("Sensor placement optimization failed") @@ -128,6 +156,7 @@ async def get_sensor_placement_scheme_detail( _project_network(network, project_context), scheme_id, current_user, + project_context, ) @@ -144,7 +173,13 @@ async def overwrite_sensor_placement_scheme( 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) + _require_project_write(project_context, current_user) + scheme = _get_scheme_response( + network, + scheme_id, + current_user, + project_context, + ) if not scheme["can_edit"]: raise HTTPException(status_code=403, detail="无权修改该监测点方案") @@ -176,7 +211,12 @@ async def export_sensor_placement_excel( current_user=Depends(get_current_metadata_user), ) -> StreamingResponse: 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 ( payload.sensor_location != scheme["sensor_location"] and not scheme["can_edit"] @@ -184,7 +224,8 @@ async def export_sensor_placement_excel( raise HTTPException(status_code=403, detail="无权导出该方案的未保存草稿") try: - workbook = build_sensor_placement_workbook( + workbook = await run_in_threadpool( + build_sensor_placement_workbook, network=network, scheme=scheme, sensor_location=payload.sensor_location, diff --git a/app/domain/schemas/sensor_placement.py b/app/domain/schemas/sensor_placement.py index 8dcfe69..71f6680 100644 --- a/app/domain/schemas/sensor_placement.py +++ b/app/domain/schemas/sensor_placement.py @@ -39,8 +39,12 @@ class SensorPlacementOptimizeRequest(BaseModel): class SensorPlacementUpdateRequest(BaseModel): - expected_sensor_location: list[str] = Field(..., min_length=1) - sensor_location: list[str] = Field(..., min_length=1) + expected_sensor_location: list[str] = Field( + ..., + min_length=1, + max_length=200, + ) + sensor_location: list[str] = Field(..., min_length=1, max_length=200) @field_validator("expected_sensor_location", "sensor_location") @classmethod @@ -49,8 +53,11 @@ class SensorPlacementUpdateRequest(BaseModel): class SensorPlacementExportRequest(BaseModel): - sensor_location: list[str] = Field(..., min_length=1) - adjustment_status: dict[str, AdjustmentStatus] = Field(default_factory=dict) + sensor_location: list[str] = Field(..., min_length=1, max_length=200) + adjustment_status: dict[str, AdjustmentStatus] = Field( + default_factory=dict, + max_length=200, + ) @field_validator("sensor_location") @classmethod diff --git a/tests/api/test_sensor_placement_endpoints.py b/tests/api/test_sensor_placement_endpoints.py index 1b67ef0..34756da 100644 --- a/tests/api/test_sensor_placement_endpoints.py +++ b/tests/api/test_sensor_placement_endpoints.py @@ -81,8 +81,9 @@ def _load_module(monkeypatch): ) 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_role = project_role async def project_context(): 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") if user is None: 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_project_context] = lambda: ( - module.ProjectContext("tjwater") + module.ProjectContext("tjwater", project_role) ) return TestClient(app) @@ -236,6 +237,73 @@ def test_optimize_rejects_unbounded_sensor_count(monkeypatch): 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): module = _load_module(monkeypatch) response = _client( @@ -253,6 +321,20 @@ def test_update_rejects_non_owner(monkeypatch): 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): module = _load_module(monkeypatch) response = _client(