11 Commits
Author SHA1 Message Date
jiang eac6b78598 fix(ci): align backend image with deployment
Server CI/CD / docker-image (push) Failing after 29s
Server CI/CD / deploy-fallback-log (push) Successful in 1s
2026-07-31 00:01:21 +08:00
jiang 1d88f8efbe fix(api): wrap pre-paginated list responses
Server CI/CD / docker-image (push) Failing after 28s
Server CI/CD / deploy-fallback-log (push) Successful in 1s
2026-07-30 21:50:21 +08:00
jiang ba947b616b feat(api): standardize REST contracts and auth 2026-07-30 20:38:51 +08:00
jiang ae1a657554 feat(server): add project RBAC and guarded workflows 2026-07-30 16:45:09 +08:00
jiang 3fbb17bb30 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.
2026-07-30 16:21:38 +08:00
jiang ddbb50173c feat(sensor-placement): add editable scheme APIs 2026-07-30 16:16:51 +08:00
jiang 437eb5a19a fix(auth): require preferred username claim 2026-07-30 14:21:21 +08:00
jiang 31e2728db1 refactor(api): unify scheme query endpoints 2026-07-30 11:01:45 +08:00
jiang 03bb2d75c2 docs: 编写中文 README 2026-07-22 11:26:06 +08:00
jiang b977bf6725 fix(db): validate cached project connections
Server CI/CD / docker-image (push) Successful in 23s
Server CI/CD / deploy-fallback-log (push) Has been skipped
2026-07-21 11:26:21 +08:00
jiang 045d6c5b49 fix(simulation): use current user for stored schemes
Server CI/CD / docker-image (push) Successful in 23s
Server CI/CD / deploy-fallback-log (push) Has been skipped
2026-07-17 16:49:20 +08:00
122 changed files with 57734 additions and 1638 deletions
+2 -1
View File
@@ -46,7 +46,8 @@ jobs:
fi
REPOSITORY_PATH="${RAW_REPOSITORY#/}"
IMAGE_REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')"
IMAGE_OWNER="${REPOSITORY_PATH%%/*}"
IMAGE_REPOSITORY_PATH="$(printf '%s' "${IMAGE_OWNER}/tjwater-backend" | tr '[:upper:]' '[:lower:]')"
IMAGE_NAME="${REGISTRY_HOST}/${IMAGE_REPOSITORY_PATH}"
IMAGE_TAG="${RAW_REF_NAME}"
{
+33 -6
View File
@@ -13,10 +13,29 @@ TJWater metadata stores only business snapshots and authorization data:
The backend does not accept passwords, does not issue local JWTs, and does not
trust frontend-supplied user IDs.
## Fixed Project RBAC
Project roles are stored directly in
`user_project_membership.project_role`; there is no separate role table or
user-defined permission editor in this delivery.
| Role | Main access |
| --- | --- |
| `modeler` | Model upload/import, simulation, burst, risk, and optimization analysis |
| `dispatcher` | SCADA cleaning, simulation and burst analysis |
| `auditor` | Project read access and project-scoped audit logs |
| `viewer` | WebGIS and read-only risk results |
Legacy `owner`, `admin`, and `member` values remain supported for existing
records. The backend is the authorization boundary; the frontend uses
`GET /api/v1/access/context` only to hide unavailable menus and guard routes.
System admins receive environment, membership, and global-audit permissions,
but still need a project membership for project business APIs.
## Login Snapshot Refresh
Every authenticated metadata-user resolution validates the Keycloak access token
and reads `sub`, `preferred_username` or `username`, and `email` claims. The
and reads `sub`, `preferred_username`, and `email` claims. The
backend finds `users` by `keycloak_id = sub`, rejects inactive or missing users,
then refreshes `username`, `email`, and `last_login_at`.
@@ -77,14 +96,22 @@ Apply metadata patches in order:
1. `resources/sql/004_metadata_auth_management.sql`
2. `resources/sql/005_metadata_project_configuration.sql`
3. `resources/sql/006_metadata_rbac_roles.sql`
`004` creates Keycloak-backed metadata users and project memberships. `005`
creates project and project database routing tables with uniqueness, role/type,
and pool-size constraints.
and pool-size constraints. `006` extends existing membership constraints with
the fixed delivery roles.
## Frontend System Management
`/system-admin` is shown only after `GET /api/v1/admin/me` confirms metadata
admin access. The page lets admins maintain metadata users, project members,
projects, project database routing for `biz_data` and `iot_data`, connection
health checks. This replaces direct SQL editing for normal project onboarding.
`/system-admin` is shown only when `GET /api/v1/access/context` returns
`environment.manage`. The page lets admins maintain metadata users, project
members, projects, project database routing for `biz_data` and `iot_data`, and
connection health checks. This replaces direct SQL editing for normal project
onboarding.
Hydraulic model authoring is outside the Web application. Models are prepared
in the desktop modeling client and uploaded/imported by an authorized modeler;
the system administrator configures the project environment and database
routing.
+97
View File
@@ -0,0 +1,97 @@
# TJWaterServerBinary 内部后端
`TJWaterServerBinary` 是 TJWater 内部版 Python 后端,基于 FastAPI 提供认证、项目、管网、模拟、爆管、漏损、SCADA、地图服务集成和命令行工具能力。该仓库用于内部开发和完整功能维护。
## 技术栈
- Python 3.12
- FastAPI / Uvicorn
- Pydantic / SQLAlchemy / psycopg
- Redis、PostgreSQL、PostGIS、TimescaleDB
- WNTR、EPANET、Cython、科学计算与空间分析依赖
- pytest
## 目录结构
```text
app/main.py FastAPI 入口
app/api/ HTTP API 路由
app/auth/ 认证和权限上下文
app/core/ 配置、日志和基础设施初始化
app/domain/ 领域模型和 Pydantic schema
app/infra/ 数据库、缓存、EPANET 和外部集成
app/services/ 业务服务编排
app/algorithms/ 管网算法、模拟、爆管、漏损、清洗和健康分析
app/native/ 本地管网数据读写与转换
cli/ tjwater-cli 命令行工具
tests/ 后端测试
resources/ SQL、模板和示例资源
infra/docker/ Docker Compose 编排
```
## 本地开发
推荐使用已有 conda 环境:
```bash
conda run -n server python -m pytest tests/unit tests/auth -q
conda run -n server uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
如需要进入环境:
```bash
conda activate server
```
## 常用命令
```bash
conda run -n server python -m pytest tests -q
conda run -n server python scripts/run_server.py
docker build -t tjwater-server:local .
docker compose -f infra/docker/docker-compose.yml config
```
- `pytest`:运行自动化测试。
- `scripts/run_server.py`:使用项目脚本启动服务。
- `docker build`:构建后端镜像。
- `docker compose config`:检查 compose 配置和变量展开。
## CLI
CLI 位于 `cli/tjwater_cli`,说明见:
```text
cli/README.md
```
修改 CLI 参数、输出结构或后端接口适配时,应同步更新 CLI 测试和文档。
## 开发规范
- Python 文件、函数、变量、Pydantic 字段、JSON body 字段和 query 参数使用 `snake_case`
- Python 类和 Pydantic 模型使用 `PascalCase`
- 新 HTTP 路径使用 `kebab-case`,例如 `/api/v1/pressure-status/analyze`
- 优先复用现有 FastAPI/service/repository 边界。
- 不要把临时数据、数据库 dump、日志或本地运行产物纳入提交。
## 测试与发布
提交前根据改动范围运行最小有效测试:
```bash
conda run -n server python -m pytest tests/unit tests/auth -q
```
发布镜像前建议运行:
```bash
docker build -t tjwater-server:local .
```
Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发构建、推送镜像并通知部署 webhook。
## 安全规则
不要提交 `.env`、客户数据、数据库 dump、日志、生成缓存、`db_inp/``temp/``data/` 或本地密钥。CI/CD 凭据应放在 Gitea secrets 和仓库变量中。
+4 -2
View File
@@ -149,14 +149,16 @@ def valve_isolation_analysis(
must_close_valves.sort()
optional_valves.sort()
isolatable = bool(must_close_valves)
result = {
"accident_elements": target_elements,
"disabled_valves": disabled_valves,
"affected_nodes": sorted(affected_nodes),
"affected_nodes": sorted(affected_nodes) if isolatable else [],
"affected_node_count": len(affected_nodes),
"must_close_valves": must_close_valves,
"optional_valves": optional_valves,
"isolatable": len(must_close_valves) > 0,
"isolatable": isolatable,
}
if len(target_elements) == 1:
+100 -60
View File
@@ -1,14 +1,77 @@
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 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 (
SensorPlacementConflictError,
SensorPlacementValidationError,
validate_sensor_placement_nodes,
)
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:
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:
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(
name: str, scheme_name: str, sensor_number: int, min_diameter: int, username: str
) -> None:
name: str,
scheme_name: str,
sensor_number: int,
min_diameter: int,
username: str,
) -> dict[str, Any]:
"""
基于改进灵敏度法进行压力监测点优化布置
:param name: 数据库名称
@@ -16,41 +79,32 @@ def pressure_sensor_placement_sensitivity(
:param sensor_number: 传感器数目
:param min_diameter: 最小管径
:param username: 用户名
:return:
:return: 新建的监测点方案
"""
sensor_location = sensitivity.get_ID(
name=name, sensor_num=sensor_number, min_diameter=min_diameter
with _sensor_inp_lock(name):
sensor_location = sensitivity.get_ID(
name=name,
sensor_num=sensor_number,
min_diameter=min_diameter,
)
return _create_validated_placement(
name,
scheme_name=scheme_name,
min_diameter=min_diameter,
username=username,
sensor_location=sensor_location,
)
try:
conn_string = get_pgconn_string(db_name=name)
with psycopg.connect(conn_string) as conn:
with conn.cursor() as cur:
sql = """
INSERT INTO sensor_placement (scheme_name, sensor_number, min_diameter, username, 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
# 基于kmeans聚类法进行压力监测点优化布置
def pressure_sensor_placement_kmeans(
name: str, scheme_name: str, sensor_number: int, min_diameter: int, username: str
) -> None:
name: str,
scheme_name: str,
sensor_number: int,
min_diameter: int,
username: str,
) -> dict[str, Any]:
"""
基于聚类法进行压力监测点优化布置
:param name: 数据库名称(注意,此处数据库名称也是inp文件名称,inp文件与pg库名要一样)
@@ -58,34 +112,20 @@ def pressure_sensor_placement_kmeans(
:param sensor_number: 传感器数目
:param min_diameter: 最小管径
:param username: 用户名
:return:
:return: 新建的监测点方案
"""
# dump_inp
inp_name = f"./db_inp/{name}.db.inp"
dump_inp(name, inp_name, "2")
sensor_location = kmeans_sensor.kmeans_sensor_placement(
name=name, sensor_num=sensor_number, min_diameter=min_diameter
with _sensor_inp_lock(name) as inp_path:
dump_inp(name, str(inp_path), "2")
sensor_location = kmeans_sensor.kmeans_sensor_placement(
name=name,
sensor_num=sensor_number,
min_diameter=min_diameter,
)
return _create_validated_placement(
name,
scheme_name=scheme_name,
min_diameter=min_diameter,
username=username,
sensor_location=sensor_location,
)
try:
conn_string = get_pgconn_string(db_name=name)
with psycopg.connect(conn_string) as conn:
with conn.cursor() as cur:
sql = """
INSERT INTO sensor_placement (scheme_name, sensor_number, min_diameter, username, 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}")
+15 -3
View File
@@ -72,6 +72,7 @@ def burst_analysis(
modify_variable_pump_pattern: dict[str, list] = None,
modify_valve_opening: dict[str, float] = None,
scheme_name: str = None,
username: str | None = None,
) -> None:
"""
爆管模拟
@@ -86,6 +87,9 @@ def burst_analysis(
:param scheme_name: 方案名称
:return:
"""
if not username:
raise ValueError("username is required when storing burst analysis scheme")
scheme_detail: dict = {
"burst_ID": burst_ID,
"burst_size": burst_size,
@@ -211,7 +215,7 @@ def burst_analysis(
name=name,
scheme_name=scheme_name,
scheme_type="burst_analysis",
username="admin",
username=username,
scheme_start_time=modify_pattern_start_time,
scheme_detail=scheme_detail,
)
@@ -311,6 +315,7 @@ def flushing_analysis(
drainage_node_ID: str = None,
flushing_flow: float = 0,
scheme_name: str = None,
username: str | None = None,
) -> None:
"""
管道冲洗模拟
@@ -323,6 +328,9 @@ def flushing_analysis(
:param scheme_name: 方案名称
:return:
"""
if not username:
raise ValueError("username is required when storing flushing analysis scheme")
scheme_detail: dict = {
"duration": modify_total_duration,
"valve_opening": modify_valve_opening,
@@ -455,7 +463,7 @@ def flushing_analysis(
name=name,
scheme_name=scheme_name,
scheme_type="flushing_analysis",
username="admin",
username=username,
scheme_start_time=modify_pattern_start_time,
scheme_detail=scheme_detail,
)
@@ -473,6 +481,7 @@ def contaminant_simulation(
concentration: float, # 污染源浓度,单位mg/L
scheme_name: str = None,
source_pattern: str = None, # 污染源时间变化模式名称
username: str | None = None,
) -> None:
"""
污染模拟
@@ -486,6 +495,9 @@ def contaminant_simulation(
:param scheme_name: 方案名称
:return:
"""
if not username:
raise ValueError("username is required when storing contaminant analysis scheme")
scheme_detail: dict = {
"source": source,
"concentration": concentration,
@@ -608,7 +620,7 @@ def contaminant_simulation(
name=name,
scheme_name=scheme_name,
scheme_type="contaminant_analysis",
username="admin",
username=username,
scheme_start_time=modify_pattern_start_time,
scheme_detail=scheme_detail,
)
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
from typing import Any
from uuid import uuid4
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
class ProblemDetails(BaseModel):
"""RFC 9457 compatible error response used by the REST contract."""
type: str
title: str
status: int
detail: str
instance: str
code: str
trace_id: str
errors: list[dict[str, Any]] = Field(default_factory=list)
def _trace_id(request: Request) -> str:
return request.headers.get("X-Request-Id") or str(uuid4())
def _problem_response(
request: Request,
*,
status_code: int,
title: str,
detail: str,
code: str,
errors: list[dict[str, Any]] | None = None,
) -> JSONResponse:
problem = ProblemDetails(
type=f"https://tjwater.example/problems/{code.replace('_', '-')}",
title=title,
status=status_code,
detail=detail,
instance=request.url.path,
code=code,
trace_id=_trace_id(request),
errors=errors or [],
)
return JSONResponse(
status_code=status_code,
content=problem.model_dump(mode="json"),
media_type="application/problem+json",
)
def install_problem_details_handlers(app: FastAPI) -> None:
@app.exception_handler(RequestValidationError)
async def validation_error_handler(
request: Request,
exc: RequestValidationError,
) -> JSONResponse:
return _problem_response(
request,
status_code=422,
title="Validation error",
detail="Request validation failed",
code="validation_error",
errors=exc.errors(),
)
@app.exception_handler(HTTPException)
async def http_error_handler(request: Request, exc: HTTPException) -> JSONResponse:
detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
code_by_status = {
401: "unauthenticated",
403: "forbidden",
404: "not_found",
409: "conflict",
422: "validation_error",
503: "dependency_unavailable",
}
return _problem_response(
request,
status_code=exc.status_code,
title=code_by_status.get(exc.status_code, "request_error")
.replace("_", " ")
.title(),
detail=detail,
code=code_by_status.get(exc.status_code, "request_error"),
)
+39
View File
@@ -0,0 +1,39 @@
from fastapi import APIRouter, Depends, Header
from app.auth.metadata_dependencies import (
get_current_metadata_user,
get_metadata_repository,
)
from app.auth.permissions import resolve_permissions
from app.auth.project_dependencies import resolve_project_context
from app.domain.schemas.access import AccessContextResponse
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
router = APIRouter()
@router.get("/access-context", response_model=AccessContextResponse)
async def get_access_context(
x_project_id: str | None = Header(default=None, alias="X-Project-Id"),
current_user=Depends(get_current_metadata_user),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> AccessContextResponse:
project_context = (
await resolve_project_context(x_project_id, current_user, metadata_repo)
if x_project_id
else None
)
permissions = resolve_permissions(
project_role=project_context.project_role if project_context else None,
system_role=current_user.role,
is_superuser=current_user.is_superuser,
)
return AccessContextResponse(
user_id=current_user.id,
username=current_user.username,
system_role=current_user.role,
is_system_admin=current_user.is_superuser or current_user.role == "admin",
project_id=project_context.project_id if project_context else None,
project_role=project_context.project_role if project_context else None,
permissions=sorted(permissions),
)
+18 -17
View File
@@ -151,14 +151,14 @@ async def _upsert_and_audit_metadata_user(
return MetadataUserResponse.model_validate(user)
@router.get("/me", response_model=MetadataUserResponse)
@router.get("/admin/users/me", response_model=MetadataUserResponse)
async def get_metadata_admin_me(
current_user=Depends(get_current_metadata_admin),
) -> MetadataUserResponse:
return MetadataUserResponse.model_validate(current_user)
@router.post("/users/sync", response_model=MetadataUserResponse)
@router.post("/admin/user-syncs", response_model=MetadataUserResponse)
async def sync_metadata_user(
payload: MetadataUserSyncRequest,
current_user=Depends(get_current_metadata_admin),
@@ -184,7 +184,7 @@ async def sync_metadata_user(
@router.post("/users/sync/batch", response_model=List[MetadataUserSyncResult])
@router.post("/admin/user-syncs/batches", response_model=List[MetadataUserSyncResult])
async def sync_metadata_users_batch(
payload: MetadataUsersBatchSyncRequest,
current_user=Depends(get_current_metadata_admin),
@@ -228,7 +228,7 @@ async def sync_metadata_users_batch(
return results
@router.get("/users", response_model=List[MetadataUserResponse])
@router.get("/admin/users", response_model=List[MetadataUserResponse])
async def list_metadata_users(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
@@ -239,7 +239,7 @@ async def list_metadata_users(
return [MetadataUserResponse.model_validate(user) for user in users]
@router.get("/projects", response_model=List[AdminProjectResponse])
@router.get("/admin/projects", response_model=List[AdminProjectResponse])
async def list_admin_projects(
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
@@ -249,7 +249,7 @@ async def list_admin_projects(
@router.post(
"/projects",
"/admin/projects",
response_model=AdminProjectResponse,
status_code=status.HTTP_201_CREATED,
)
@@ -266,6 +266,7 @@ async def create_admin_project(
gs_workspace=payload.gs_workspace,
map_extent=payload.map_extent,
status=payload.status,
creator_user_id=current_user.id,
)
except IntegrityError as exc:
raise HTTPException(
@@ -292,7 +293,7 @@ async def create_admin_project(
@router.patch(
"/projects/{project_id}",
"/admin/projects/{project_id}",
response_model=AdminProjectResponse,
)
async def update_admin_project(
@@ -331,7 +332,7 @@ async def update_admin_project(
@router.get(
"/projects/{project_id}/databases",
"/admin/projects/{project_id}/databases",
response_model=List[ProjectDatabaseResponse],
)
async def list_project_databases(
@@ -347,7 +348,7 @@ async def list_project_databases(
@router.put(
"/projects/{project_id}/databases",
"/admin/projects/{project_id}/databases",
response_model=ProjectDatabaseResponse,
)
async def upsert_project_database(
@@ -420,7 +421,7 @@ async def upsert_project_database(
@router.delete(
"/projects/{project_id}/databases/{db_role}",
"/admin/projects/{project_id}/databases/{db_role}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_project_database(
@@ -448,7 +449,7 @@ async def delete_project_database(
@router.post(
"/projects/{project_id}/databases/{db_role}/health",
"/admin/projects/{project_id}/databases/{db_role}/health-checks",
response_model=ProjectDatabaseHealthResponse,
)
async def check_project_database_health(
@@ -503,7 +504,7 @@ async def check_project_database_health(
)
@router.get("/users/{user_id}", response_model=MetadataUserResponse)
@router.get("/admin/users/{user_id}", response_model=MetadataUserResponse)
async def get_metadata_user(
user_id: UUID = Path(...),
current_user=Depends(get_current_metadata_admin),
@@ -515,7 +516,7 @@ async def get_metadata_user(
return MetadataUserResponse.model_validate(user)
@router.patch("/users/{user_id}", response_model=MetadataUserResponse)
@router.patch("/admin/users/{user_id}", response_model=MetadataUserResponse)
async def update_metadata_user(
payload: MetadataUserUpdateRequest,
user_id: UUID = Path(...),
@@ -548,7 +549,7 @@ async def update_metadata_user(
@router.get(
"/projects/{project_id}/members",
"/admin/projects/{project_id}/members",
response_model=List[ProjectMemberResponse],
)
async def list_project_members(
@@ -566,7 +567,7 @@ async def list_project_members(
@router.post(
"/projects/{project_id}/members",
"/admin/projects/{project_id}/members",
response_model=ProjectMemberResponse,
status_code=status.HTTP_201_CREATED,
)
@@ -621,7 +622,7 @@ async def add_project_member(
@router.patch(
"/projects/{project_id}/members/{user_id}",
"/admin/projects/{project_id}/members/{user_id}",
response_model=ProjectMemberResponse,
)
async def update_project_member(
@@ -667,7 +668,7 @@ async def update_project_member(
)
@router.delete("/projects/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete("/admin/projects/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_project_member(
project_id: UUID = Path(...),
user_id: UUID = Path(...),
+4 -1
View File
@@ -9,6 +9,7 @@ from app.auth.project_dependencies import (
ProjectContext,
get_project_context,
)
from app.auth.permissions import permissions_for_context
router = APIRouter()
@@ -22,10 +23,11 @@ class AgentAuthContextResponse(BaseModel):
project_id: str
network: str
project_role: str
permissions: list[str]
token_expires_at: str | None = None
@router.get("/agent/auth/context", response_model=AgentAuthContextResponse)
@router.get("/agent-auth-context", response_model=AgentAuthContextResponse)
async def get_agent_auth_context(
ctx: ProjectContext = Depends(get_project_context),
current_user=Depends(get_current_metadata_user),
@@ -46,5 +48,6 @@ async def get_agent_auth_context(
project_id=str(ctx.project_id),
network=ctx.project_code,
project_role=ctx.project_role,
permissions=sorted(permissions_for_context(ctx)),
token_expires_at=token_expires_at,
)
+60 -56
View File
@@ -1,56 +1,52 @@
"""
审计日志 API 接口
仅管理员可访问
"""
from typing import List, Optional
from uuid import UUID
from datetime import datetime
from fastapi import APIRouter, Depends, Query, Path
from app.domain.schemas.audit import AuditLogResponse
from app.infra.db.metadb.repositories.audit_repository import AuditRepository
from typing import Literal
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.metadata_dependencies import (
get_current_metadata_admin,
get_current_metadata_user,
)
from app.core.audit import AuditAction, log_audit_event
from app.domain.schemas.audit import AuditLogResponse
from app.infra.db.metadb.database import get_metadata_session
from sqlalchemy.ext.asyncio import AsyncSession
from app.infra.db.metadb.repositories.audit_repository import AuditRepository
router = APIRouter()
class SessionAuditEventRequest(BaseModel):
event: Literal["login", "logout"]
async def get_audit_repository(
session: AsyncSession = Depends(get_metadata_session),
) -> AuditRepository:
"""获取审计日志仓储"""
return AuditRepository(session)
@router.get(
"/logs",
"/audit-logs",
summary="查询审计日志",
description="查询审计日志(仅管理员)",
response_model=List[AuditLogResponse],
response_model=list[AuditLogResponse],
)
async def get_audit_logs(
user_id: Optional[UUID] = Query(None, description="按用户ID过滤"),
project_id: Optional[UUID] = Query(None, description="按项目ID过滤"),
action: Optional[str] = Query(None, description="按操作类型过滤"),
resource_type: Optional[str] = Query(None, description="按资源类型过滤"),
start_time: Optional[datetime] = Query(None, description="开始时间"),
end_time: Optional[datetime] = Query(None, description="结束时间"),
user_id: UUID | None = Query(None, description="按用户ID过滤"),
project_id: UUID | None = Query(None, description="按项目ID过滤"),
action: str | None = Query(None, description="按操作类型过滤"),
resource_type: str | None = Query(None, description="按资源类型过滤"),
start_time: datetime | None = Query(None, description="开始时间"),
end_time: datetime | None = Query(None, description="结束时间"),
skip: int = Query(0, ge=0, description="跳过记录数"),
limit: int = Query(100, ge=1, le=1000, description="限制记录数"),
current_user=Depends(get_current_metadata_admin),
_current_user=Depends(get_current_metadata_admin),
audit_repo: AuditRepository = Depends(get_audit_repository),
) -> List[AuditLogResponse]:
"""
查询审计日志
支持按用户、时间、操作类型等条件过滤,仅管理员可访问
"""
logs = await audit_repo.get_logs(
) -> list[AuditLogResponse]:
return await audit_repo.get_logs(
user_id=user_id,
project_id=project_id,
action=action,
@@ -60,29 +56,23 @@ async def get_audit_logs(
skip=skip,
limit=limit,
)
return logs
@router.get(
"/logs/count",
"/audit-logs/count",
summary="获取审计日志总数",
description="获取审计日志总数(仅管理员)",
)
async def get_audit_logs_count(
user_id: Optional[UUID] = Query(None, description="按用户ID过滤"),
project_id: Optional[UUID] = Query(None, description="按项目ID过滤"),
action: Optional[str] = Query(None, description="按操作类型过滤"),
resource_type: Optional[str] = Query(None, description="按资源类型过滤"),
start_time: Optional[datetime] = Query(None, description="开始时间"),
end_time: Optional[datetime] = Query(None, description="结束时间"),
current_user=Depends(get_current_metadata_admin),
user_id: UUID | None = Query(None, description="按用户ID过滤"),
project_id: UUID | None = Query(None, description="按项目ID过滤"),
action: str | None = Query(None, description="按操作类型过滤"),
resource_type: str | None = Query(None, description="按资源类型过滤"),
start_time: datetime | None = Query(None, description="开始时间"),
end_time: datetime | None = Query(None, description="结束时间"),
_current_user=Depends(get_current_metadata_admin),
audit_repo: AuditRepository = Depends(get_audit_repository),
) -> dict:
"""
获取审计日志总数
获取符合条件的审计日志的总数,仅管理员可访问
"""
count = await audit_repo.get_log_count(
user_id=user_id,
project_id=project_id,
@@ -94,27 +84,42 @@ async def get_audit_logs_count(
return {"count": count}
@router.post("/audit-events", status_code=status.HTTP_204_NO_CONTENT)
async def record_session_event(
payload: SessionAuditEventRequest,
request: Request,
current_user=Depends(get_current_metadata_user),
session: AsyncSession = Depends(get_metadata_session),
) -> None:
await log_audit_event(
action=AuditAction.LOGIN if payload.event == "login" else AuditAction.LOGOUT,
user_id=current_user.id,
resource_type="session",
resource_id=str(current_user.keycloak_id),
ip_address=request.client.host if request.client else None,
request_method=request.method,
request_path=request.url.path,
response_status=status.HTTP_204_NO_CONTENT,
session=session,
)
@router.get(
"/logs/my",
"/audit-logs/mine",
summary="查询我的审计日志",
description="查询当前用户的审计日志",
response_model=List[AuditLogResponse],
response_model=list[AuditLogResponse],
)
async def get_my_audit_logs(
action: Optional[str] = Query(None, description="按操作类型过滤"),
start_time: Optional[datetime] = Query(None, description="开始时间"),
end_time: Optional[datetime] = Query(None, description="结束时间"),
action: str | None = Query(None, description="按操作类型过滤"),
start_time: datetime | None = Query(None, description="开始时间"),
end_time: datetime | None = Query(None, description="结束时间"),
skip: int = Query(0, ge=0, description="跳过记录数"),
limit: int = Query(100, ge=1, le=1000, description="限制记录数"),
current_user=Depends(get_current_metadata_user),
audit_repo: AuditRepository = Depends(get_audit_repository),
) -> List[AuditLogResponse]:
"""
查询当前用户的审计日志
普通用户只能查看自己的操作记录
"""
logs = await audit_repo.get_logs(
) -> list[AuditLogResponse]:
return await audit_repo.get_logs(
user_id=current_user.id,
action=action,
start_time=start_time,
@@ -122,4 +127,3 @@ async def get_my_audit_logs(
skip=skip,
limit=limit,
)
return logs
+2 -65
View File
@@ -1,13 +1,11 @@
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
from fastapi import APIRouter, Depends, HTTPException, Body
from pydantic import BaseModel, Field
from app.auth.keycloak_dependencies import get_current_keycloak_username
from app.services.burst_detection import (
get_burst_detection_scheme_detail,
list_burst_detection_schemes,
run_burst_detection,
)
@@ -50,7 +48,7 @@ class BurstDetectionRequest(BaseModel):
@router.post(
"/detect/",
"/burst-detections",
summary="执行爆管检测",
description="基于压力观测数据和其他参数执行爆管检测分析"
)
@@ -78,64 +76,3 @@ async def detect_burst(
return run_burst_detection(**data.model_dump(), username=username)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.get(
"/schemes/",
summary="查询爆管检测方案列表",
description="获取指定网络的所有爆管检测方案"
)
async def query_burst_detection_schemes(
network: str = Query(..., description="管网名称(或数据库名称)"),
query_date: datetime | None = Query(None, description="查询日期(可选)"),
) -> list[dict[str, Any]]:
"""
获取爆管检测方案列表。
查询指定网络的所有已配置的爆管检测方案,
可按日期进行筛选。
Args:
network: 管网名称(或数据库名称)
query_date: 查询日期(可选)
Returns:
爆管检测方案列表
Raises:
HTTPException: 当查询失败时
"""
try:
return list_burst_detection_schemes(network=network, query_date=query_date)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.get(
"/schemes/{scheme_name}",
summary="获取爆管检测方案详情",
description="获取指定爆管检测方案的详细信息"
)
async def query_burst_detection_scheme_detail(
network: str = Query(..., description="管网名称(或数据库名称)"),
scheme_name: str = Path(..., description="爆管检测方案名称"),
) -> dict[str, Any]:
"""
获取爆管检测方案详情。
查询指定爆管检测方案的完整配置和参数信息。
Args:
network: 管网名称(或数据库名称)
scheme_name: 爆管检测方案名称
Returns:
包含方案详情的字典
Raises:
HTTPException: 当查询失败时
"""
try:
return get_burst_detection_scheme_detail(network=network, scheme_name=scheme_name)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
+2 -65
View File
@@ -3,13 +3,11 @@ from datetime import datetime
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
from fastapi import APIRouter, Depends, HTTPException, Body
from pydantic import BaseModel, Field
from app.auth.keycloak_dependencies import get_current_keycloak_username
from app.services.burst_location import (
get_burst_location_scheme_detail,
list_burst_location_schemes,
run_burst_location_by_network,
)
@@ -40,7 +38,7 @@ class BurstLocationRequest(BaseModel):
@router.post(
"/locate/",
"/burst-locations",
summary="执行爆管定位",
description="基于压力和流量数据定位管网中的爆管位置"
)
@@ -68,64 +66,3 @@ async def locate_burst(
return run_burst_location_by_network(**data.model_dump(), username=username)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.get(
"/schemes/",
summary="查询爆管定位方案列表",
description="获取指定网络的所有爆管定位方案"
)
async def query_burst_schemes(
network: str = Query(..., description="管网名称(或数据库名称)"),
query_date: datetime | None = Query(None, description="查询日期(可选)")
) -> list[dict[str, Any]]:
"""
获取爆管定位方案列表。
查询指定网络的所有已配置的爆管定位方案,
可按日期进行筛选。
Args:
network: 管网名称(或数据库名称)
query_date: 查询日期(可选)
Returns:
爆管定位方案列表
Raises:
HTTPException: 当查询失败时
"""
try:
return list_burst_location_schemes(network=network, query_date=query_date)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.get(
"/schemes/{scheme_name}",
summary="获取爆管定位方案详情",
description="获取指定爆管定位方案的详细信息"
)
async def query_burst_scheme_detail(
network: str = Query(..., description="管网名称(或数据库名称)"),
scheme_name: str = Path(..., description="爆管定位方案名称")
) -> dict[str, Any]:
"""
获取爆管定位方案详情。
查询指定爆管定位方案的完整配置和参数信息。
Args:
network: 管网名称(或数据库名称)
scheme_name: 爆管定位方案名称
Returns:
包含方案详情的字典
Raises:
HTTPException: 当查询失败时
"""
try:
return get_burst_location_scheme_detail(network=network, scheme_name=scheme_name)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
+4 -4
View File
@@ -3,7 +3,7 @@ from app.infra.cache.redis_client import redis_client
router = APIRouter()
@router.post("/clearrediskey/", summary="清除单个缓存键", description="根据键名清除单个Redis缓存")
@router.delete("/redis-keys/detail", summary="清除单个缓存键", description="根据键名清除单个Redis缓存")
async def fastapi_clear_redis_key(key: str = Query(..., description="缓存键名")):
"""
清除单个缓存键
@@ -14,7 +14,7 @@ async def fastapi_clear_redis_key(key: str = Query(..., description="缓存键
return True
@router.post("/clearrediskeys/", summary="清除匹配的缓存键", description="根据模式清除匹配的Redis缓存键")
@router.delete("/redis-keys", summary="清除匹配的缓存键", description="根据模式清除匹配的Redis缓存键")
async def fastapi_clear_redis_keys(keys: str = Query(..., description="缓存键模式(支持通配符)")):
"""
清除匹配的缓存键
@@ -29,7 +29,7 @@ async def fastapi_clear_redis_keys(keys: str = Query(..., description="缓存键
return True
@router.post("/clearallredis/", summary="清除所有缓存", description="清空整个Redis数据库的所有缓存")
@router.delete("/all-redis", summary="清除所有缓存", description="清空整个Redis数据库的所有缓存")
async def fastapi_clear_all_redis():
"""
清除所有缓存
@@ -40,7 +40,7 @@ async def fastapi_clear_all_redis():
return True
@router.get("/queryredis/", summary="查询缓存键列表", description="获取Redis中所有的缓存键")
@router.get("/redis", summary="查询缓存键列表", description="获取Redis中所有的缓存键")
async def fastapi_query_redis():
"""
查询缓存键列表
+6 -6
View File
@@ -13,7 +13,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/getcontrolschema/", summary="获取控制架构", description="获取网络中控制对象的架构定义")
@router.get("/network-schemas/control", summary="获取控制架构", description="获取网络中控制对象的架构定义")
async def fastapi_get_control_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取控制架构。
@@ -21,7 +21,7 @@ async def fastapi_get_control_schema(network: str = Query(..., description="管
"""
return get_control_schema(network)
@router.get("/getcontrolproperties/", summary="获取控制属性", description="获取指定网络中的控制属性信息")
@router.get("/controls/properties", summary="获取控制属性", description="获取指定网络中的控制属性信息")
async def fastapi_get_control_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取控制属性。
@@ -29,7 +29,7 @@ async def fastapi_get_control_properties(network: str = Query(..., description="
"""
return get_control(network)
@router.post("/setcontrolproperties/", response_model=None, summary="设置控制属性", description="更新指定网络中的控制属性")
@router.patch("/controls/properties", response_model=None, summary="设置控制属性", description="更新指定网络中的控制属性")
async def fastapi_set_control_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -41,7 +41,7 @@ async def fastapi_set_control_properties(
props = await req.json()
return set_control(network, ChangeSet(props))
@router.get("/getruleschema/", summary="获取规则架构", description="获取网络中规则对象的架构定义")
@router.get("/rule-schemas", summary="获取规则架构", description="获取网络中规则对象的架构定义")
async def fastapi_get_rule_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取规则架构。
@@ -49,7 +49,7 @@ async def fastapi_get_rule_schema(network: str = Query(..., description="管网
"""
return get_rule_schema(network)
@router.get("/getruleproperties/", summary="获取规则属性", description="获取指定网络中的规则属性信息")
@router.get("/rule-properties", summary="获取规则属性", description="获取指定网络中的规则属性信息")
async def fastapi_get_rule_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取规则属性。
@@ -57,7 +57,7 @@ async def fastapi_get_rule_properties(network: str = Query(..., description="管
"""
return get_rule(network)
@router.post("/setruleproperties/", response_model=None, summary="设置规则属性", description="更新指定网络中的规则属性")
@router.patch("/rule-properties", response_model=None, summary="设置规则属性", description="更新指定网络中的规则属性")
async def fastapi_set_rule_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
+7 -7
View File
@@ -14,7 +14,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/getcurveschema", summary="获取曲线架构", description="获取网络中曲线对象的架构定义")
@router.get("/network-schemas/curve", summary="获取曲线架构", description="获取网络中曲线对象的架构定义")
async def fastapi_get_curve_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取曲线架构。
@@ -22,7 +22,7 @@ async def fastapi_get_curve_schema(network: str = Query(..., description="管网
"""
return get_curve_schema(network)
@router.post("/addcurve/", response_model=None, summary="添加曲线", description="在网络中添加一条新的曲线")
@router.post("/curves", response_model=None, summary="添加曲线", description="在网络中添加一条新的曲线")
async def fastapi_add_curve(
network: str = Query(..., description="管网名称(或数据库名称)"),
curve: str = Query(..., description="曲线ID"),
@@ -38,7 +38,7 @@ async def fastapi_add_curve(
} | props
return add_curve(network, ChangeSet(ps))
@router.post("/deletecurve/", response_model=None, summary="删除曲线", description="从网络中删除指定的曲线")
@router.delete("/curves", response_model=None, summary="删除曲线", description="从网络中删除指定的曲线")
async def fastapi_delete_curve(
network: str = Query(..., description="管网名称(或数据库名称)"),
curve: str = Query(..., description="曲线ID")
@@ -50,7 +50,7 @@ async def fastapi_delete_curve(
ps = {"id": curve}
return delete_curve(network, ChangeSet(ps))
@router.get("/getcurveproperties/", summary="获取曲线属性", description="获取指定曲线的属性信息")
@router.get("/curves/properties", summary="获取曲线属性", description="获取指定曲线的属性信息")
async def fastapi_get_curve_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
curve: str = Query(..., description="曲线ID")
@@ -61,7 +61,7 @@ async def fastapi_get_curve_properties(
"""
return get_curve(network, curve)
@router.post("/setcurveproperties/", response_model=None, summary="设置曲线属性", description="更新指定曲线的属性")
@router.patch("/curves/properties", response_model=None, summary="设置曲线属性", description="更新指定曲线的属性")
async def fastapi_set_curve_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
curve: str = Query(..., description="曲线ID"),
@@ -75,7 +75,7 @@ async def fastapi_set_curve_properties(
ps = {"id": curve} | props
return set_curve(network, ChangeSet(ps))
@router.get("/getcurves/", summary="获取所有曲线", description="获取网络中的所有曲线列表")
@router.get("/curves", summary="获取所有曲线", description="获取网络中的所有曲线列表")
async def fastapi_get_curves(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
"""获取所有曲线。
@@ -83,7 +83,7 @@ async def fastapi_get_curves(network: str = Query(..., description="管网名称
"""
return get_curves(network)
@router.get("/iscurve/", summary="检查曲线存在性", description="检查指定的曲线是否存在")
@router.get("/curves/existence", summary="检查曲线存在性", description="检查指定的曲线是否存在")
async def fastapi_is_curve(
network: str = Query(..., description="管网名称(或数据库名称)"),
curve: str = Query(..., description="曲线ID")
+12 -12
View File
@@ -19,7 +19,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/gettimeschema", summary="获取时间选项架构", description="获取网络中时间选项的架构定义")
@router.get("/network-schemas/time", summary="获取时间选项架构", description="获取网络中时间选项的架构定义")
async def fastapi_get_time_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取时间选项架构。
@@ -27,7 +27,7 @@ async def fastapi_get_time_schema(network: str = Query(..., description="管网
"""
return get_time_schema(network)
@router.get("/gettimeproperties/", summary="获取时间选项属性", description="获取指定网络中的时间选项属性信息")
@router.get("/network-options/time", summary="获取时间选项属性", description="获取指定网络中的时间选项属性信息")
async def fastapi_get_time_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取时间选项属性。
@@ -35,7 +35,7 @@ async def fastapi_get_time_properties(network: str = Query(..., description="管
"""
return get_time(network)
@router.post("/settimeproperties/", response_model=None, summary="设置时间选项属性", description="更新指定网络中的时间选项属性")
@router.patch("/time-properties", response_model=None, summary="设置时间选项属性", description="更新指定网络中的时间选项属性")
async def fastapi_set_time_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -47,7 +47,7 @@ async def fastapi_set_time_properties(
props = await req.json()
return set_time(network, ChangeSet(props))
@router.get("/getenergyschema/", summary="获取能耗选项架构", description="获取网络中能耗选项的架构定义")
@router.get("/network-schemas/energy", summary="获取能耗选项架构", description="获取网络中能耗选项的架构定义")
async def fastapi_get_energy_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取能耗选项架构。
@@ -55,7 +55,7 @@ async def fastapi_get_energy_schema(network: str = Query(..., description="管
"""
return get_energy_schema(network)
@router.get("/getenergyproperties/", summary="获取能耗选项属性", description="获取指定网络中的能耗选项属性信息")
@router.get("/network-options/energy", summary="获取能耗选项属性", description="获取指定网络中的能耗选项属性信息")
async def fastapi_get_energy_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取能耗选项属性。
@@ -63,7 +63,7 @@ async def fastapi_get_energy_properties(network: str = Query(..., description="
"""
return get_energy(network)
@router.post("/setenergyproperties/", response_model=None, summary="设置能耗选项属性", description="更新指定网络中的能耗选项属性")
@router.patch("/energy-properties", response_model=None, summary="设置能耗选项属性", description="更新指定网络中的能耗选项属性")
async def fastapi_set_energy_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -75,7 +75,7 @@ async def fastapi_set_energy_properties(
props = await req.json()
return set_energy(network, ChangeSet(props))
@router.get("/getpumpenergyschema/", summary="获取泵能耗选项架构", description="获取网络中泵能耗选项的架构定义")
@router.get("/network-schemas/pump-energy", summary="获取泵能耗选项架构", description="获取网络中泵能耗选项的架构定义")
async def fastapi_get_pump_energy_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取泵能耗选项架构。
@@ -83,7 +83,7 @@ async def fastapi_get_pump_energy_schema(network: str = Query(..., description="
"""
return get_pump_energy_schema(network)
@router.get("/getpumpenergyproperties//", summary="获取泵能耗属性", description="获取指定泵的能耗属性信息")
@router.get("/network-options/pump-energy", summary="获取泵能耗属性", description="获取指定泵的能耗属性信息")
async def fastapi_get_pump_energy_proeprties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="泵ID")
@@ -94,7 +94,7 @@ async def fastapi_get_pump_energy_proeprties(
"""
return get_pump_energy(network, pump)
@router.get("/setpumpenergyproperties//", response_model=None, summary="设置泵能耗属性", description="更新指定泵的能耗属性")
@router.patch("/network-options/pump-energy", response_model=None, summary="设置泵能耗属性", description="更新指定泵的能耗属性")
async def fastapi_set_pump_energy_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="泵ID"),
@@ -108,7 +108,7 @@ async def fastapi_set_pump_energy_properties(
ps = {"id": pump} | props
return set_pump_energy(network, ChangeSet(ps))
@router.get("/getoptionschema/", summary="获取选项架构", description="获取网络中选项对象的架构定义")
@router.get("/network-schemas/option", summary="获取选项架构", description="获取网络中选项对象的架构定义")
async def fastapi_get_option_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取选项架构。
@@ -116,7 +116,7 @@ async def fastapi_get_option_schema(network: str = Query(..., description="管
"""
return get_option_v3_schema(network)
@router.get("/getoptionproperties/", summary="获取选项属性", description="获取指定网络中的选项属性信息")
@router.get("/network-options", summary="获取选项属性", description="获取指定网络中的选项属性信息")
async def fastapi_get_option_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取选项属性。
@@ -124,7 +124,7 @@ async def fastapi_get_option_properties(network: str = Query(..., description="
"""
return get_option_v3(network)
@router.post("/setoptionproperties/", response_model=None, summary="设置选项属性", description="更新指定网络中的选项属性")
@router.patch("/network-options", response_model=None, summary="设置选项属性", description="更新指定网络中的选项属性")
async def fastapi_set_option_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
+7 -7
View File
@@ -14,7 +14,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/getpatternschema", summary="获取模式架构", description="获取网络中模式对象的架构定义")
@router.get("/network-schemas/pattern", summary="获取模式架构", description="获取网络中模式对象的架构定义")
async def fastapi_get_pattern_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取模式架构。
@@ -22,7 +22,7 @@ async def fastapi_get_pattern_schema(network: str = Query(..., description="管
"""
return get_pattern_schema(network)
@router.post("/addpattern/", response_model=None, summary="添加模式", description="在网络中添加一个新的模式")
@router.post("/patterns", response_model=None, summary="添加模式", description="在网络中添加一个新的模式")
async def fastapi_add_pattern(
network: str = Query(..., description="管网名称(或数据库名称)"),
pattern: str = Query(..., description="模式ID"),
@@ -38,7 +38,7 @@ async def fastapi_add_pattern(
} | props
return add_pattern(network, ChangeSet(ps))
@router.post("/deletepattern/", response_model=None, summary="删除模式", description="从网络中删除指定的模式")
@router.delete("/patterns", response_model=None, summary="删除模式", description="从网络中删除指定的模式")
async def fastapi_delete_pattern(
network: str = Query(..., description="管网名称(或数据库名称)"),
pattern: str = Query(..., description="模式ID")
@@ -50,7 +50,7 @@ async def fastapi_delete_pattern(
ps = {"id": pattern}
return delete_pattern(network, ChangeSet(ps))
@router.get("/getpatternproperties/", summary="获取模式属性", description="获取指定模式的属性信息")
@router.get("/patterns/properties", summary="获取模式属性", description="获取指定模式的属性信息")
async def fastapi_get_pattern_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pattern: str = Query(..., description="模式ID")
@@ -61,7 +61,7 @@ async def fastapi_get_pattern_properties(
"""
return get_pattern(network, pattern)
@router.post("/setpatternproperties/", response_model=None, summary="设置模式属性", description="更新指定模式的属性")
@router.patch("/patterns/properties", response_model=None, summary="设置模式属性", description="更新指定模式的属性")
async def fastapi_set_pattern_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pattern: str = Query(..., description="模式ID"),
@@ -75,7 +75,7 @@ async def fastapi_set_pattern_properties(
ps = {"id": pattern} | props
return set_pattern(network, ChangeSet(ps))
@router.get("/ispattern/", summary="检查模式存在性", description="检查指定的模式是否存在")
@router.get("/patterns/existence", summary="检查模式存在性", description="检查指定的模式是否存在")
async def fastapi_is_pattern(
network: str = Query(..., description="管网名称(或数据库名称)"),
pattern: str = Query(..., description="模式ID")
@@ -86,7 +86,7 @@ async def fastapi_is_pattern(
"""
return is_pattern(network, pattern)
@router.get("/getpatterns/", summary="获取所有模式", description="获取网络中的所有模式列表")
@router.get("/patterns", summary="获取所有模式", description="获取网络中的所有模式列表")
async def fastapi_get_patterns(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
"""获取所有模式。
+25 -25
View File
@@ -32,7 +32,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/getqualityschema/", summary="获取水质架构", description="获取网络中水质对象的架构定义")
@router.get("/network-schemas/quality", summary="获取水质架构", description="获取网络中水质对象的架构定义")
async def fastapi_get_quality_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取水质架构。
@@ -40,7 +40,7 @@ async def fastapi_get_quality_schema(network: str = Query(..., description="管
"""
return get_quality_schema(network)
@router.get("/getqualityproperties/", summary="获取水质属性", description="获取指定节点的水质属性信息")
@router.get("/quality-configurations/properties", summary="获取水质属性", description="获取指定节点的水质属性信息")
async def fastapi_get_quality_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
@@ -51,7 +51,7 @@ async def fastapi_get_quality_properties(
"""
return get_quality(network, node)
@router.post("/setqualityproperties/", response_model=None, summary="设置水质属性", description="更新指定节点的水质属性")
@router.patch("/quality-configurations/properties", response_model=None, summary="设置水质属性", description="更新指定节点的水质属性")
async def fastapi_set_quality_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -63,7 +63,7 @@ async def fastapi_set_quality_properties(
props = await req.json()
return set_quality(network, ChangeSet(props))
@router.get("/getemitterschema", summary="获取发射器架构", description="获取网络中发射器对象的架构定义")
@router.get("/network-schemas/emitter", summary="获取发射器架构", description="获取网络中发射器对象的架构定义")
async def fastapi_get_emitter_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取发射器架构。
@@ -71,7 +71,7 @@ async def fastapi_get_emitter_schema(network: str = Query(..., description="管
"""
return get_emitter_schema(network)
@router.get("/getemitterproperties/", summary="获取发射器属性", description="获取指定连接点的发射器属性信息")
@router.get("/emitters/properties", summary="获取发射器属性", description="获取指定连接点的发射器属性信息")
async def fastapi_get_emitter_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="连接点ID")
@@ -82,7 +82,7 @@ async def fastapi_get_emitter_properties(
"""
return get_emitter(network, junction)
@router.post("/setemitterproperties/", response_model=None, summary="设置发射器属性", description="更新指定连接点的发射器属性")
@router.patch("/emitters/properties", response_model=None, summary="设置发射器属性", description="更新指定连接点的发射器属性")
async def fastapi_set_emitter_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="连接点ID"),
@@ -96,7 +96,7 @@ async def fastapi_set_emitter_properties(
ps = {"junction": junction} | props
return set_emitter(network, ChangeSet(ps))
@router.get("/getsourcechema/", summary="获取水源架构", description="获取网络中水源对象的架构定义")
@router.get("/network-schemas/source", summary="获取水源架构", description="获取网络中水源对象的架构定义")
async def fastapi_get_source_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取水源架构。
@@ -104,7 +104,7 @@ async def fastapi_get_source_schema(network: str = Query(..., description="管
"""
return get_source_schema(network)
@router.get("/getsource/", summary="获取水源属性", description="获取指定节点的水源属性信息")
@router.get("/sources/detail", summary="获取水源属性", description="获取指定节点的水源属性信息")
async def fastapi_get_source(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
@@ -115,7 +115,7 @@ async def fastapi_get_source(
"""
return get_source(network, node)
@router.post("/setsource/", response_model=None, summary="设置水源属性", description="更新指定节点的水源属性")
@router.patch("/sources", response_model=None, summary="设置水源属性", description="更新指定节点的水源属性")
async def fastapi_set_source(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -127,7 +127,7 @@ async def fastapi_set_source(
props = await req.json()
return set_source(network, ChangeSet(props))
@router.post("/addsource/", response_model=None, summary="添加水源", description="在网络中添加一个新的水源")
@router.post("/sources", response_model=None, summary="添加水源", description="在网络中添加一个新的水源")
async def fastapi_add_source(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -139,7 +139,7 @@ async def fastapi_add_source(
props = await req.json()
return add_source(network, ChangeSet(props))
@router.post("/deletesource/", response_model=None, summary="删除水源", description="从网络中删除指定节点的水源")
@router.delete("/sources", response_model=None, summary="删除水源", description="从网络中删除指定节点的水源")
async def fastapi_delete_source(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
@@ -151,7 +151,7 @@ async def fastapi_delete_source(
props = {"node": node}
return delete_source(network, ChangeSet(props))
@router.get("/getreactionschema/", summary="获取反应架构", description="获取网络中反应对象的架构定义")
@router.get("/network-schemas/reaction", summary="获取反应架构", description="获取网络中反应对象的架构定义")
async def fastapi_get_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取反应架构。
@@ -159,7 +159,7 @@ async def fastapi_get_reaction_schema(network: str = Query(..., description="管
"""
return get_reaction_schema(network)
@router.get("/getreaction/", summary="获取反应属性", description="获取指定网络中的反应属性信息")
@router.get("/reactions/detail", summary="获取反应属性", description="获取指定网络中的反应属性信息")
async def fastapi_get_reaction(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取反应属性。
@@ -167,7 +167,7 @@ async def fastapi_get_reaction(network: str = Query(..., description="管网名
"""
return get_reaction(network)
@router.post("/setreaction/", response_model=None, summary="设置反应属性", description="更新指定网络中的反应属性")
@router.patch("/reactions", response_model=None, summary="设置反应属性", description="更新指定网络中的反应属性")
async def fastapi_set_reaction(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -179,7 +179,7 @@ async def fastapi_set_reaction(
props = await req.json()
return set_reaction(network, ChangeSet(props))
@router.get("/getpipereactionschema/", summary="获取管道反应架构", description="获取网络中管道反应对象的架构定义")
@router.get("/network-schemas/pipe-reaction", summary="获取管道反应架构", description="获取网络中管道反应对象的架构定义")
async def fastapi_get_pipe_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取管道反应架构。
@@ -187,7 +187,7 @@ async def fastapi_get_pipe_reaction_schema(network: str = Query(..., description
"""
return get_pipe_reaction_schema(network)
@router.get("/getpipereaction/", summary="获取管道反应属性", description="获取指定管道的反应属性信息")
@router.get("/pipe-reactions/detail", summary="获取管道反应属性", description="获取指定管道的反应属性信息")
async def fastapi_get_pipe_reaction(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
@@ -198,7 +198,7 @@ async def fastapi_get_pipe_reaction(
"""
return get_pipe_reaction(network, pipe)
@router.post("/setpipereaction/", response_model=None, summary="设置管道反应属性", description="更新指定管道的反应属性")
@router.patch("/pipe-reactions", response_model=None, summary="设置管道反应属性", description="更新指定管道的反应属性")
async def fastapi_set_pipe_reaction(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -210,7 +210,7 @@ async def fastapi_set_pipe_reaction(
props = await req.json()
return set_pipe_reaction(network, ChangeSet(props))
@router.get("/gettankreactionschema/", summary="获取水池反应架构", description="获取网络中水池反应对象的架构定义")
@router.get("/network-schemas/tank-reaction", summary="获取水池反应架构", description="获取网络中水池反应对象的架构定义")
async def fastapi_get_tank_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取水池反应架构。
@@ -218,7 +218,7 @@ async def fastapi_get_tank_reaction_schema(network: str = Query(..., description
"""
return get_tank_reaction_schema(network)
@router.get("/gettankreaction/", summary="获取水池反应属性", description="获取指定水池的反应属性信息")
@router.get("/tank-reactions/detail", summary="获取水池反应属性", description="获取指定水池的反应属性信息")
async def fastapi_get_tank_reaction(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水池ID")
@@ -229,7 +229,7 @@ async def fastapi_get_tank_reaction(
"""
return get_tank_reaction(network, tank)
@router.post("/settankreaction/", response_model=None, summary="设置水池反应属性", description="更新指定水池的反应属性")
@router.patch("/tank-reactions", response_model=None, summary="设置水池反应属性", description="更新指定水池的反应属性")
async def fastapi_set_tank_reaction(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -241,7 +241,7 @@ async def fastapi_set_tank_reaction(
props = await req.json()
return set_tank_reaction(network, ChangeSet(props))
@router.get("/getmixingschema/", summary="获取混合架构", description="获取网络中混合对象的架构定义")
@router.get("/network-schemas/mixing", summary="获取混合架构", description="获取网络中混合对象的架构定义")
async def fastapi_get_mixing_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取混合架构。
@@ -249,7 +249,7 @@ async def fastapi_get_mixing_schema(network: str = Query(..., description="管
"""
return get_mixing_schema(network)
@router.get("/getmixing/", summary="获取混合属性", description="获取指定水池的混合属性信息")
@router.get("/mixing-configurations/detail", summary="获取混合属性", description="获取指定水池的混合属性信息")
async def fastapi_get_mixing(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水池ID")
@@ -260,7 +260,7 @@ async def fastapi_get_mixing(
"""
return get_mixing(network, tank)
@router.post("/setmixing/", response_model=None, summary="设置混合属性", description="更新指定水池的混合属性")
@router.patch("/mixing-configurations", response_model=None, summary="设置混合属性", description="更新指定水池的混合属性")
async def fastapi_set_mixing(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -272,7 +272,7 @@ async def fastapi_set_mixing(
props = await req.json()
return api.set_mixing(network, ChangeSet(props))
@router.post("/addmixing/", response_model=None, summary="添加混合", description="在网络中添加一个新的混合")
@router.post("/mixing-configurations", response_model=None, summary="添加混合", description="在网络中添加一个新的混合")
async def fastapi_add_mixing(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -284,7 +284,7 @@ async def fastapi_add_mixing(
props = await req.json()
return add_mixing(network, ChangeSet(props))
@router.post("/deletemixing/", response_model=None, summary="删除混合", description="从网络中删除指定的混合")
@router.delete("/mixing-configurations", response_model=None, summary="删除混合", description="从网络中删除指定的混合")
async def fastapi_delete_mixing(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
+15 -15
View File
@@ -24,7 +24,7 @@ import json
router = APIRouter()
@router.get("/getvertexschema/", summary="获取图形元素架构", description="获取网络中图形元素对象的架构定义")
@router.get("/network-schemas/vertex", summary="获取图形元素架构", description="获取网络中图形元素对象的架构定义")
async def fastapi_get_vertex_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取图形元素架构。
@@ -32,7 +32,7 @@ async def fastapi_get_vertex_schema(network: str = Query(..., description="管
"""
return get_vertex_schema(network)
@router.get("/getvertexproperties/", summary="获取图形元素属性", description="获取指定图形元素的属性信息")
@router.get("/visual-elements/properties", summary="获取图形元素属性", description="获取指定图形元素的属性信息")
async def fastapi_get_vertex_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
link: str = Query(..., description="图形元素链接")
@@ -43,7 +43,7 @@ async def fastapi_get_vertex_properties(
"""
return get_vertex(network, link)
@router.post("/setvertexproperties/", response_model=None, summary="设置图形元素属性", description="更新指定图形元素的属性")
@router.patch("/visual-elements/properties", response_model=None, summary="设置图形元素属性", description="更新指定图形元素的属性")
async def fastapi_set_vertex_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -55,7 +55,7 @@ async def fastapi_set_vertex_properties(
props = await req.json()
return set_vertex(network, ChangeSet(props))
@router.post("/addvertex/", response_model=None, summary="添加图形元素", description="在网络中添加一个新的图形元素")
@router.post("/visual-elements", response_model=None, summary="添加图形元素", description="在网络中添加一个新的图形元素")
async def fastapi_add_vertex(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -67,7 +67,7 @@ async def fastapi_add_vertex(
props = await req.json()
return add_vertex(network, ChangeSet(props))
@router.post("/deletevertex/", response_model=None, summary="删除图形元素", description="从网络中删除指定的图形元素")
@router.delete("/visual-elements", response_model=None, summary="删除图形元素", description="从网络中删除指定的图形元素")
async def fastapi_delete_vertex(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -79,7 +79,7 @@ async def fastapi_delete_vertex(
props = await req.json()
return delete_vertex(network, ChangeSet(props))
@router.get("/getallvertexlinks/", response_class=PlainTextResponse, summary="获取所有图形元素链接", description="获取网络中的所有图形元素链接列表")
@router.get("/visual-elements/links", response_class=PlainTextResponse, summary="获取所有图形元素链接", description="获取网络中的所有图形元素链接列表")
async def fastapi_get_all_vertex_links(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
"""获取所有图形元素链接。
@@ -87,7 +87,7 @@ async def fastapi_get_all_vertex_links(network: str = Query(..., description="
"""
return json.dumps(get_all_vertex_links(network))
@router.get("/getallvertices/", response_class=PlainTextResponse, summary="获取所有图形元素", description="获取网络中的所有图形元素详细信息")
@router.get("/all-vertices", response_class=PlainTextResponse, summary="获取所有图形元素", description="获取网络中的所有图形元素详细信息")
async def fastapi_get_all_vertices(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[str, Any]]:
"""获取所有图形元素。
@@ -95,7 +95,7 @@ async def fastapi_get_all_vertices(network: str = Query(..., description="管网
"""
return json.dumps(get_all_vertices(network))
@router.get("/getlabelschema/", summary="获取标签架构", description="获取网络中标签对象的架构定义")
@router.get("/network-schemas/label", summary="获取标签架构", description="获取网络中标签对象的架构定义")
async def fastapi_get_label_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取标签架构。
@@ -103,7 +103,7 @@ async def fastapi_get_label_schema(network: str = Query(..., description="管网
"""
return get_label_schema(network)
@router.get("/getlabelproperties/", summary="获取标签属性", description="获取指定坐标处的标签属性信息")
@router.get("/labels/properties", summary="获取标签属性", description="获取指定坐标处的标签属性信息")
async def fastapi_get_label_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
x: float = Query(..., description="X坐标"),
@@ -115,7 +115,7 @@ async def fastapi_get_label_properties(
"""
return get_label(network, x, y)
@router.post("/setlabelproperties/", response_model=None, summary="设置标签属性", description="更新指定标签的属性")
@router.patch("/labels/properties", response_model=None, summary="设置标签属性", description="更新指定标签的属性")
async def fastapi_set_label_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -127,7 +127,7 @@ async def fastapi_set_label_properties(
props = await req.json()
return set_label(network, ChangeSet(props))
@router.post("/addlabel/", response_model=None, summary="添加标签", description="在网络中添加一个新的标签")
@router.post("/labels", response_model=None, summary="添加标签", description="在网络中添加一个新的标签")
async def fastapi_add_label(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -139,7 +139,7 @@ async def fastapi_add_label(
props = await req.json()
return add_label(network, ChangeSet(props))
@router.post("/deletelabel/", response_model=None, summary="删除标签", description="从网络中删除指定的标签")
@router.delete("/labels", response_model=None, summary="删除标签", description="从网络中删除指定的标签")
async def fastapi_delete_label(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -151,7 +151,7 @@ async def fastapi_delete_label(
props = await req.json()
return delete_label(network, ChangeSet(props))
@router.get("/getbackdropschema/", summary="获取背景架构", description="获取网络中背景对象的架构定义")
@router.get("/network-schemas/backdrop", summary="获取背景架构", description="获取网络中背景对象的架构定义")
async def fastapi_get_backdrop_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取背景架构。
@@ -159,7 +159,7 @@ async def fastapi_get_backdrop_schema(network: str = Query(..., description="管
"""
return get_backdrop_schema(network)
@router.get("/getbackdropproperties/", summary="获取背景属性", description="获取指定网络的背景属性信息")
@router.get("/backdrops/properties", summary="获取背景属性", description="获取指定网络的背景属性信息")
async def fastapi_get_backdrop_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取背景属性。
@@ -167,7 +167,7 @@ async def fastapi_get_backdrop_properties(network: str = Query(..., description=
"""
return get_backdrop(network)
@router.post("/setbackdropproperties/", response_model=None, summary="设置背景属性", description="更新指定网络的背景属性")
@router.patch("/backdrops/properties", response_model=None, summary="设置背景属性", description="更新指定网络的背景属性")
async def fastapi_set_backdrop_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
+5 -5
View File
@@ -11,7 +11,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get(
"/getallextensiondatakeys/",
"/all-extension-data-keys",
summary="获取所有扩展数据键",
description="获取指定网络的所有扩展数据的键列表"
)
@@ -32,7 +32,7 @@ async def get_all_extension_data_keys_endpoint(
return get_all_extension_data_keys(network)
@router.get(
"/getallextensiondata/",
"/all-extension-datas",
summary="获取所有扩展数据",
description="获取指定网络的所有扩展数据"
)
@@ -53,7 +53,7 @@ async def get_all_extension_data_endpoint(
return get_all_extension_data(network)
@router.get(
"/getextensiondata/",
"/extension-datas",
summary="获取指定扩展数据",
description="获取指定网络中指定键的扩展数据值"
)
@@ -75,8 +75,8 @@ async def get_extension_data_endpoint(
"""
return get_extension_data(network, key)
@router.post(
"/setextensiondata/",
@router.patch(
"/extension-datas",
response_model=None,
summary="设置扩展数据",
description="设置指定网络中的扩展数据"
+1 -1
View File
@@ -13,7 +13,7 @@ router = APIRouter()
@router.post(
"/tianditu/geocode",
"/geocoding-requests",
summary="Tianditu Geocoding",
description="调用天地图地理编码服务,将结构化地址转换为经纬度",
)
+2 -67
View File
@@ -2,13 +2,11 @@ import os
from typing import Any
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
from fastapi import APIRouter, Depends, HTTPException, Body
from pydantic import BaseModel, Field
from app.auth.keycloak_dependencies import get_current_keycloak_username
from app.services.leakage_identifier import (
get_leakage_identify_scheme_detail,
list_leakage_identify_schemes,
run_leakage_identification,
)
@@ -40,7 +38,7 @@ class LeakageIdentifyRequest(BaseModel):
@router.post(
"/identify/",
"/leakage-identifications",
summary="执行漏损识别",
description="基于压力观测数据和遗传算法识别管网中的漏损位置和大小"
)
@@ -68,66 +66,3 @@ async def identify_leakage(
return run_leakage_identification(**data.model_dump(), username=username)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.get(
"/schemes/",
summary="查询漏损识别方案列表",
description="获取指定网络的所有漏损识别方案"
)
async def query_leakage_schemes(
network: str = Query(..., description="管网名称(或数据库名称)"),
query_date: datetime | None = Query(None, description="查询日期(可选)")
) -> list[dict[str, Any]]:
"""
获取漏损识别方案列表。
查询指定网络的所有已配置的漏损识别方案,
可按日期进行筛选。
Args:
network: 管网名称(或数据库名称)
query_date: 查询日期(可选)
Returns:
漏损识别方案列表
Raises:
HTTPException: 当查询失败时
"""
try:
return list_leakage_identify_schemes(network=network, query_date=query_date)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.get(
"/schemes/{scheme_name}",
summary="获取漏损识别方案详情",
description="获取指定漏损识别方案的详细信息"
)
async def query_leakage_scheme_detail(
network: str = Query(..., description="管网名称(或数据库名称)"),
scheme_name: str = Path(..., description="漏损识别方案名称")
) -> dict[str, Any]:
"""
获取漏损识别方案详情。
查询指定漏损识别方案的完整配置和参数信息。
Args:
network: 管网名称(或数据库名称)
scheme_name: 漏损识别方案名称
Returns:
包含方案详情的字典
Raises:
HTTPException: 当查询失败时
"""
try:
return get_leakage_identify_scheme_detail(
network=network, scheme_name=scheme_name
)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
+3 -3
View File
@@ -25,7 +25,7 @@ router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/meta/project", summary="获取项目元数据", description="获取当前项目的元数据和配置信息", response_model=ProjectMetaResponse)
@router.get("/projects/current/metadata", summary="获取项目元数据", description="获取当前项目的元数据和配置信息", response_model=ProjectMetaResponse)
async def get_project_metadata(
ctx: ProjectContext = Depends(get_project_context),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
@@ -52,7 +52,7 @@ async def get_project_metadata(
)
@router.get("/meta/projects", summary="列出用户项目", description="获取当前用户有权限的所有项目列表", response_model=list[ProjectSummaryResponse])
@router.get("/projects", summary="列出用户项目", description="获取当前用户有权限的所有项目列表", response_model=list[ProjectSummaryResponse])
async def list_user_projects(
current_user=Depends(get_current_metadata_user),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
@@ -88,7 +88,7 @@ async def list_user_projects(
]
@router.get("/meta/db/health", summary="检查数据库健康状态", description="检查项目数据库连接的健康状况")
@router.get("/projects/current/database-health", summary="检查数据库健康状态", description="检查项目数据库连接的健康状况")
async def project_db_health(
pg_session: AsyncSession = Depends(get_project_pg_session),
ts_conn: AsyncConnection = Depends(get_project_timescale_connection),
+1 -4
View File
@@ -11,7 +11,6 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/getjson/", summary="获取JSON示例", description="获取JSON格式响应示例")
async def fastapi_get_json():
"""
获取JSON示例
@@ -29,7 +28,6 @@ async def fastapi_get_json():
@router.get("/sensor-placement-schemes", summary="获取所有传感器位置", description="获取网络中所有传感器的放置位置信息")
@router.get("/getallsensorplacements/", summary="获取所有传感器位置(旧路径)", description="获取网络中所有传感器的放置位置信息", deprecated=True)
async def fastapi_get_all_sensor_placements(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[Any, Any]]:
"""
获取所有传感器位置
@@ -39,7 +37,7 @@ async def fastapi_get_all_sensor_placements(network: str = Query(..., descriptio
return get_all_sensor_placements(network)
@router.get("/getallburstlocateresults/", summary="获取所有爆管定位结果", description="获取网络中所有爆管定位的分析结果")
@router.get("/burst-locations", summary="获取所有爆管定位结果", description="获取网络中所有爆管定位的分析结果")
async def fastapi_get_all_burst_locate_results(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[Any, Any]]:
"""
获取所有爆管定位结果
@@ -54,7 +52,6 @@ class Item(BaseModel):
str_info: str
@router.post("/test_dict/", summary="测试字典处理", description="测试处理字典类型数据")
async def fastapi_test_dict(data: Item) -> dict[str, str]:
"""
测试字典处理
+188
View File
@@ -0,0 +1,188 @@
from pathlib import Path
from tempfile import NamedTemporaryFile
from uuid import UUID, uuid4
from fastapi import (
APIRouter,
Depends,
File,
HTTPException,
Path as ApiPath,
Request,
UploadFile,
status,
)
from app.auth.metadata_dependencies import (
get_current_metadata_admin,
get_metadata_repository,
)
from app.core.audit import AuditAction, log_audit_event
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.services.network_import import network_update
from app.services.tjnetwork import run_inp
router = APIRouter()
MAX_INP_FILE_BYTES = 50 * 1024 * 1024
INP_SECTIONS = ("[TITLE]", "[JUNCTIONS]", "[RESERVOIRS]", "[TANKS]", "[PIPES]")
async def _get_active_project(project_id: UUID, metadata_repo: MetadataRepository):
project = await metadata_repo.get_project_by_id(project_id)
if project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found",
)
if project.status != "active":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Project is not active",
)
return project
def _validate_inp_bytes(content: bytes, filename: str) -> str:
if Path(filename).suffix.lower() != ".inp":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only .inp model files are accepted",
)
if not content:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="INP file is empty",
)
if len(content) > MAX_INP_FILE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="INP file exceeds the 50 MiB limit",
)
for encoding in ("utf-8-sig", "gb18030"):
try:
text = content.decode(encoding)
break
except UnicodeDecodeError:
continue
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="INP file encoding is not supported",
)
upper_text = text.upper()
if not any(section in upper_text for section in INP_SECTIONS):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid INP file structure",
)
return text
async def _read_upload(file: UploadFile) -> tuple[bytes, str]:
filename = Path(file.filename or "").name
content = await file.read(MAX_INP_FILE_BYTES + 1)
_validate_inp_bytes(content, filename)
return content, filename
async def _audit_model_change(
*,
request: Request,
current_user,
metadata_repo: MetadataRepository,
project_id: UUID,
action: str,
) -> None:
await log_audit_event(
action=AuditAction.UPDATE,
user_id=current_user.id,
project_id=project_id,
resource_type="hydraulic_model",
resource_id=action,
request_data={"operation": action},
ip_address=request.client.host if request.client else None,
request_method=request.method,
request_path=request.url.path,
response_status=status.HTTP_200_OK,
session=metadata_repo.session,
)
async def _run_uploaded_inp(content: bytes) -> str:
target_dir = Path("inp")
target_dir.mkdir(parents=True, exist_ok=True)
model_name = f"admin_model_{uuid4().hex}"
target_path = target_dir / f"{model_name}.inp"
target_path.write_bytes(content)
return run_inp(model_name)
async def _update_from_inp(content: bytes) -> None:
temp_path: Path | None = None
try:
with NamedTemporaryFile(suffix=".inp", delete=False) as temp_file:
temp_file.write(content)
temp_path = Path(temp_file.name)
network_update(str(temp_path))
finally:
if temp_path is not None:
temp_path.unlink(missing_ok=True)
async def _apply_model_update(content: bytes) -> None:
try:
await _update_from_inp(content)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"数据库操作失败: {exc}",
) from exc
@router.post(
"/admin/projects/{project_id}/model-imports",
summary="导入桌面端水力模型",
)
async def import_project_model(
request: Request,
project_id: UUID = ApiPath(...),
file: UploadFile = File(..., description="桌面端导出的 INP 模型文件"),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> dict:
project = await _get_active_project(project_id, metadata_repo)
content, filename = await _read_upload(file)
result = await _run_uploaded_inp(content)
await _audit_model_change(
request=request,
current_user=current_user,
metadata_repo=metadata_repo,
project_id=project.id,
action="import",
)
return {"project_id": str(project.id), "filename": filename, "result": result}
@router.patch(
"/admin/projects/{project_id}/model-imports",
summary="更新桌面端水力模型",
)
async def update_project_model(
request: Request,
project_id: UUID = ApiPath(...),
file: UploadFile = File(..., description="桌面端导出的 INP 模型文件"),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> dict:
project = await _get_active_project(project_id, metadata_repo)
content, filename = await _read_upload(file)
await _apply_model_update(content)
await _audit_model_change(
request=request,
current_user=current_user,
metadata_repo=metadata_repo,
project_id=project.id,
action="update",
)
return {"project_id": str(project.id), "filename": filename, "updated": True}
+10 -10
View File
@@ -18,7 +18,7 @@ router = APIRouter()
############################################################
@router.get(
"/getdemandschema",
"/network-schemas/demand",
summary="获取需水量属性架构",
description="获取指定水网中需水量(Demand)的属性架构定义"
)
@@ -32,7 +32,7 @@ async def fastapi_get_demand_schema(network: str = Query(..., description="管
@router.get(
"/getdemandproperties/",
"/demands/properties",
summary="获取需水量属性",
description="获取指定水网中节点的需水量属性信息"
)
@@ -49,8 +49,8 @@ async def fastapi_get_demand_properties(
# example: set_demand(p, ChangeSet({'junction': 'j1', 'demands': [{'demand': 10.0, 'pattern': None, 'category': 'x'}, {'demand': 20.0, 'pattern': None, 'category': None}]}))
@router.post(
"/setdemandproperties/",
@router.patch(
"/demands/properties",
response_model=None,
summary="设置需水量属性",
description="设置指定水网中节点的需水量属性信息"
@@ -72,8 +72,8 @@ async def fastapi_set_demand_properties(
############################################################
# water distribution 36.[Water Distribution]
############################################################
@router.get(
"/calculatedemandtonodes/",
@router.post(
"/demands/to-nodes",
summary="计算需水量到节点分配",
description="将总需水量按指定方式分配到多个节点"
)
@@ -97,8 +97,8 @@ async def fastapi_calculate_demand_to_nodes(
nodes = props["nodes"]
return calculate_demand_to_nodes(network, demand, nodes)
@router.get(
"/calculatedemandtoregion/",
@router.post(
"/demands/to-region",
summary="计算需水量到区域分配",
description="将总需水量按区域特征分配到该区域内的节点"
)
@@ -122,8 +122,8 @@ async def fastapi_calculate_demand_to_region(
region = props["region"]
return calculate_demand_to_region(network, demand, region)
@router.get(
"/calculatedemandtonetwork/",
@router.post(
"/demands/to-network",
summary="计算需水量到整网分配",
description="将需水量均匀分配到整个水网的所有需水节点"
)
+33 -33
View File
@@ -45,7 +45,7 @@ router = APIRouter()
############################################################
@router.get(
"/isnode/",
"/nodes/existence",
summary="检查节点有效性",
description="检查指定ID是否为水网中的有效节点"
)
@@ -57,7 +57,7 @@ async def fastapi_is_node(
return is_node(network, node)
@router.get(
"/isjunction/",
"/junctions/existence",
summary="检查是否为接点",
description="检查指定ID是否为水网中的接点(需求点)"
)
@@ -69,7 +69,7 @@ async def fastapi_is_junction(
return is_junction(network, node)
@router.get(
"/isreservoir/",
"/reservoirs/existence",
summary="检查是否为水源",
description="检查指定ID是否为水网中的水源(水库/河流)"
)
@@ -81,7 +81,7 @@ async def fastapi_is_reservoir(
return is_reservoir(network, node)
@router.get(
"/istank/",
"/tanks/existence",
summary="检查是否为蓄水池",
description="检查指定ID是否为水网中的蓄水池"
)
@@ -93,7 +93,7 @@ async def fastapi_is_tank(
return is_tank(network, node)
@router.get(
"/islink/",
"/links/existence",
summary="检查管线有效性",
description="检查指定ID是否为水网中的有效管线"
)
@@ -105,7 +105,7 @@ async def fastapi_is_link(
return is_link(network, link)
@router.get(
"/ispipe/",
"/pipes/existence",
summary="检查是否为管道",
description="检查指定ID是否为水网中的管道"
)
@@ -117,7 +117,7 @@ async def fastapi_is_pipe(
return is_pipe(network, link)
@router.get(
"/ispump/",
"/pumps/existence",
summary="检查是否为泵",
description="检查指定ID是否为水网中的泵"
)
@@ -129,7 +129,7 @@ async def fastapi_is_pump(
return is_pump(network, link)
@router.get(
"/isvalve/",
"/valves/existence",
summary="检查是否为阀门",
description="检查指定ID是否为水网中的阀门"
)
@@ -141,7 +141,7 @@ async def fastapi_is_valve(
return is_valve(network, link)
@router.get(
"/getnodetype/",
"/node-types",
summary="获取节点类型",
description="获取指定节点的类型(接点/水源/蓄水池)"
)
@@ -153,7 +153,7 @@ async def fastapi_get_node_type(
return get_node_type(network, node)
@router.get(
"/getlinktype/",
"/link-types",
summary="获取管线类型",
description="获取指定管线的类型(管道/泵/阀门)"
)
@@ -165,7 +165,7 @@ async def fastapi_get_link_type(
return get_link_type(network, link)
@router.get(
"/getelementtype/",
"/element-types",
summary="获取元素类型",
description="获取指定元素的类型(节点或管线)"
)
@@ -177,7 +177,7 @@ async def fastapi_get_element_type(
return get_element_type(network, element)
@router.get(
"/getelementtypevalue/",
"/element-type-values",
summary="获取元素类型值",
description="获取指定元素的类型数值标识"
)
@@ -189,7 +189,7 @@ async def fastapi_get_element_type_value(
return get_element_type_value(network, element)
@router.get(
"/getnodes/",
"/nodes",
summary="获取所有节点",
description="获取指定水网中的所有节点ID列表"
)
@@ -198,7 +198,7 @@ async def fastapi_get_nodes(network: str = Query(..., description="管网名称
return get_nodes(network)
@router.get(
"/getlinks/",
"/links",
summary="获取所有管线",
description="获取指定水网中的所有管线ID列表"
)
@@ -207,7 +207,7 @@ async def fastapi_get_links(network: str = Query(..., description="管网名称
return get_links(network)
@router.get(
"/getnodelinks/",
"/node-links",
summary="获取节点的关联管线",
description="获取指定节点连接的所有管线ID列表"
)
@@ -223,7 +223,7 @@ def get_node_links_endpoint(
############################################################
@router.get(
"/getnodeproperties/",
"/node-properties",
summary="获取节点属性",
description="获取指定节点的所有属性信息"
)
@@ -235,7 +235,7 @@ async def fast_get_node_properties(
return get_node_properties(network, node)
@router.get(
"/getlinkproperties/",
"/link-properties",
summary="获取管线属性",
description="获取指定管线的所有属性信息"
)
@@ -247,7 +247,7 @@ async def fast_get_link_properties(
return get_link_properties(network, link)
@router.get(
"/getscadaproperties/",
"/scada-properties",
summary="获取SCADA点属性",
description="获取指定SCADA点的属性信息"
)
@@ -259,7 +259,7 @@ async def fast_get_scada_properties(
return get_scada_info(network, scada)
@router.get(
"/getallscadaproperties/",
"/all-scada-properties",
summary="获取所有SCADA点属性",
description="获取指定水网中所有SCADA点的属性信息"
)
@@ -270,7 +270,7 @@ async def fast_get_all_scada_properties(
return get_all_scada_info(network)
@router.get(
"/getelementpropertieswithtype/",
"/element-properties-with-types",
summary="获取指定类型元素属性",
description="获取指定类型的元素属性信息"
)
@@ -283,7 +283,7 @@ async def fast_get_element_properties_with_type(
return get_element_properties_with_type(network, elementtype, element)
@router.get(
"/getelementproperties/",
"/element-properties",
summary="获取元素属性",
description="获取指定元素的属性信息"
)
@@ -299,7 +299,7 @@ async def fast_get_element_properties(
############################################################
@router.get(
"/gettitleschema/",
"/title-schemas",
summary="获取标题属性架构",
description="获取指定水网的标题(标题)属性架构定义"
)
@@ -310,7 +310,7 @@ async def fast_get_title_schema(
return get_title_schema(network)
@router.get(
"/gettitle/",
"/titles",
summary="获取水网标题属性",
description="获取指定水网的标题(Title)信息"
)
@@ -318,8 +318,8 @@ async def fast_get_title(network: str = Query(..., description="管网名称(
"""获取水网的标题属性。"""
return get_title(network)
@router.get(
"/settitle/",
@router.patch(
"/titles",
response_model=None,
summary="设置水网标题属性",
description="设置指定水网的标题(Title)信息"
@@ -337,7 +337,7 @@ async def fastapi_set_title(
############################################################
@router.get(
"/getstatusschema",
"/status-schemas",
summary="获取状态属性架构",
description="获取指定水网的状态(Status)属性架构定义"
)
@@ -348,7 +348,7 @@ async def fastapi_get_status_schema(
return get_status_schema(network)
@router.get(
"/getstatus/",
"/status",
summary="获取管线状态",
description="获取指定管线的状态信息"
)
@@ -359,8 +359,8 @@ async def fastapi_get_status(
"""获取管线的状态属性。"""
return get_status(network, link)
@router.post(
"/setstatus/",
@router.patch(
"/status-properties",
response_model=None,
summary="设置管线状态",
description="设置指定管线的状态信息"
@@ -379,8 +379,8 @@ async def fastapi_set_status_properties(
# General Deletion
############################################################
@router.post(
"/deletenode/",
@router.delete(
"/nodes",
response_model=None,
summary="删除节点",
description="删除指定的节点(接点/水源/蓄水池)"
@@ -399,8 +399,8 @@ async def fastapi_delete_node(
return delete_tank(network, ChangeSet(ps))
return ChangeSet() # Should probably raise error or return empty
@router.post(
"/deletelink/",
@router.delete(
"/links",
response_model=None,
summary="删除管线",
description="删除指定的管线(管道/泵/阀门)"
+9 -40
View File
@@ -1,18 +1,14 @@
from fastapi import APIRouter, Request, Depends, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Query
from app.services.tjnetwork import (
Any,
get_all_scada_info,
get_major_node_coords,
get_major_pipe_nodes,
get_network_in_extent,
get_network_link_nodes,
get_network_node_coords,
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()
@@ -35,7 +31,7 @@ router = APIRouter()
# return set_coord(network, ChangeSet(props))
@router.get(
"/getnodecoord/",
"/node-coords",
summary="获取节点坐标",
description="获取指定节点的地理坐标(X, Y)"
)
@@ -48,7 +44,7 @@ async def fastapi_get_node_coord(
# Additional geometry queries found in main.py logic (implicit or explicit)
@router.get(
"/getnetworkinextent/",
"/network-in-extents",
summary="获取范围内的网络元素",
description="获取指定地理范围内的网络节点和管线"
)
@@ -63,34 +59,7 @@ async def fastapi_get_network_in_extent(
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(
"/getmajornodecoords/",
"/majornode-coords",
summary="获取主要节点坐标",
description="获取直径大于等于指定值的节点坐标"
)
@@ -102,7 +71,7 @@ async def fastapi_get_majornode_coords(
return get_major_node_coords(network, diameter)
@router.get(
"/getmajorpipenodes/",
"/major-pipe-nodes",
summary="获取主要管道节点",
description="获取直径大于等于指定值的管道的节点ID"
)
@@ -114,7 +83,7 @@ async def fastapi_get_major_pipe_nodes(
return get_major_pipe_nodes(network, diameter)
@router.get(
"/getnetworklinknodes/",
"/network-link-nodes",
summary="获取网络管线节点",
description="获取指定水网所有管线的起点和终点节点"
)
+18 -18
View File
@@ -13,7 +13,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/getjunctionschema", summary="获取节点架构", description="获取指定项目的节点属性架构和数据类型定义。")
@router.get("/network-schemas/junction", summary="获取节点架构", description="获取指定项目的节点属性架构和数据类型定义。")
async def fast_get_junction_schema(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> dict[str, dict[str, Any]]:
@@ -27,7 +27,7 @@ async def fast_get_junction_schema(
"""
return get_junction_schema(network)
@router.post("/addjunction/", response_model=None, summary="添加节点", description="在供水网络中添加新的节点,指定节点ID和空间坐标。")
@router.post("/junctions", response_model=None, summary="添加节点", description="在供水网络中添加新的节点,指定节点ID和空间坐标。")
async def fastapi_add_junction(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
@@ -51,7 +51,7 @@ async def fastapi_add_junction(
ps = {"id": junction, "x": x, "y": y, "elevation": z}
return add_junction(network, ChangeSet(ps))
@router.post("/deletejunction/", response_model=None, summary="删除节点", description="从供水网络中删除指定的节点。")
@router.delete("/junctions", response_model=None, summary="删除节点", description="从供水网络中删除指定的节点。")
async def fastapi_delete_junction(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
@@ -69,7 +69,7 @@ async def fastapi_delete_junction(
ps = {"id": junction}
return delete_junction(network, ChangeSet(ps))
@router.get("/getjunctionelevation/", summary="获取节点标高", description="获取指定节点的标高(海拔高度)。")
@router.get("/junctions/elevation", summary="获取节点标高", description="获取指定节点的标高(海拔高度)。")
async def fastapi_get_junction_elevation(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
@@ -87,7 +87,7 @@ async def fastapi_get_junction_elevation(
ps = get_junction(network, junction)
return ps["elevation"]
@router.get("/getjunctionx/", summary="获取节点 X 坐标", description="获取指定节点的 X 坐标值。")
@router.get("/junctions/x", summary="获取节点 X 坐标", description="获取指定节点的 X 坐标值。")
async def fastapi_get_junction_x(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
@@ -105,7 +105,7 @@ async def fastapi_get_junction_x(
ps = get_junction(network, junction)
return ps["x"]
@router.get("/getjunctiony/", summary="获取节点 Y 坐标", description="获取指定节点的 Y 坐标值。")
@router.get("/junctions/y", summary="获取节点 Y 坐标", description="获取指定节点的 Y 坐标值。")
async def fastapi_get_junction_y(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
@@ -123,7 +123,7 @@ async def fastapi_get_junction_y(
ps = get_junction(network, junction)
return ps["y"]
@router.get("/getjunctioncoord/", summary="获取节点坐标", description="获取指定节点的 X 和 Y 坐标。")
@router.get("/junctions/coord", summary="获取节点坐标", description="获取指定节点的 X 和 Y 坐标。")
async def fastapi_get_junction_coord(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
@@ -142,7 +142,7 @@ async def fastapi_get_junction_coord(
coord = {"x": ps["x"], "y": ps["y"]}
return coord
@router.get("/getjunctiondemand/", summary="获取节点需水量", description="获取指定节点的需水量。")
@router.get("/junctions/demand", summary="获取节点需水量", description="获取指定节点的需水量。")
async def fastapi_get_junction_demand(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
@@ -160,7 +160,7 @@ async def fastapi_get_junction_demand(
ps = get_junction(network, junction)
return ps["demand"]
@router.get("/getjunctionpattern/", summary="获取节点需水模式", description="获取指定节点的需水模式标识。")
@router.get("/junctions/pattern", summary="获取节点需水模式", description="获取指定节点的需水模式标识。")
async def fastapi_get_junction_pattern(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
@@ -178,7 +178,7 @@ async def fastapi_get_junction_pattern(
ps = get_junction(network, junction)
return ps["pattern"]
@router.post("/setjunctionelevation/", response_model=None, summary="设置节点标高", description="设置指定节点的标高值。")
@router.patch("/junctions/elevation", response_model=None, summary="设置节点标高", description="设置指定节点的标高值。")
async def fastapi_set_junction_elevation(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
@@ -198,7 +198,7 @@ async def fastapi_set_junction_elevation(
ps = {"id": junction, "elevation": elevation}
return set_junction(network, ChangeSet(ps))
@router.post("/setjunctionx/", response_model=None, summary="设置节点 X 坐标", description="设置指定节点的 X 坐标值。")
@router.patch("/junctions/x", response_model=None, summary="设置节点 X 坐标", description="设置指定节点的 X 坐标值。")
async def fastapi_set_junction_x(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
@@ -218,7 +218,7 @@ async def fastapi_set_junction_x(
ps = {"id": junction, "x": x}
return set_junction(network, ChangeSet(ps))
@router.post("/setjunctiony/", response_model=None, summary="设置节点 Y 坐标", description="设置指定节点的 Y 坐标值。")
@router.patch("/junctions/y", response_model=None, summary="设置节点 Y 坐标", description="设置指定节点的 Y 坐标值。")
async def fastapi_set_junction_y(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
@@ -238,7 +238,7 @@ async def fastapi_set_junction_y(
ps = {"id": junction, "y": y}
return set_junction(network, ChangeSet(ps))
@router.post("/setjunctioncoord/", response_model=None, summary="设置节点坐标", description="设置指定节点的 X 和 Y 坐标。")
@router.patch("/junctions/coord", response_model=None, summary="设置节点坐标", description="设置指定节点的 X 和 Y 坐标。")
async def fastapi_set_junction_coord(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
@@ -260,7 +260,7 @@ async def fastapi_set_junction_coord(
ps = {"id": junction, "x": x, "y": y}
return set_junction(network, ChangeSet(ps))
@router.post("/setjunctiondemand/", response_model=None, summary="设置节点需水量", description="设置指定节点的需水量。")
@router.patch("/junctions/demand", response_model=None, summary="设置节点需水量", description="设置指定节点的需水量。")
async def fastapi_set_junction_demand(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
@@ -280,7 +280,7 @@ async def fastapi_set_junction_demand(
ps = {"id": junction, "demand": demand}
return set_junction(network, ChangeSet(ps))
@router.post("/setjunctionpattern/", response_model=None, summary="设置节点需水模式", description="设置指定节点的需水模式标识。")
@router.patch("/junctions/pattern", response_model=None, summary="设置节点需水模式", description="设置指定节点的需水模式标识。")
async def fastapi_set_junction_pattern(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
@@ -300,7 +300,7 @@ async def fastapi_set_junction_pattern(
ps = {"id": junction, "pattern": pattern}
return set_junction(network, ChangeSet(ps))
@router.get("/getjunctionproperties/", summary="获取节点属性", description="获取指定节点的所有属性信息。")
@router.get("/junctions/properties", summary="获取节点属性", description="获取指定节点的所有属性信息。")
async def fastapi_get_junction_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
@@ -317,7 +317,7 @@ async def fastapi_get_junction_properties(
"""
return get_junction(network, junction)
@router.get("/getalljunctionproperties/", summary="获取所有节点属性", description="获取指定项目中所有节点的属性信息。")
@router.get("/junctions", summary="获取所有节点属性", description="获取指定项目中所有节点的属性信息。")
async def fastapi_get_all_junction_properties(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
@@ -337,7 +337,7 @@ async def fastapi_get_all_junction_properties(
results = get_all_junctions(network)
return results
@router.post("/setjunctionproperties/", response_model=None, summary="批量设置节点属性", description="批量设置指定节点的多个属性。")
@router.patch("/junctions/properties", response_model=None, summary="批量设置节点属性", description="批量设置指定节点的多个属性。")
async def fastapi_set_junction_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
+20 -20
View File
@@ -14,7 +14,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/getpipeschema", summary="获取管道模式", description="获取管道对象的模式定义,包含所有可用字段及其类型")
@router.get("/network-schemas/pipe", summary="获取管道模式", description="获取管道对象的模式定义,包含所有可用字段及其类型")
async def fastapi_get_pipe_schema(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> dict[str, dict[str, Any]]:
@@ -29,7 +29,7 @@ async def fastapi_get_pipe_schema(
"""
return get_pipe_schema(network)
@router.post("/addpipe/", response_model=None, summary="添加管道", description="向网络中添加新的管道,需要提供管道的基本参数如长度、管径、粗糙度等")
@router.post("/pipes", response_model=None, summary="添加管道", description="向网络中添加新的管道,需要提供管道的基本参数如长度、管径、粗糙度等")
async def fastapi_add_pipe(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道标识符"),
@@ -70,7 +70,7 @@ async def fastapi_add_pipe(
}
return add_pipe(network, ChangeSet(ps))
@router.post("/deletepipe/", response_model=None, summary="删除管道", description="从网络中删除指定的管道")
@router.delete("/pipes", response_model=None, summary="删除管道", description="从网络中删除指定的管道")
async def fastapi_delete_pipe(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="要删除的管道ID")
@@ -88,7 +88,7 @@ async def fastapi_delete_pipe(
ps = {"id": pipe}
return delete_pipe(network, ChangeSet(ps))
@router.get("/getpipenode1/", summary="获取管道起始节点", description="获取指定管道的起始节点ID")
@router.get("/pipes/node1", summary="获取管道起始节点", description="获取指定管道的起始节点ID")
async def fastapi_get_pipe_node1(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
@@ -106,7 +106,7 @@ async def fastapi_get_pipe_node1(
ps = get_pipe(network, pipe)
return ps["node1"]
@router.get("/getpipenode2/", summary="获取管道终止节点", description="获取指定管道的终止节点ID")
@router.get("/pipes/node2", summary="获取管道终止节点", description="获取指定管道的终止节点ID")
async def fastapi_get_pipe_node2(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
@@ -124,7 +124,7 @@ async def fastapi_get_pipe_node2(
ps = get_pipe(network, pipe)
return ps["node2"]
@router.get("/getpipelength/", summary="获取管道长度", description="获取指定管道的长度")
@router.get("/pipes/length", summary="获取管道长度", description="获取指定管道的长度")
async def fastapi_get_pipe_length(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
@@ -142,7 +142,7 @@ async def fastapi_get_pipe_length(
ps = get_pipe(network, pipe)
return ps["length"]
@router.get("/getpipediameter/", summary="获取管道管径", description="获取指定管道的管径")
@router.get("/pipes/diameter", summary="获取管道管径", description="获取指定管道的管径")
async def fastapi_get_pipe_diameter(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
@@ -160,7 +160,7 @@ async def fastapi_get_pipe_diameter(
ps = get_pipe(network, pipe)
return ps["diameter"]
@router.get("/getpiperoughness/", summary="获取管道粗糙度", description="获取指定管道的粗糙度")
@router.get("/pipes/roughness", summary="获取管道粗糙度", description="获取指定管道的粗糙度")
async def fastapi_get_pipe_roughness(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
@@ -178,7 +178,7 @@ async def fastapi_get_pipe_roughness(
ps = get_pipe(network, pipe)
return ps["roughness"]
@router.get("/getpipeminorloss/", summary="获取管道局部阻力系数", description="获取指定管道的局部阻力系数")
@router.get("/pipes/minor-loss", summary="获取管道局部阻力系数", description="获取指定管道的局部阻力系数")
async def fastapi_get_pipe_minor_loss(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
@@ -196,7 +196,7 @@ async def fastapi_get_pipe_minor_loss(
ps = get_pipe(network, pipe)
return ps["minor_loss"]
@router.get("/getpipestatus/", summary="获取管道状态", description="获取指定管道的状态(开启或关闭)")
@router.get("/pipes/status", summary="获取管道状态", description="获取指定管道的状态(开启或关闭)")
async def fastapi_get_pipe_status(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
@@ -214,7 +214,7 @@ async def fastapi_get_pipe_status(
ps = get_pipe(network, pipe)
return ps["status"]
@router.post("/setpipenode1/", response_model=None, summary="设置管道起始节点", description="设置指定管道的起始节点")
@router.patch("/pipes/node1", response_model=None, summary="设置管道起始节点", description="设置指定管道的起始节点")
async def fastapi_set_pipe_node1(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
@@ -234,7 +234,7 @@ async def fastapi_set_pipe_node1(
ps = {"id": pipe, "node1": node1}
return set_pipe(network, ChangeSet(ps))
@router.post("/setpipenode2/", response_model=None, summary="设置管道终止节点", description="设置指定管道的终止节点")
@router.patch("/pipes/node2", response_model=None, summary="设置管道终止节点", description="设置指定管道的终止节点")
async def fastapi_set_pipe_node2(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
@@ -254,7 +254,7 @@ async def fastapi_set_pipe_node2(
ps = {"id": pipe, "node2": node2}
return set_pipe(network, ChangeSet(ps))
@router.post("/setpipelength/", response_model=None, summary="设置管道长度", description="设置指定管道的长度")
@router.patch("/pipes/length", response_model=None, summary="设置管道长度", description="设置指定管道的长度")
async def fastapi_set_pipe_length(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
@@ -274,7 +274,7 @@ async def fastapi_set_pipe_length(
ps = {"id": pipe, "length": length}
return set_pipe(network, ChangeSet(ps))
@router.post("/setpipediameter/", response_model=None, summary="设置管道管径", description="设置指定管道的管径")
@router.patch("/pipes/diameter", response_model=None, summary="设置管道管径", description="设置指定管道的管径")
async def fastapi_set_pipe_diameter(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
@@ -294,7 +294,7 @@ async def fastapi_set_pipe_diameter(
ps = {"id": pipe, "diameter": diameter}
return set_pipe(network, ChangeSet(ps))
@router.post("/setpiperoughness/", response_model=None, summary="设置管道粗糙度", description="设置指定管道的粗糙度")
@router.patch("/pipes/roughness", response_model=None, summary="设置管道粗糙度", description="设置指定管道的粗糙度")
async def fastapi_set_pipe_roughness(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
@@ -314,7 +314,7 @@ async def fastapi_set_pipe_roughness(
ps = {"id": pipe, "roughness": roughness}
return set_pipe(network, ChangeSet(ps))
@router.post("/setpipeminorloss/", response_model=None, summary="设置管道局部阻力系数", description="设置指定管道的局部阻力系数")
@router.patch("/pipes/minor-loss", response_model=None, summary="设置管道局部阻力系数", description="设置指定管道的局部阻力系数")
async def fastapi_set_pipe_minor_loss(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
@@ -334,7 +334,7 @@ async def fastapi_set_pipe_minor_loss(
ps = {"id": pipe, "minor_loss": minor_loss}
return set_pipe(network, ChangeSet(ps))
@router.post("/setpipestatus/", response_model=None, summary="设置管道状态", description="设置指定管道的状态(开启或关闭)")
@router.patch("/pipes/status", response_model=None, summary="设置管道状态", description="设置指定管道的状态(开启或关闭)")
async def fastapi_set_pipe_status(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
@@ -354,7 +354,7 @@ async def fastapi_set_pipe_status(
ps = {"id": pipe, "status": status}
return set_pipe(network, ChangeSet(ps))
@router.get("/getpipeproperties/", summary="获取管道属性", description="获取指定管道的所有属性信息")
@router.get("/pipes/properties", summary="获取管道属性", description="获取指定管道的所有属性信息")
async def fastapi_get_pipe_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
@@ -371,7 +371,7 @@ async def fastapi_get_pipe_properties(
"""
return get_pipe(network, pipe)
@router.get("/getallpipeproperties/", summary="获取所有管道属性", description="获取网络中所有管道的属性信息列表")
@router.get("/pipes", summary="获取所有管道属性", description="获取网络中所有管道的属性信息列表")
async def fastapi_get_all_pipe_properties(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
@@ -389,7 +389,7 @@ async def fastapi_get_all_pipe_properties(
results = get_all_pipes(network)
return results
@router.post("/setpipeproperties/", response_model=None, summary="设置管道属性", description="批量设置指定管道的多个属性")
@router.patch("/pipes/properties", response_model=None, summary="设置管道属性", description="批量设置指定管道的多个属性")
async def fastapi_set_pipe_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
+10 -10
View File
@@ -13,7 +13,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/getpumpschema", summary="获取水泵模式", description="获取水泵对象的模式定义,包含所有可用字段及其类型")
@router.get("/network-schemas/pump", summary="获取水泵模式", description="获取水泵对象的模式定义,包含所有可用字段及其类型")
async def fastapi_get_pump_schema(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> dict[str, dict[str, Any]]:
@@ -28,7 +28,7 @@ async def fastapi_get_pump_schema(
"""
return get_pump_schema(network)
@router.post("/addpump/", response_model=None, summary="添加水泵", description="向网络中添加新的水泵,需要提供水泵的基本参数如功率等")
@router.post("/pumps", response_model=None, summary="添加水泵", description="向网络中添加新的水泵,需要提供水泵的基本参数如功率等")
async def fastapi_add_pump(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="水泵标识符"),
@@ -52,7 +52,7 @@ async def fastapi_add_pump(
ps = {"id": pump, "node1": node1, "node2": node2, "power": power}
return add_pump(network, ChangeSet(ps))
@router.post("/deletepump/", response_model=None, summary="删除水泵", description="从网络中删除指定的水泵")
@router.delete("/pumps", response_model=None, summary="删除水泵", description="从网络中删除指定的水泵")
async def fastapi_delete_pump(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="要删除的水泵ID")
@@ -70,7 +70,7 @@ async def fastapi_delete_pump(
ps = {"id": pump}
return delete_pump(network, ChangeSet(ps))
@router.get("/getpumpnode1/", summary="获取水泵起始节点", description="获取指定水泵的起始节点ID")
@router.get("/pumps/node1", summary="获取水泵起始节点", description="获取指定水泵的起始节点ID")
async def fastapi_get_pump_node1(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="水泵ID")
@@ -88,7 +88,7 @@ async def fastapi_get_pump_node1(
ps = get_pump(network, pump)
return ps["node1"]
@router.get("/getpumpnode2/", summary="获取水泵终止节点", description="获取指定水泵的终止节点ID")
@router.get("/pumps/node2", summary="获取水泵终止节点", description="获取指定水泵的终止节点ID")
async def fastapi_get_pump_node2(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="水泵ID")
@@ -106,7 +106,7 @@ async def fastapi_get_pump_node2(
ps = get_pump(network, pump)
return ps["node2"]
@router.post("/setpumpnode1/", response_model=None, summary="设置水泵起始节点", description="设置指定水泵的起始节点")
@router.patch("/pumps/node1", response_model=None, summary="设置水泵起始节点", description="设置指定水泵的起始节点")
async def fastapi_set_pump_node1(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="水泵ID"),
@@ -126,7 +126,7 @@ async def fastapi_set_pump_node1(
ps = {"id": pump, "node1": node1}
return set_pump(network, ChangeSet(ps))
@router.post("/setpumpnode2/", response_model=None, summary="设置水泵终止节点", description="设置指定水泵的终止节点")
@router.patch("/pumps/node2", response_model=None, summary="设置水泵终止节点", description="设置指定水泵的终止节点")
async def fastapi_set_pump_node2(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="水泵ID"),
@@ -146,7 +146,7 @@ async def fastapi_set_pump_node2(
ps = {"id": pump, "node2": node2}
return set_pump(network, ChangeSet(ps))
@router.get("/getpumpproperties/", summary="获取水泵属性", description="获取指定水泵的所有属性信息")
@router.get("/pumps/properties", summary="获取水泵属性", description="获取指定水泵的所有属性信息")
async def fastapi_get_pump_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="水泵ID")
@@ -163,7 +163,7 @@ async def fastapi_get_pump_properties(
"""
return get_pump(network, pump)
@router.get("/getallpumpproperties/", summary="获取所有水泵属性", description="获取网络中所有水泵的属性信息列表")
@router.get("/pumps", summary="获取所有水泵属性", description="获取网络中所有水泵的属性信息列表")
async def fastapi_get_all_pump_properties(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
@@ -181,7 +181,7 @@ async def fastapi_get_all_pump_properties(
results = get_all_pumps(network)
return results
@router.post("/setpumpproperties/", response_model=None, summary="设置水泵属性", description="批量设置指定水泵的多个属性")
@router.patch("/pumps/properties", response_model=None, summary="设置水泵属性", description="批量设置指定水泵的多个属性")
async def fastapi_set_pump_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="水泵ID"),
+46 -46
View File
@@ -45,7 +45,7 @@ router = APIRouter()
############################################################
@router.get(
"/getregionschema/",
"/network-schemas/region",
summary="获取区域属性架构",
description="获取指定水网的区域属性架构定义"
)
@@ -56,7 +56,7 @@ async def fastapi_get_region_schema(
return get_region_schema(network)
@router.get(
"/getregion/",
"/regions/detail",
summary="获取区域信息",
description="获取指定ID的区域详细信息"
)
@@ -67,8 +67,8 @@ async def fastapi_get_region(
"""获取区域的详细信息。"""
return get_region(network, id)
@router.post(
"/setregion/",
@router.patch(
"/regions",
response_model=None,
summary="设置区域属性",
description="修改指定区域的属性信息"
@@ -82,7 +82,7 @@ async def fastapi_set_region(
return set_region(network, ChangeSet(props))
@router.post(
"/addregion/",
"/regions",
response_model=None,
summary="添加新区域",
description="向水网添加一个新的区域"
@@ -95,8 +95,8 @@ async def fastapi_add_region(
props = await req.json()
return add_region(network, ChangeSet(props))
@router.post(
"/deleteregion/",
@router.delete(
"/regions",
response_model=None,
summary="删除区域",
description="删除指定的区域"
@@ -114,8 +114,8 @@ async def fastapi_delete_region(
# district_metering_area 33
############################################################
@router.get(
"/calculatedistrictmeteringareaforregion/",
@router.post(
"/district-metering-areas/for-region",
summary="计算区域内DMA分区",
description="为指定区域计算区域计量(DMA)分区方案"
)
@@ -141,8 +141,8 @@ async def fastapi_calculate_district_metering_area_for_region(
network, region, part_count, part_type
)
@router.get(
"/calculatedistrictmeteringareafornetwork/",
@router.post(
"/district-metering-areas/for-network",
summary="计算整网DMA分区",
description="为整个水网计算区域计量(DMA)分区方案"
)
@@ -165,7 +165,7 @@ async def fastapi_calculate_district_metering_area_for_network(
return calculate_district_metering_area_for_network(network, part_count, part_type)
@router.get(
"/getdistrictmeteringareaschema/",
"/network-schemas/district-metering-area",
summary="获取DMA属性架构",
description="获取指定水网的区域计量(DMA)属性架构定义"
)
@@ -176,7 +176,7 @@ async def fastapi_get_district_metering_area_schema(
return get_district_metering_area_schema(network)
@router.get(
"/getdistrictmeteringarea/",
"/district-metering-areas/detail",
summary="获取DMA信息",
description="获取指定ID的区域计量(DMA)详细信息"
)
@@ -187,8 +187,8 @@ async def fastapi_get_district_metering_area(
"""获取DMA的详细信息。"""
return get_district_metering_area(network, id)
@router.post(
"/setdistrictmeteringarea/",
@router.patch(
"/district-metering-areas",
response_model=None,
summary="设置DMA属性",
description="修改指定DMA的属性信息"
@@ -202,7 +202,7 @@ async def fastapi_set_district_metering_area(
return set_district_metering_area(network, ChangeSet(props))
@router.post(
"/adddistrictmeteringarea/",
"/district-metering-areas",
response_model=None,
summary="添加新DMA",
description="向水网添加一个新的区域计量(DMA)"
@@ -222,8 +222,8 @@ async def fastapi_add_district_metering_area(
props["boundary"] = newBoundary
return add_district_metering_area(network, ChangeSet(props))
@router.post(
"/deletedistrictmeteringarea/",
@router.delete(
"/district-metering-areas",
response_model=None,
summary="删除DMA",
description="删除指定的区域计量(DMA)"
@@ -237,7 +237,7 @@ async def fastapi_delete_district_metering_area(
return delete_district_metering_area(network, ChangeSet(props))
@router.get(
"/getalldistrictmeteringareaids/",
"/district-metering-areas/ids",
summary="获取所有DMA ID",
description="获取指定水网中所有DMA的ID列表"
)
@@ -248,7 +248,7 @@ async def fastapi_get_all_district_metering_area_ids(
return get_all_district_metering_area_ids(network)
@router.get(
"/getalldistrictmeteringareas/",
"/district-metering-areas",
summary="获取所有DMA",
description="获取指定水网中所有DMA的详细信息"
)
@@ -259,7 +259,7 @@ async def getalldistrictmeteringareas(
return get_all_district_metering_areas(network)
@router.post(
"/generatedistrictmeteringarea/",
"/district-metering-area-generation-runs",
response_model=None,
summary="生成DMA分区",
description="根据参数自动生成水网的DMA分区方案"
@@ -276,7 +276,7 @@ async def fastapi_generate_district_metering_area(
)
@router.post(
"/generatesubdistrictmeteringarea/",
"/sub-district-metering-areas",
response_model=None,
summary="生成DMA子分区",
description="为指定DMA生成子DMA分区"
@@ -298,8 +298,8 @@ async def fastapi_generate_sub_district_metering_area(
# service_area 34
############################################################
@router.get(
"/calculateservicearea/",
@router.post(
"/service-area-calculations",
summary="计算服务区",
description="计算指定水网的服务区分区,返回全部时间步结果"
)
@@ -310,7 +310,7 @@ async def fastapi_calculate_service_area(
return calculate_service_area(network)
@router.get(
"/getserviceareaschema/",
"/network-schemas/service-area",
summary="获取服务区属性架构",
description="获取指定水网的服务区属性架构定义"
)
@@ -321,7 +321,7 @@ async def fastapi_get_service_area_schema(
return get_service_area_schema(network)
@router.get(
"/getservicearea/",
"/service-areas/detail",
summary="获取服务区信息",
description="获取指定ID的服务区详细信息"
)
@@ -332,8 +332,8 @@ async def fastapi_get_service_area(
"""获取服务区的详细信息。"""
return get_service_area(network, id)
@router.post(
"/setservicearea/",
@router.patch(
"/service-areas",
response_model=None,
summary="设置服务区属性",
description="修改指定服务区的属性信息"
@@ -347,7 +347,7 @@ async def fastapi_set_service_area(
return set_service_area(network, ChangeSet(props))
@router.post(
"/addservicearea/",
"/service-areas",
response_model=None,
summary="添加新服务区",
description="向水网添加一个新的服务区"
@@ -360,8 +360,8 @@ async def fastapi_add_service_area(
props = await req.json()
return add_service_area(network, ChangeSet(props))
@router.post(
"/deleteservicearea/",
@router.delete(
"/service-areas",
response_model=None,
summary="删除服务区",
description="删除指定的服务区"
@@ -375,7 +375,7 @@ async def fastapi_delete_service_area(
return delete_service_area(network, ChangeSet(props))
@router.get(
"/getallserviceareas/",
"/service-areas",
summary="获取所有服务区",
description="获取指定水网中的所有服务区信息"
)
@@ -386,7 +386,7 @@ async def fastapi_get_all_service_areas(
return get_all_service_areas(network)
@router.post(
"/generateservicearea/",
"/service-area-generation-runs",
response_model=None,
summary="生成服务区分区",
description="根据参数自动生成水网的服务区分区"
@@ -403,8 +403,8 @@ async def fastapi_generate_service_area(
# virtual_district 35
############################################################
@router.get(
"/calculatevirtualdistrict/",
@router.post(
"/virtual-district-calculations",
summary="计算虚拟分区",
description="根据指定的压力监测节点作为中心节点计算虚拟分区方案"
)
@@ -416,7 +416,7 @@ async def fastapi_calculate_virtual_district(
return calculate_virtual_district(network, centers)
@router.get(
"/getvirtualdistrictschema/",
"/network-schemas/virtual-district",
summary="获取虚拟分区属性架构",
description="获取指定水网的虚拟分区属性架构定义"
)
@@ -427,7 +427,7 @@ async def fastapi_get_virtual_district_schema(
return get_virtual_district_schema(network)
@router.get(
"/getvirtualdistrict/",
"/virtual-districts/detail",
summary="获取虚拟分区信息",
description="获取指定ID的虚拟分区详细信息"
)
@@ -438,8 +438,8 @@ async def fastapi_get_virtual_district(
"""获取虚拟分区的详细信息。"""
return get_virtual_district(network, id)
@router.post(
"/setvirtualdistrict/",
@router.patch(
"/virtual-districts",
response_model=None,
summary="设置虚拟分区属性",
description="修改指定虚拟分区的属性信息"
@@ -453,7 +453,7 @@ async def fastapi_set_virtual_district(
return set_virtual_district(network, ChangeSet(props))
@router.post(
"/addvirtualdistrict/",
"/virtual-districts",
response_model=None,
summary="添加新虚拟分区",
description="向水网添加一个新的虚拟分区"
@@ -466,8 +466,8 @@ async def fastapi_add_virtual_district(
props = await req.json()
return add_virtual_district(network, ChangeSet(props))
@router.post(
"/deletevirtualdistrict/",
@router.delete(
"/virtual-districts",
response_model=None,
summary="删除虚拟分区",
description="删除指定的虚拟分区"
@@ -481,7 +481,7 @@ async def fastapi_delete_virtual_district(
return delete_virtual_district(network, ChangeSet(props))
@router.get(
"/getallvirtualdistrict/",
"/virtual-districts",
summary="获取所有虚拟分区",
description="获取指定水网中的所有虚拟分区信息"
)
@@ -492,7 +492,7 @@ async def fastapi_get_all_virtual_district(
return get_all_virtual_districts(network)
@router.post(
"/generatevirtualdistrict/",
"/virtual-district-generation-runs",
response_model=None,
summary="生成虚拟分区",
description="根据参数自动生成虚拟分区方案"
@@ -506,8 +506,8 @@ async def fastapi_generate_virtual_district(
props = await req.json()
return generate_virtual_district(network, props["centers"], inflate_delta)
@router.get(
"/calculatedistrictmeteringareafornodes/",
@router.post(
"/district-metering-areas/for-nodes",
summary="计算节点DMA分区",
description="为指定节点集计算区域计量(DMA)分区方案"
)
+23 -23
View File
@@ -14,7 +14,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get(
"/getreservoirschema",
"/network-schemas/reservoir",
summary="获取水库模式",
description="获取指定供水网络中所有水库的模式/属性字段定义"
)
@@ -35,7 +35,7 @@ async def fast_get_reservoir_schema(
return get_reservoir_schema(network)
@router.post(
"/addreservoir/",
"/reservoirs",
response_model=None,
summary="添加水库",
description="在指定供水网络中添加新的水库/水源节点"
@@ -65,8 +65,8 @@ async def fastapi_add_reservoir(
ps = {"id": reservoir, "x": x, "y": y, "head": head}
return add_reservoir(network, ChangeSet(ps))
@router.post(
"/deletereservoir/",
@router.delete(
"/reservoirs",
response_model=None,
summary="删除水库",
description="从指定供水网络中删除指定的水库/水源节点"
@@ -91,7 +91,7 @@ async def fastapi_delete_reservoir(
return delete_reservoir(network, ChangeSet(ps))
@router.get(
"/getreservoirhead/",
"/reservoirs/head",
summary="获取水库水头",
description="获取指定水库的供水水头/总水头值"
)
@@ -115,7 +115,7 @@ async def fastapi_get_reservoir_head(
return ps["head"]
@router.get(
"/getreservoirpattern/",
"/reservoirs/pattern",
summary="获取水库模式",
description="获取指定水库的运行模式/供水模式"
)
@@ -139,7 +139,7 @@ async def fastapi_get_reservoir_pattern(
return ps["pattern"]
@router.get(
"/getreservoirx/",
"/reservoirs/x",
summary="获取水库X坐标",
description="获取指定水库的X坐标位置"
)
@@ -163,7 +163,7 @@ async def fastapi_get_reservoir_x(
return ps["x"]
@router.get(
"/getreservoiry/",
"/reservoirs/y",
summary="获取水库Y坐标",
description="获取指定水库的Y坐标位置"
)
@@ -187,7 +187,7 @@ async def fastapi_get_reservoir_y(
return ps["y"]
@router.get(
"/getreservoircoord/",
"/reservoirs/coord",
summary="获取水库坐标",
description="获取指定水库的平面坐标(X和Y坐标)"
)
@@ -211,8 +211,8 @@ async def fastapi_get_reservoir_coord(
coord = {"id": reservoir, "x": ps["x"], "y": ps["y"]}
return coord
@router.post(
"/setreservoirhead/",
@router.patch(
"/reservoirs/head",
response_model=None,
summary="设置水库水头",
description="更新指定水库的供水水头/总水头值"
@@ -238,8 +238,8 @@ async def fastapi_set_reservoir_head(
ps = {"id": reservoir, "head": head}
return set_reservoir(network, ChangeSet(ps))
@router.post(
"/setreservoirpattern/",
@router.patch(
"/reservoirs/pattern",
response_model=None,
summary="设置水库模式",
description="更新指定水库的运行模式/供水模式"
@@ -265,8 +265,8 @@ async def fastapi_set_reservoir_pattern(
ps = {"id": reservoir, "pattern": pattern}
return set_reservoir(network, ChangeSet(ps))
@router.post(
"/setreservoirx/",
@router.patch(
"/reservoirs/x",
response_model=None,
summary="设置水库X坐标",
description="更新指定水库的X坐标位置"
@@ -292,8 +292,8 @@ async def fastapi_set_reservoir_x(
ps = {"id": reservoir, "x": x}
return set_reservoir(network, ChangeSet(ps))
@router.post(
"/setreservoiry/",
@router.patch(
"/reservoirs/y",
response_model=None,
summary="设置水库Y坐标",
description="更新指定水库的Y坐标位置"
@@ -319,8 +319,8 @@ async def fastapi_set_reservoir_y(
ps = {"id": reservoir, "y": y}
return set_reservoir(network, ChangeSet(ps))
@router.post(
"/setreservoircoord/",
@router.patch(
"/reservoirs/coord",
response_model=None,
summary="设置水库坐标",
description="更新指定水库的平面坐标(X和Y坐标)"
@@ -349,7 +349,7 @@ async def fastapi_set_reservoir_coord(
return set_reservoir(network, ChangeSet(ps))
@router.get(
"/getreservoirproperties/",
"/reservoirs/properties",
summary="获取水库属性",
description="获取指定水库的所有属性"
)
@@ -372,7 +372,7 @@ async def fastapi_get_reservoir_properties(
return get_reservoir(network, reservoir)
@router.get(
"/getallreservoirproperties/",
"/reservoirs",
summary="获取所有水库属性",
description="获取指定供水网络中所有水库的属性"
)
@@ -393,8 +393,8 @@ async def fastapi_get_all_reservoir_properties(
results = get_all_reservoirs(network)
return results
@router.post(
"/setreservoirproperties/",
@router.patch(
"/reservoirs/properties",
response_model=None,
summary="设置水库属性",
description="批量更新指定水库的多个属性"
+5 -5
View File
@@ -16,7 +16,7 @@ router = APIRouter()
############################################################
@router.get(
"/gettagschema/",
"/network-schemas/tag",
summary="获取标签属性架构",
description="获取指定水网的标签(Tag)属性架构定义"
)
@@ -27,7 +27,7 @@ async def fastapi_get_tag_schema(
return get_tag_schema(network)
@router.get(
"/gettag/",
"/tags/detail",
summary="获取标签信息",
description="获取指定类型和ID的标签信息"
)
@@ -40,7 +40,7 @@ async def fastapi_get_tag(
return get_tag(network, t_type, id)
@router.get(
"/gettags/",
"/tags",
summary="获取所有标签",
description="获取指定水网中的所有标签信息"
)
@@ -51,8 +51,8 @@ async def fastapi_get_tags(
tags = get_tags(network)
return tags
@router.post(
"/settag/",
@router.patch(
"/tags",
response_model=None,
summary="设置标签",
description="为指定元素设置或修改标签信息"
+28 -28
View File
@@ -13,7 +13,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/gettankschema", summary="获取水箱模式", description="获取指定网络的水箱数据结构模式定义")
@router.get("/network-schemas/tank", summary="获取水箱模式", description="获取指定网络的水箱数据结构模式定义")
async def fast_get_tank_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""
获取水箱的数据结构模式
@@ -26,7 +26,7 @@ async def fast_get_tank_schema(network: str = Query(..., description="管网名
"""
return get_tank_schema(network)
@router.post("/addtank/", summary="新增水箱", description="向指定网络中新增一个水箱", response_model=None)
@router.post("/tanks", summary="新增水箱", description="向指定网络中新增一个水箱", response_model=None)
async def fastapi_add_tank(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
@@ -70,7 +70,7 @@ async def fastapi_add_tank(
}
return add_tank(network, ChangeSet(ps))
@router.post("/deletetank/", summary="删除水箱", description="删除指定网络中的水箱", response_model=None)
@router.delete("/tanks", summary="删除水箱", description="删除指定网络中的水箱", response_model=None)
async def fastapi_delete_tank(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
@@ -88,7 +88,7 @@ async def fastapi_delete_tank(
ps = {"id": tank}
return delete_tank(network, ChangeSet(ps))
@router.get("/gettankelevation/", summary="获取水箱标高", description="获取指定水箱的标高值")
@router.get("/tanks/elevation", summary="获取水箱标高", description="获取指定水箱的标高值")
async def fastapi_get_tank_elevation(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
@@ -106,7 +106,7 @@ async def fastapi_get_tank_elevation(
ps = get_tank(network, tank)
return ps["elevation"]
@router.get("/gettankinitlevel/", summary="获取水箱初始水位", description="获取指定水箱的初始水位值")
@router.get("/tanks/init-level", summary="获取水箱初始水位", description="获取指定水箱的初始水位值")
async def fastapi_get_tank_init_level(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
@@ -124,7 +124,7 @@ async def fastapi_get_tank_init_level(
ps = get_tank(network, tank)
return ps["init_level"]
@router.get("/gettankminlevel/", summary="获取水箱最小水位", description="获取指定水箱的最小水位值")
@router.get("/tanks/min-level", summary="获取水箱最小水位", description="获取指定水箱的最小水位值")
async def fastapi_get_tank_min_level(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
@@ -142,7 +142,7 @@ async def fastapi_get_tank_min_level(
ps = get_tank(network, tank)
return ps["min_level"]
@router.get("/gettankmaxlevel/", summary="获取水箱最大水位", description="获取指定水箱的最大水位值")
@router.get("/tanks/max-level", summary="获取水箱最大水位", description="获取指定水箱的最大水位值")
async def fastapi_get_tank_max_level(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
@@ -160,7 +160,7 @@ async def fastapi_get_tank_max_level(
ps = get_tank(network, tank)
return ps["max_level"]
@router.get("/gettankdiameter/", summary="获取水箱直径", description="获取指定水箱的直径值")
@router.get("/tanks/diameter", summary="获取水箱直径", description="获取指定水箱的直径值")
async def fastapi_get_tank_diameter(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
@@ -178,7 +178,7 @@ async def fastapi_get_tank_diameter(
ps = get_tank(network, tank)
return ps["diameter"]
@router.get("/gettankminvol/", summary="获取水箱最小体积", description="获取指定水箱的最小体积值")
@router.get("/tanks/min-vol", summary="获取水箱最小体积", description="获取指定水箱的最小体积值")
async def fastapi_get_tank_min_vol(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
@@ -196,7 +196,7 @@ async def fastapi_get_tank_min_vol(
ps = get_tank(network, tank)
return ps["min_vol"]
@router.get("/gettankvolcurve/", summary="获取水箱容积曲线", description="获取指定水箱的容积曲线标识")
@router.get("/tanks/vol-curve", summary="获取水箱容积曲线", description="获取指定水箱的容积曲线标识")
async def fastapi_get_tank_vol_curve(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
@@ -214,7 +214,7 @@ async def fastapi_get_tank_vol_curve(
ps = get_tank(network, tank)
return ps["vol_curve"]
@router.get("/gettankoverflow/", summary="获取水箱溢流口", description="获取指定水箱的溢流口配置")
@router.get("/tanks/overflow", summary="获取水箱溢流口", description="获取指定水箱的溢流口配置")
async def fastapi_get_tank_overflow(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
@@ -232,7 +232,7 @@ async def fastapi_get_tank_overflow(
ps = get_tank(network, tank)
return ps["overflow"]
@router.get("/gettankx/", summary="获取水箱X坐标", description="获取指定水箱的X坐标值")
@router.get("/tanks/x", summary="获取水箱X坐标", description="获取指定水箱的X坐标值")
async def fastapi_get_tank_x(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
@@ -250,7 +250,7 @@ async def fastapi_get_tank_x(
ps = get_tank(network, tank)
return ps["x"]
@router.get("/gettanky/", summary="获取水箱Y坐标", description="获取指定水箱的Y坐标值")
@router.get("/tanks/y", summary="获取水箱Y坐标", description="获取指定水箱的Y坐标值")
async def fastapi_get_tank_y(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
@@ -268,7 +268,7 @@ async def fastapi_get_tank_y(
ps = get_tank(network, tank)
return ps["y"]
@router.get("/gettankcoord/", summary="获取水箱坐标", description="获取指定水箱的X和Y坐标")
@router.get("/tanks/coord", summary="获取水箱坐标", description="获取指定水箱的X和Y坐标")
async def fastapi_get_tank_coord(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
@@ -287,7 +287,7 @@ async def fastapi_get_tank_coord(
coord = {"x": ps["x"], "y": ps["y"]}
return coord
@router.post("/settankelevation/", summary="设置水箱标高", description="设置指定水箱的标高值", response_model=None)
@router.patch("/tanks/elevation", summary="设置水箱标高", description="设置指定水箱的标高值", response_model=None)
async def fastapi_set_tank_elevation(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
@@ -307,7 +307,7 @@ async def fastapi_set_tank_elevation(
ps = {"id": tank, "elevation": elevation}
return set_tank(network, ChangeSet(ps))
@router.post("/settankinitlevel/", summary="设置水箱初始水位", description="设置指定水箱的初始水位值", response_model=None)
@router.patch("/tanks/init-level", summary="设置水箱初始水位", description="设置指定水箱的初始水位值", response_model=None)
async def fastapi_set_tank_init_level(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
@@ -327,7 +327,7 @@ async def fastapi_set_tank_init_level(
ps = {"id": tank, "init_level": init_level}
return set_tank(network, ChangeSet(ps))
@router.post("/settankminlevel/", summary="设置水箱最小水位", description="设置指定水箱的最小水位值", response_model=None)
@router.patch("/tanks/min-level", summary="设置水箱最小水位", description="设置指定水箱的最小水位值", response_model=None)
async def fastapi_set_tank_min_level(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
@@ -347,7 +347,7 @@ async def fastapi_set_tank_min_level(
ps = {"id": tank, "min_level": min_level}
return set_tank(network, ChangeSet(ps))
@router.post("/settankmaxlevel/", summary="设置水箱最大水位", description="设置指定水箱的最大水位值", response_model=None)
@router.patch("/tanks/max-level", summary="设置水箱最大水位", description="设置指定水箱的最大水位值", response_model=None)
async def fastapi_set_tank_max_level(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
@@ -367,7 +367,7 @@ async def fastapi_set_tank_max_level(
ps = {"id": tank, "max_level": max_level}
return set_tank(network, ChangeSet(ps))
@router.post("/settankdiameter/", summary="设置水箱直径", description="设置指定水箱的直径值", response_model=None)
@router.patch("/tanks/diameter", summary="设置水箱直径", description="设置指定水箱的直径值", response_model=None)
async def fastapi_set_tank_diameter(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
@@ -387,7 +387,7 @@ async def fastapi_set_tank_diameter(
ps = {"id": tank, "diameter": diameter}
return set_tank(network, ChangeSet(ps))
@router.post("/settankminvol/", summary="设置水箱最小体积", description="设置指定水箱的最小体积值", response_model=None)
@router.patch("/tanks/min-vol", summary="设置水箱最小体积", description="设置指定水箱的最小体积值", response_model=None)
async def fastapi_set_tank_min_vol(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
@@ -407,7 +407,7 @@ async def fastapi_set_tank_min_vol(
ps = {"id": tank, "min_vol": min_vol}
return set_tank(network, ChangeSet(ps))
@router.post("/settankvolcurve/", summary="设置水箱容积曲线", description="设置指定水箱的容积曲线标识", response_model=None)
@router.patch("/tanks/vol-curve", summary="设置水箱容积曲线", description="设置指定水箱的容积曲线标识", response_model=None)
async def fastapi_set_tank_vol_curve(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
@@ -427,7 +427,7 @@ async def fastapi_set_tank_vol_curve(
ps = {"id": tank, "vol_curve": vol_curve}
return set_tank(network, ChangeSet(ps))
@router.post("/settankoverflow/", summary="设置水箱溢流口", description="设置指定水箱的溢流口配置", response_model=None)
@router.patch("/tanks/overflow", summary="设置水箱溢流口", description="设置指定水箱的溢流口配置", response_model=None)
async def fastapi_set_tank_overflow(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
@@ -447,7 +447,7 @@ async def fastapi_set_tank_overflow(
ps = {"id": tank, "overflow": overflow}
return set_tank(network, ChangeSet(ps))
@router.post("/settankx/", summary="设置水箱X坐标", description="设置指定水箱的X坐标值", response_model=None)
@router.patch("/tanks/x", summary="设置水箱X坐标", description="设置指定水箱的X坐标值", response_model=None)
async def fastapi_set_tank_x(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
@@ -467,7 +467,7 @@ async def fastapi_set_tank_x(
ps = {"id": tank, "x": x}
return set_tank(network, ChangeSet(ps))
@router.post("/settanky/", summary="设置水箱Y坐标", description="设置指定水箱的Y坐标值", response_model=None)
@router.patch("/tanks/y", summary="设置水箱Y坐标", description="设置指定水箱的Y坐标值", response_model=None)
async def fastapi_set_tank_y(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
@@ -487,7 +487,7 @@ async def fastapi_set_tank_y(
ps = {"id": tank, "y": y}
return set_tank(network, ChangeSet(ps))
@router.post("/settankcoord/", summary="设置水箱坐标", description="设置指定水箱的X和Y坐标", response_model=None)
@router.patch("/tanks/coord", summary="设置水箱坐标", description="设置指定水箱的X和Y坐标", response_model=None)
async def fastapi_set_tank_coord(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
@@ -509,7 +509,7 @@ async def fastapi_set_tank_coord(
ps = {"id": tank, "x": x, "y": y}
return set_tank(network, ChangeSet(ps))
@router.get("/gettankproperties/", summary="获取水箱属性", description="获取指定水箱的所有属性")
@router.get("/tanks/properties", summary="获取水箱属性", description="获取指定水箱的所有属性")
async def fastapi_get_tank_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
@@ -526,7 +526,7 @@ async def fastapi_get_tank_properties(
"""
return get_tank(network, tank)
@router.get("/getalltankproperties/", summary="获取所有水箱属性", description="获取指定网络中所有水箱的属性")
@router.get("/tanks", summary="获取所有水箱属性", description="获取指定网络中所有水箱的属性")
async def fastapi_get_all_tank_properties(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
@@ -544,7 +544,7 @@ async def fastapi_get_all_tank_properties(
results = get_all_tanks(network)
return results
@router.post("/settankproperties/", summary="设置水箱属性", description="批量设置指定水箱的多个属性", response_model=None)
@router.patch("/tanks/properties", summary="设置水箱属性", description="批量设置指定水箱的多个属性", response_model=None)
async def fastapi_set_tank_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
+24 -24
View File
@@ -15,7 +15,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get(
"/getvalveschema",
"/network-schemas/valve",
summary="获取阀门架构",
description="获取指定水网中所有阀门的架构和字段定义",
)
@@ -30,7 +30,7 @@ async def fastapi_get_valve_schema(
return get_valve_schema(network)
@router.post(
"/addvalve/",
"/valves",
response_model=None,
summary="添加阀门",
description="在指定的水网中添加新的阀门",
@@ -62,8 +62,8 @@ async def fastapi_add_valve(
return add_valve(network, ChangeSet(ps))
@router.post(
"/deletevalve/",
@router.delete(
"/valves",
response_model=None,
summary="删除阀门",
description="从指定的水网中删除指定的阀门",
@@ -81,7 +81,7 @@ async def fastapi_delete_valve(
return delete_valve(network, ChangeSet(ps))
@router.get(
"/getvalvenode1/",
"/valves/node1",
summary="获取阀门起点节点",
description="获取指定阀门连接的起点节点ID",
)
@@ -98,7 +98,7 @@ async def fastapi_get_valve_node1(
return ps["node1"]
@router.get(
"/getvalvenode2/",
"/valves/node2",
summary="获取阀门终点节点",
description="获取指定阀门连接的终点节点ID",
)
@@ -115,7 +115,7 @@ async def fastapi_get_valve_node2(
return ps["node2"]
@router.get(
"/getvalvediameter/",
"/valves/diameter",
summary="获取阀门直径",
description="获取指定阀门的直径",
)
@@ -132,7 +132,7 @@ async def fastapi_get_valve_diameter(
return ps["diameter"]
@router.get(
"/getvalvetype/",
"/valves/type",
summary="获取阀门类型",
description="获取指定阀门的类型",
)
@@ -149,7 +149,7 @@ async def fastapi_get_valve_type(
return ps["type"]
@router.get(
"/getvalvesetting/",
"/valves/setting",
summary="获取阀门开度",
description="获取指定阀门的开度/设置值",
)
@@ -166,7 +166,7 @@ async def fastapi_get_valve_setting(
return ps["setting"]
@router.get(
"/getvalveminorloss/",
"/valves/minor-loss",
summary="获取阀门损失系数",
description="获取指定阀门的损失系数",
)
@@ -182,8 +182,8 @@ async def fastapi_get_valve_minor_loss(
ps = get_valve(network, valve)
return ps["minor_loss"]
@router.post(
"/setvalvenode1/",
@router.patch(
"/valves/node1",
response_model=None,
summary="设置阀门起点节点",
description="设置指定阀门的起点节点",
@@ -201,8 +201,8 @@ async def fastapi_set_valve_node1(
ps = {"id": valve, "node1": node1}
return set_valve(network, ChangeSet(ps))
@router.post(
"/setvalvenode2/",
@router.patch(
"/valves/node2",
response_model=None,
summary="设置阀门终点节点",
description="设置指定阀门的终点节点",
@@ -220,8 +220,8 @@ async def fastapi_set_valve_node2(
ps = {"id": valve, "node2": node2}
return set_valve(network, ChangeSet(ps))
@router.post(
"/setvalvenodediameter/",
@router.patch(
"/valves/diameter",
response_model=None,
summary="设置阀门直径",
description="设置指定阀门的直径",
@@ -239,8 +239,8 @@ async def fastapi_set_valve_diameter(
ps = {"id": valve, "diameter": diameter}
return set_valve(network, ChangeSet(ps))
@router.post(
"/setvalvetype/",
@router.patch(
"/valves/type",
response_model=None,
summary="设置阀门类型",
description="设置指定阀门的类型",
@@ -258,8 +258,8 @@ async def fastapi_set_valve_type(
ps = {"id": valve, "type": type}
return set_valve(network, ChangeSet(ps))
@router.post(
"/setvalvesetting/",
@router.patch(
"/valves/setting",
response_model=None,
summary="设置阀门开度",
description="设置指定阀门的开度/设置值",
@@ -278,7 +278,7 @@ async def fastapi_set_valve_setting(
return set_valve(network, ChangeSet(ps))
@router.get(
"/getvalveproperties/",
"/valves/properties",
summary="获取阀门所有属性",
description="获取指定阀门的所有属性",
)
@@ -294,7 +294,7 @@ async def fastapi_get_valve_properties(
return get_valve(network, valve)
@router.get(
"/getallvalveproperties/",
"/valves",
summary="获取所有阀门属性",
description="获取指定水网中所有阀门的属性",
)
@@ -311,8 +311,8 @@ async def fastapi_get_all_valve_properties(
results = get_all_valves(network)
return results
@router.post(
"/setvalveproperties/",
@router.patch(
"/valves/properties",
response_model=None,
summary="批量设置阀门属性",
description="批量设置指定阀门的多个属性",
+29 -91
View File
@@ -1,9 +1,13 @@
import json
from fastapi import APIRouter, Request, HTTPException, Query, Path, Body, Depends
from fastapi import APIRouter, Request, HTTPException, Query, Path, Depends
from fastapi.responses import PlainTextResponse
from typing import Any, Dict, List
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.auth.project_dependencies import get_metadata_repository
from app.auth.permissions import (
ENVIRONMENT_MANAGE,
require_permission,
)
from app.domain.schemas.metadata import ProjectMetaResponse
import app.services.project_info as project_info
from app.infra.db.postgresql.database import get_database_instance as get_pg_db
@@ -18,7 +22,6 @@ from app.services.tjnetwork import (
open_project,
close_project,
copy_project,
import_inp,
export_inp,
read_inp,
dump_inp,
@@ -42,8 +45,7 @@ inpDir = "data/" # Assuming data directory exists or is defined somewhere.
router = APIRouter()
lockedPrjs: Dict[str, str] = {}
@router.get("/project-info", summary="获取项目信息", description="从数据库获取项目的详细信息,包括地图范围等。", response_model=ProjectMetaResponse)
@router.get("/project_info/", summary="获取项目信息(旧路径)", description="从数据库获取项目的详细信息,包括地图范围等。", response_model=ProjectMetaResponse, deprecated=True)
@router.get("/projects/current", summary="获取项目信息", description="从数据库获取项目的详细信息,包括地图范围等。", response_model=ProjectMetaResponse)
async def get_project_info_endpoint(
network: str = Query(..., description="管网名称(或项目代码)"),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
@@ -67,7 +69,7 @@ async def get_project_info_endpoint(
project_role="viewer", # Default role for public access
)
@router.get("/listprojects/", summary="获取项目列表", description="获取服务器上所有可用的供水管网项目名称列表。")
@router.get("/project-codes", summary="获取项目列表", description="获取服务器上所有可用的供水管网项目名称列表。")
async def list_projects_endpoint() -> list[str]:
"""
获取项目列表
@@ -76,7 +78,7 @@ async def list_projects_endpoint() -> list[str]:
"""
return list_project()
@router.get("/haveproject/", summary="检查项目是否存在", description="检查指定名称的项目是否存在。")
@router.get("/projects/existence", summary="检查项目是否存在", description="检查指定名称的项目是否存在。")
async def have_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)")
):
@@ -87,9 +89,10 @@ async def have_project_endpoint(
"""
return have_project(network)
@router.post("/createproject/", summary="创建新项目", description="创建一个新的供水管网项目。如果项目已存在,可能会覆盖或报错(取决于底层实现)。")
@router.post("/projects", summary="创建新项目", description="创建一个新的供水管网项目。如果项目已存在,可能会覆盖或报错(取决于底层实现)。")
async def create_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)")
network: str = Query(..., description="管网名称(或数据库名称)"),
_=Depends(require_permission(ENVIRONMENT_MANAGE)),
):
"""
创建新项目
@@ -99,9 +102,10 @@ async def create_project_endpoint(
create_project(network)
return network
@router.post("/deleteproject/", summary="删除项目", description="永久删除指定的供水管网项目。此操作不可恢复。")
@router.delete("/projects", summary="删除项目", description="永久删除指定的供水管网项目。此操作不可恢复。")
async def delete_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)")
network: str = Query(..., description="管网名称(或数据库名称)"),
_=Depends(require_permission(ENVIRONMENT_MANAGE)),
):
"""
删除项目
@@ -111,7 +115,7 @@ async def delete_project_endpoint(
delete_project(network)
return True
@router.get("/isprojectopen/", summary="检查项目是否已打开", description="检查指定项目是否已被加载到内存中。")
@router.get("/projects/current/status", summary="检查项目是否已打开", description="检查指定项目是否已被加载到内存中。")
async def is_project_open_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)")
):
@@ -122,8 +126,7 @@ async def is_project_open_endpoint(
"""
return is_project_open(network)
@router.post("/projects/open", summary="打开项目", description="将指定项目加载到内存中,并初始化数据库连接池。")
@router.post("/openproject/", summary="打开项目(旧路径)", description="将指定项目加载到内存中,并初始化数据库连接池。", deprecated=True)
@router.post("/projects/current", summary="打开项目", description="将指定项目加载到内存中,并初始化数据库连接池。")
async def open_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)")
):
@@ -157,7 +160,7 @@ async def open_project_endpoint(
return network
@router.post("/closeproject/", summary="关闭项目", description="将指定项目从内存中卸载,释放资源。")
@router.delete("/projects/current", summary="关闭项目", description="将指定项目从内存中卸载,释放资源。")
async def close_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)")
):
@@ -169,10 +172,11 @@ async def close_project_endpoint(
close_project(network)
return True
@router.post("/copyproject/", summary="复制项目", description="将现有项目复制为新项目。")
@router.post("/project-copies", summary="复制项目", description="将现有项目复制为新项目。")
async def copy_project_endpoint(
source: str = Query(..., description="管网名称(或数据库名称)"),
target: str = Query(..., description="管网名称(或数据库名称)")
target: str = Query(..., description="管网名称(或数据库名称)"),
_=Depends(require_permission(ENVIRONMENT_MANAGE)),
):
"""
复制项目
@@ -183,25 +187,7 @@ async def copy_project_endpoint(
copy_project(source, target)
return True
@router.post("/importinp/", summary="导入 INP 文件内容", description="将 INP 格式的文本内容导入到指定项目中")
async def import_inp_endpoint(
req: Request,
network: str = Query(..., description="管网名称(或数据库名称)")
):
"""
导入 INP 文件内容
- **network**: 管网名称或数据库名称
- **req**: 请求体需包含 `{"inp": "..."}` 结构
"""
jo_root = await req.json()
inp_text = jo_root["inp"]
ps = {"inp": inp_text}
ret = import_inp(network, ChangeSet(ps))
print(ret)
return ret
@router.get("/exportinp/", response_model=None, summary="导出项目为 ChangeSet", description="导出项目的变更集 (ChangeSet),包含顶点、SCADA 元素、DMA、SA、VD 等信息。")
@router.get("/projects/current/exports/change-set", response_model=None, summary="导出项目为 ChangeSet", description="导出项目的变更集 (ChangeSet),包含顶点、SCADA 元素、DMA、SA、VD 等信息")
async def export_inp_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
version: str = Query(..., description="版本号 (通常用于增量更新)")
@@ -234,7 +220,7 @@ async def export_inp_endpoint(
return cs
@router.post("/readinp/", summary="读取 INP 文件到项目", description="从服务器文件系统中读取指定的 INP 文件并加载到项目中。")
@router.post("/projects/current/imports", summary="读取 INP 文件到项目", description="从服务器文件系统中读取指定的 INP 文件并加载到项目中。")
async def read_inp_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
inp: str = Query(..., description="INP 文件名 (不包含路径)")
@@ -248,7 +234,7 @@ async def read_inp_endpoint(
read_inp(network, inp)
return True
@router.get("/dumpinp/", summary="导出项目到 INP 文件", description="将项目当前状态保存为 INP 文件到服务器文件系统。")
@router.post("/projects/current/exports/inp", summary="导出项目到 INP 文件", description="将项目当前状态保存为 INP 文件到服务器文件系统。")
async def dump_inp_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
inp: str = Query(..., description="目标文件名")
@@ -262,7 +248,7 @@ async def dump_inp_endpoint(
dump_inp(network, inp)
return True
@router.get("/isprojectlocked/", summary="检查项目是否被锁定", description="检查指定项目是否处于锁定状态。")
@router.get("/projects/current/lock", summary="检查项目是否被锁定", description="检查指定项目是否处于锁定状态。")
async def is_project_locked_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -274,7 +260,7 @@ async def is_project_locked_endpoint(
"""
return network in lockedPrjs.keys()
@router.get("/isprojectlockedbyme/", summary="检查项目是否被当前用户锁定", description="检查指定项目是否被当前客户端 (IP) 锁定。")
@router.get("/projects/current/lock/ownership", summary="检查项目是否被当前用户锁定", description="检查指定项目是否被当前客户端 (IP) 锁定。")
async def is_project_locked_by_me_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -290,7 +276,7 @@ async def is_project_locked_by_me_endpoint(
# 0 successfully locked
# 1 already locked by you
# 2 locked by others
@router.post("/lockproject/", summary="锁定项目", description="锁定指定项目以防止并发修改。")
@router.post("/projects/current/lock", summary="锁定项目", description="锁定指定项目以防止并发修改。")
async def lock_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -313,7 +299,7 @@ async def lock_project_endpoint(
else:
return 2
@router.post("/unlockproject/", summary="解锁项目", description="释放对项目的锁定。")
@router.delete("/projects/current/lock", summary="解锁项目", description="释放对项目的锁定。")
def unlock_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -331,27 +317,7 @@ def unlock_project_endpoint(
return False
# inp file operations
@router.post("/uploadinp/", status_code=status.HTTP_200_OK, summary="上传 INP 文件", description="上传 INP 文件到服务器数据目录。")
async def fastapi_upload_inp(
afile: bytes = Body(..., description="文件二进制内容"),
name: str = Query(..., description="保存的文件名")
):
"""
上传 INP 文件
- **afile**: 文件内容
- **name**: 文件名
"""
if not os.path.exists(inpDir):
os.makedirs(inpDir, exist_ok=True)
filePath = inpDir + str(name)
with open(filePath, "wb") as f:
f.write(afile)
return True
@router.get("/downloadinp/", status_code=status.HTTP_200_OK, summary="下载 INP 文件", description="从服务器数据目录下载指定的 INP 文件。")
@router.get("/projects/current/files/inp", status_code=status.HTTP_200_OK, summary="下载 INP 文件", description="从服务器数据目录下载指定的 INP 文件。")
async def fastapi_download_inp(
name: str = Query(..., description="文件名"),
response: Response = None
@@ -371,7 +337,7 @@ async def fastapi_download_inp(
return True
# DingZQ, 2024-12-28, convert v3 to v2
@router.get("/convertv3tov2/", response_model=None, summary="转换 INP V3 为 V2", description="将 EPANET 3.0 格式的 INP 内容转换为 2.x 格式。")
@router.post("/project-conversions", response_model=None, summary="转换 INP V3 为 V2", description="将 EPANET 3.0 格式的 INP 内容转换为 2.x 格式。")
async def fastapi_convert_v3_to_v2(
req: Request
) -> ChangeSet:
@@ -405,7 +371,6 @@ async def fastapi_convert_v3_to_v2(
return cs
@router.post("/readinp/", summary="读取 INP 文件到项目", description="从服务器文件系统中读取指定的 INP 文件并加载到项目中。")
async def read_inp_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
inp: str = Query(..., description="INP 文件名 (不包含路径)")
@@ -419,7 +384,6 @@ async def read_inp_endpoint(
read_inp(network, inp)
return True
@router.get("/dumpinp/", summary="导出项目到 INP 文件", description="将项目当前状态保存为 INP 文件到服务器文件系统。")
async def dump_inp_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
inp: str = Query(..., description="目标文件名")
@@ -433,7 +397,6 @@ async def dump_inp_endpoint(
dump_inp(network, inp)
return True
@router.get("/isprojectlocked/", summary="检查项目是否被锁定", description="检查指定项目是否处于锁定状态。")
async def is_project_locked_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -445,7 +408,6 @@ async def is_project_locked_endpoint(
"""
return network in lockedPrjs.keys()
@router.get("/isprojectlockedbyme/", summary="检查项目是否被当前用户锁定", description="检查指定项目是否被当前客户端 (IP) 锁定。")
async def is_project_locked_by_me_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -461,7 +423,6 @@ async def is_project_locked_by_me_endpoint(
# 0 successfully locked
# 1 already locked by you
# 2 locked by others
@router.post("/lockproject/", summary="锁定项目", description="锁定指定项目以防止并发修改。")
async def lock_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -484,7 +445,6 @@ async def lock_project_endpoint(
else:
return 2
@router.post("/unlockproject/", summary="解锁项目", description="释放对项目的锁定。")
def unlock_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -502,27 +462,6 @@ def unlock_project_endpoint(
return False
# inp file operations
@router.post("/uploadinp/", status_code=status.HTTP_200_OK, summary="上传 INP 文件", description="上传 INP 文件到服务器数据目录。")
async def fastapi_upload_inp(
afile: bytes = Body(..., description="文件二进制内容"),
name: str = Query(..., description="保存的文件名")
):
"""
上传 INP 文件
- **afile**: 文件内容
- **name**: 文件名
"""
if not os.path.exists(inpDir):
os.makedirs(inpDir, exist_ok=True)
filePath = inpDir + str(name)
with open(filePath, "wb") as f:
f.write(afile)
return True
@router.get("/downloadinp/", status_code=status.HTTP_200_OK, summary="下载 INP 文件", description="从服务器数据目录下载指定的 INP 文件。")
async def fastapi_download_inp(
name: str = Query(..., description="文件名"),
response: Response = None
@@ -542,7 +481,6 @@ async def fastapi_download_inp(
return True
# DingZQ, 2024-12-28, convert v3 to v2
@router.get("/convertv3tov2/", response_model=None, summary="转换 INP V3 为 V2", description="将 EPANET 3.0 格式的 INP 内容转换为 2.x 格式。")
async def fastapi_convert_v3_to_v2(
req: Request
) -> ChangeSet:
+4 -4
View File
@@ -15,7 +15,7 @@ async def get_database_connection(
yield conn
@router.get("/scada-info", summary="获取SCADA信息", description="使用连接池查询所有SCADA信息")
@router.get("/scada-info/database-view", summary="获取SCADA信息", description="使用连接池查询所有SCADA信息")
async def get_scada_info_with_connection(
conn: AsyncConnection = Depends(get_database_connection),
):
@@ -33,7 +33,7 @@ async def get_scada_info_with_connection(
)
@router.get("/scheme-list", summary="获取方案列表", description="使用连接池查询所有方案信息")
@router.get("/schemes/list-with-connection", summary="获取方案列表", description="使用连接池查询所有方案信息")
async def get_scheme_list_with_connection(
conn: AsyncConnection = Depends(get_database_connection),
):
@@ -49,7 +49,7 @@ async def get_scheme_list_with_connection(
raise HTTPException(status_code=500, detail=f"查询方案信息时发生错误: {str(e)}")
@router.get("/burst-locate-result", summary="获取爆管定位结果", description="使用连接池查询所有爆管定位结果")
@router.get("/burst-locations/database-view", summary="获取爆管定位结果", description="使用连接池查询所有爆管定位结果")
async def get_burst_locate_result_with_connection(
conn: AsyncConnection = Depends(get_database_connection),
):
@@ -67,7 +67,7 @@ async def get_burst_locate_result_with_connection(
)
@router.get("/burst-locate-result/{burst_incident}", summary="按事件查询爆管定位结果", description="根据爆管事件ID查询对应的爆管定位结果")
@router.get("/burst-locations/{burst_incident}", summary="按事件查询爆管定位结果", description="根据爆管事件ID查询对应的爆管定位结果")
async def get_burst_locate_result_by_incident(
burst_incident: str = Path(..., description="爆管事件ID"),
conn: AsyncConnection = Depends(get_database_connection),
+5 -5
View File
@@ -11,7 +11,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get(
"/getpiperiskprobabilitynow/",
"/pipes/risk-probability-now",
summary="获取管道当前风险概率",
description="获取指定管道当前时刻的风险概率值"
)
@@ -35,7 +35,7 @@ async def fastapi_get_pipe_risk_probability_now(
@router.get(
"/getpiperiskprobability/",
"/pipes/risk-probability",
summary="获取管道风险概率历史",
description="获取指定管道的风险概率历史数据"
)
@@ -59,7 +59,7 @@ async def fastapi_get_pipe_risk_probability(
@router.get(
"/getpipesriskprobability/",
"/pipes-risk-probabilities",
summary="批量获取多条管道风险概率",
description="批量获取多条管道的风险概率值"
)
@@ -84,7 +84,7 @@ async def fastapi_get_pipes_risk_probability(
@router.get(
"/getnetworkpiperiskprobabilitynow/",
"/network-pipe-risk-probability-nows",
summary="获取整个网络的管道风险概率",
description="获取指定网络中所有管道的当前风险概率值"
)
@@ -106,7 +106,7 @@ async def fastapi_get_network_pipe_risk_probability_now(
@router.get(
"/getpiperiskprobabilitygeometries/",
"/pipes/risk-probability-geometries",
summary="获取管道风险几何信息",
description="获取指定网络中管道的风险相关几何数据"
)
+24 -26
View File
@@ -31,7 +31,6 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/getscadaproperties/", summary="获取SCADA属性", tags=["SCADA基础"])
async def fast_get_scada_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
scada: str = Query(..., description="SCADA设备ID")
@@ -50,7 +49,6 @@ async def fast_get_scada_properties(
"""
return get_scada_info(network, scada)
@router.get("/getallscadaproperties/", summary="获取所有SCADA属性", tags=["SCADA基础"])
async def fast_get_all_scada_properties(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
@@ -72,7 +70,7 @@ async def fast_get_all_scada_properties(
# scada_device 设备管理
############################################################
@router.get("/getscadadeviceschema/", summary="获取SCADA设备架构", tags=["SCADA设备"])
@router.get("/network-schemas/scada-device", summary="获取SCADA设备架构", tags=["SCADA设备"])
async def fastapi_get_scada_device_schema(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> dict[str, dict[str, Any]]:
@@ -89,7 +87,7 @@ async def fastapi_get_scada_device_schema(
"""
return get_scada_device_schema(network)
@router.get("/getscadadevice/", summary="获取SCADA设备", tags=["SCADA设备"])
@router.get("/scada-devices/detail", summary="获取SCADA设备", tags=["SCADA设备"])
async def fastapi_get_scada_device(
network: str = Query(..., description="管网名称(或数据库名称)"),
id: str = Query(..., description="SCADA设备ID")
@@ -108,7 +106,7 @@ async def fastapi_get_scada_device(
"""
return get_scada_device(network, id)
@router.post("/setscadadevice/", response_model=None, summary="更新SCADA设备", tags=["SCADA设备"])
@router.patch("/scada-devices", response_model=None, summary="更新SCADA设备", tags=["SCADA设备"])
async def fastapi_set_scada_device(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -128,7 +126,7 @@ async def fastapi_set_scada_device(
props = await req.json()
return set_scada_device(network, ChangeSet(props))
@router.post("/addscadadevice/", response_model=None, summary="添加SCADA设备", tags=["SCADA设备"])
@router.post("/scada-devices", response_model=None, summary="添加SCADA设备", tags=["SCADA设备"])
async def fastapi_add_scada_device(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -148,7 +146,7 @@ async def fastapi_add_scada_device(
props = await req.json()
return add_scada_device(network, ChangeSet(props))
@router.post("/deletescadadevice/", response_model=None, summary="删除SCADA设备", tags=["SCADA设备"])
@router.delete("/scada-devices", response_model=None, summary="删除SCADA设备", tags=["SCADA设备"])
async def fastapi_delete_scada_device(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -168,7 +166,7 @@ async def fastapi_delete_scada_device(
props = await req.json()
return delete_scada_device(network, ChangeSet(props))
@router.post("/cleanscadadevice/", response_model=None, summary="清空SCADA设备表", tags=["SCADA设备"])
@router.post("/scada-device-cleaning-runs", response_model=None, summary="清空SCADA设备表", tags=["SCADA设备"])
async def fastapi_clean_scada_device(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> ChangeSet:
@@ -185,7 +183,7 @@ async def fastapi_clean_scada_device(
"""
return clean_scada_device(network)
@router.get("/getallscadadeviceids/", summary="获取所有SCADA设备ID", tags=["SCADA设备"])
@router.get("/scada-devices/ids", summary="获取所有SCADA设备ID", tags=["SCADA设备"])
async def fastapi_get_all_scada_device_ids(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[str]:
@@ -200,7 +198,7 @@ async def fastapi_get_all_scada_device_ids(
"""
return get_all_scada_device_ids(network)
@router.get("/getallscadadevices/", summary="获取所有SCADA设备", tags=["SCADA设备"])
@router.get("/scada-devices", summary="获取所有SCADA设备", tags=["SCADA设备"])
async def fastapi_get_all_scada_devices(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
@@ -220,7 +218,7 @@ async def fastapi_get_all_scada_devices(
# scada_device_data 设备数据管理
############################################################
@router.get("/getscadadevicedataschema/", summary="获取SCADA设备数据架构", tags=["SCADA设备数据"])
@router.get("/network-schemas/scada-device-data", summary="获取SCADA设备数据架构", tags=["SCADA设备数据"])
async def fastapi_get_scada_device_data_schema(
network: str = Query(..., description="管网名称(或数据库名称)"),
) -> dict[str, dict[str, Any]]:
@@ -237,7 +235,7 @@ async def fastapi_get_scada_device_data_schema(
"""
return get_scada_device_data_schema(network)
@router.get("/getscadadevicedata/", summary="获取SCADA设备数据", tags=["SCADA设备数据"])
@router.get("/scada-device-datas/detail", summary="获取SCADA设备数据", tags=["SCADA设备数据"])
async def fastapi_get_scada_device_data(
network: str = Query(..., description="管网名称(或数据库名称)"),
device_id: str = Query(..., description="SCADA设备ID")
@@ -256,7 +254,7 @@ async def fastapi_get_scada_device_data(
"""
return get_scada_device_data(network, device_id)
@router.post("/setscadadevicedata/", response_model=None, summary="更新SCADA设备数据", tags=["SCADA设备数据"])
@router.patch("/scada-device-datas", response_model=None, summary="更新SCADA设备数据", tags=["SCADA设备数据"])
async def fastapi_set_scada_device_data(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -276,7 +274,7 @@ async def fastapi_set_scada_device_data(
props = await req.json()
return set_scada_device_data(network, ChangeSet(props))
@router.post("/addscadadevicedata/", response_model=None, summary="添加SCADA设备数据", tags=["SCADA设备数据"])
@router.post("/scada-device-datas", response_model=None, summary="添加SCADA设备数据", tags=["SCADA设备数据"])
async def fastapi_add_scada_device_data(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -296,7 +294,7 @@ async def fastapi_add_scada_device_data(
props = await req.json()
return add_scada_device_data(network, ChangeSet(props))
@router.post("/deletescadadevicedata/", response_model=None, summary="删除SCADA设备数据", tags=["SCADA设备数据"])
@router.delete("/scada-device-datas", response_model=None, summary="删除SCADA设备数据", tags=["SCADA设备数据"])
async def fastapi_delete_scada_device_data(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -316,7 +314,7 @@ async def fastapi_delete_scada_device_data(
props = await req.json()
return delete_scada_device_data(network, ChangeSet(props))
@router.post("/cleanscadadevicedata/", response_model=None, summary="清空SCADA设备数据表", tags=["SCADA设备数据"])
@router.post("/scada-device-data-cleaning-runs", response_model=None, summary="清空SCADA设备数据表", tags=["SCADA设备数据"])
async def fastapi_clean_scada_device_data(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> ChangeSet:
@@ -338,7 +336,7 @@ async def fastapi_clean_scada_device_data(
# scada_element SCADA元素映射
############################################################
@router.get("/getscadaelementschema/", summary="获取SCADA元素架构", tags=["SCADA元素映射"])
@router.get("/network-schemas/scada-element", summary="获取SCADA元素架构", tags=["SCADA元素映射"])
async def fastapi_get_scada_element_schema(
network: str = Query(..., description="管网名称(或数据库名称)"),
) -> dict[str, dict[str, Any]]:
@@ -355,7 +353,7 @@ async def fastapi_get_scada_element_schema(
"""
return get_scada_element_schema(network)
@router.get("/getscadaelements/", summary="获取所有SCADA元素映射", tags=["SCADA元素映射"])
@router.get("/scada-elements", summary="获取所有SCADA元素映射", tags=["SCADA元素映射"])
async def fastapi_get_scada_elements(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
@@ -372,7 +370,7 @@ async def fastapi_get_scada_elements(
"""
return get_all_scada_elements(network)
@router.get("/getscadaelement/", summary="获取单个SCADA元素映射", tags=["SCADA元素映射"])
@router.get("/scada-elements/detail", summary="获取单个SCADA元素映射", tags=["SCADA元素映射"])
async def fastapi_get_scada_element(
network: str = Query(..., description="管网名称(或数据库名称)"),
id: str = Query(..., description="SCADA元素映射ID")
@@ -391,7 +389,7 @@ async def fastapi_get_scada_element(
"""
return get_scada_element(network, id)
@router.post("/setscadaelement/", response_model=None, summary="更新SCADA元素映射", tags=["SCADA元素映射"])
@router.patch("/scada-elements", response_model=None, summary="更新SCADA元素映射", tags=["SCADA元素映射"])
async def fastapi_set_scada_element(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -411,7 +409,7 @@ async def fastapi_set_scada_element(
props = await req.json()
return set_scada_element(network, ChangeSet(props))
@router.post("/addscadaelement/", response_model=None, summary="添加SCADA元素映射", tags=["SCADA元素映射"])
@router.post("/scada-elements", response_model=None, summary="添加SCADA元素映射", tags=["SCADA元素映射"])
async def fastapi_add_scada_element(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -431,7 +429,7 @@ async def fastapi_add_scada_element(
props = await req.json()
return add_scada_element(network, ChangeSet(props))
@router.post("/deletescadaelement/", response_model=None, summary="删除SCADA元素映射", tags=["SCADA元素映射"])
@router.delete("/scada-elements", response_model=None, summary="删除SCADA元素映射", tags=["SCADA元素映射"])
async def fastapi_delete_scada_element(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -451,7 +449,7 @@ async def fastapi_delete_scada_element(
props = await req.json()
return delete_scada_element(network, ChangeSet(props))
@router.post("/cleanscadaelement/", response_model=None, summary="清空SCADA元素映射表", tags=["SCADA元素映射"])
@router.post("/scada-element-cleaning-runs", response_model=None, summary="清空SCADA元素映射表", tags=["SCADA元素映射"])
async def fastapi_clean_scada_element(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> ChangeSet:
@@ -473,7 +471,7 @@ async def fastapi_clean_scada_element(
# scada_info SCADA信息
############################################################
@router.get("/getscadainfoschema/", summary="获取SCADA信息架构", tags=["SCADA信息"])
@router.get("/scada-info-schemas", summary="获取SCADA信息架构", tags=["SCADA信息"])
async def fastapi_get_scada_info_schema(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> dict[str, dict[str, Any]]:
@@ -490,7 +488,7 @@ async def fastapi_get_scada_info_schema(
"""
return get_scada_info_schema(network)
@router.get("/getscadainfo/", summary="获取SCADA信息", tags=["SCADA信息"])
@router.get("/scada-info/detail", summary="获取SCADA信息", tags=["SCADA信息"])
async def fastapi_get_scada_info(
network: str = Query(..., description="管网名称(或数据库名称)"),
id: str = Query(..., description="SCADA信息ID")
@@ -509,7 +507,7 @@ async def fastapi_get_scada_info(
"""
return get_scada_info(network, id)
@router.get("/getallscadainfo/", summary="获取所有SCADA信息", tags=["SCADA信息"])
@router.get("/scada-info", summary="获取所有SCADA信息", tags=["SCADA信息"])
async def fastapi_get_all_scada_info(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
+34 -7
View File
@@ -1,10 +1,13 @@
from fastapi import APIRouter, Query
from typing import Any, List, Dict
from datetime import datetime
from fastapi import APIRouter, HTTPException, Path, Query
from typing import Any
from app.services.tjnetwork import get_scheme_schema, get_scheme, get_all_schemes
from app.services.scheme_management import query_scheme_detail
from app.services.time_api import extract_date
router = APIRouter()
@router.get("/getschemeschema/", summary="获取方案模式", description="获取指定网络的方案模式定义")
@router.get("/network-schemas/scheme", summary="获取方案模式", description="获取指定网络的方案模式定义")
async def fastapi_get_scheme_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[Any, Any]]:
"""
获取方案模式定义
@@ -13,7 +16,7 @@ async def fastapi_get_scheme_schema(network: str = Query(..., description="管
"""
return get_scheme_schema(network)
@router.get("/getscheme/", summary="获取单个方案", description="根据名称获取指定的方案信息")
@router.get("/schemes/detail", summary="获取单个方案", description="根据名称获取指定的方案信息")
async def fastapi_get_scheme(network: str = Query(..., description="管网名称(或数据库名称)"), schema_name: str = Query(..., description="方案名称")) -> dict[Any, Any]:
"""
获取单个方案详情
@@ -23,11 +26,35 @@ async def fastapi_get_scheme(network: str = Query(..., description="管网名称
return get_scheme(network, schema_name)
@router.get("/schemes", summary="获取所有方案", description="获取指定网络的所有方案信息")
@router.get("/getallschemes/", summary="获取所有方案(旧路径)", description="获取指定网络的所有方案信息", deprecated=True)
async def fastapi_get_all_schemes(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[Any, Any]]:
async def fastapi_get_all_schemes(
network: str = Query(..., description="管网名称(或数据库名称)"),
scheme_type: str | None = Query(None, description="方案类型;为空时返回全部类型"),
query_date: datetime | None = Query(None, description="查询日期(可选)"),
) -> list[dict[Any, Any]]:
"""
获取所有方案列表
返回指定网络中所有可用的方案
"""
return get_all_schemes(network)
parsed_date = (
extract_date(query_date, field_name="query_date")
if query_date is not None
else None
)
return get_all_schemes(network, scheme_type=scheme_type, query_date=parsed_date)
@router.get("/schemes/{scheme_name}", summary="获取方案详情", description="按方案类型获取指定方案详情")
async def fastapi_get_scheme_detail(
scheme_name: str = Path(..., description="方案名称"),
network: str = Query(..., description="管网名称(或数据库名称)"),
scheme_type: str | None = Query(None, description="方案类型;为空时返回通用方案详情"),
) -> dict[Any, Any]:
result = query_scheme_detail(
name=network,
scheme_name=scheme_name,
scheme_type=scheme_type,
)
if not result:
raise HTTPException(status_code=404, detail=f"Scheme {scheme_name} not found")
return result
+244
View File
@@ -0,0 +1,244 @@
import logging
from typing import Any
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from starlette.concurrency import run_in_threadpool
from app.algorithms.sensor import (
pressure_sensor_placement_kmeans,
pressure_sensor_placement_sensitivity,
)
from app.auth.metadata_dependencies import get_current_metadata_user
from app.auth.project_dependencies import ProjectContext, get_project_context
from app.domain.schemas.sensor_placement import (
SensorPlacementExportRequest,
SensorPlacementOptimizeRequest,
SensorPlacementSchemeResponse,
SensorPlacementUpdateRequest,
)
from app.services.sensor_placement import (
SensorPlacementConflictError,
SensorPlacementNotFoundError,
SensorPlacementValidationError,
build_sensor_placement_workbook,
can_edit_sensor_placement,
get_sensor_placement_scheme,
update_sensor_placement_scheme,
)
router = APIRouter()
logger = logging.getLogger(__name__)
def _project_network(network: str, project_context: ProjectContext) -> str:
if network != project_context.project_code:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="请求的管网不属于当前项目",
)
return project_context.project_code
def _can_modify_project(project_context: ProjectContext) -> bool:
return project_context.project_role == "member"
def _require_project_write(
project_context: ProjectContext,
) -> None:
if not _can_modify_project(project_context):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="当前项目角色为只读,不能修改监测点方案",
)
def _service_http_error(exc: Exception) -> HTTPException:
if isinstance(exc, SensorPlacementNotFoundError):
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
if isinstance(exc, SensorPlacementConflictError):
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
)
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
)
def _get_scheme_response(
network: str,
scheme_id: int,
current_user: Any,
project_context: ProjectContext,
) -> dict[str, Any]:
try:
scheme = get_sensor_placement_scheme(network, scheme_id)
return {
**scheme,
"can_edit": (
_can_modify_project(project_context)
and can_edit_sensor_placement(current_user, scheme)
),
}
except (
SensorPlacementNotFoundError,
SensorPlacementValidationError,
) as exc:
raise _service_http_error(exc) from exc
@router.post(
"/sensor-placement-optimization-runs",
response_model=SensorPlacementSchemeResponse,
summary="创建并返回监测点优化方案",
)
async def optimize_sensor_placement_scheme(
payload: SensorPlacementOptimizeRequest,
project_context: ProjectContext = Depends(get_project_context),
current_user=Depends(get_current_metadata_user),
) -> dict[str, Any]:
network = _project_network(payload.network, project_context)
_require_project_write(project_context)
optimizer = (
pressure_sensor_placement_sensitivity
if payload.method == "sensitivity"
else pressure_sensor_placement_kmeans
)
try:
created = await run_in_threadpool(
optimizer,
name=network,
scheme_name=payload.scheme_name,
sensor_number=payload.sensor_count,
min_diameter=payload.min_diameter,
username=current_user.username,
)
scheme = get_sensor_placement_scheme(network, int(created["id"]))
return {**scheme, "can_edit": True}
except (
SensorPlacementConflictError,
SensorPlacementValidationError,
ValueError,
) as exc:
raise _service_http_error(exc) from exc
except Exception as exc:
logger.exception("Sensor placement optimization failed")
raise HTTPException(
status_code=500,
detail="监测点优化失败,请稍后重试",
) from exc
@router.get(
"/sensor-placement-schemes/{scheme_id}",
response_model=SensorPlacementSchemeResponse,
summary="获取监测点方案详情",
)
async def get_sensor_placement_scheme_detail(
scheme_id: int,
network: str = Query(..., min_length=1),
project_context: ProjectContext = Depends(get_project_context),
current_user=Depends(get_current_metadata_user),
) -> dict[str, Any]:
return _get_scheme_response(
_project_network(network, project_context),
scheme_id,
current_user,
project_context,
)
@router.put(
"/sensor-placement-schemes/{scheme_id}",
response_model=SensorPlacementSchemeResponse,
summary="覆盖保存监测点方案",
)
async def overwrite_sensor_placement_scheme(
scheme_id: int,
payload: SensorPlacementUpdateRequest,
network: str = Query(..., min_length=1),
project_context: ProjectContext = Depends(get_project_context),
current_user=Depends(get_current_metadata_user),
) -> dict[str, Any]:
network = _project_network(network, project_context)
_require_project_write(project_context)
scheme = _get_scheme_response(
network,
scheme_id,
current_user,
project_context,
)
if not scheme["can_edit"]:
raise HTTPException(status_code=403, detail="无权修改该监测点方案")
try:
updated = update_sensor_placement_scheme(
network,
scheme_id,
expected_sensor_location=payload.expected_sensor_location,
sensor_location=payload.sensor_location,
)
return {**updated, "can_edit": True}
except (
SensorPlacementConflictError,
SensorPlacementNotFoundError,
SensorPlacementValidationError,
) as exc:
raise _service_http_error(exc) from exc
@router.post(
"/sensor-placement-schemes/{scheme_id}/exports/excel",
summary="导出监测点工程清单",
)
async def export_sensor_placement_excel(
scheme_id: int,
payload: SensorPlacementExportRequest,
network: str = Query(..., min_length=1),
project_context: ProjectContext = Depends(get_project_context),
current_user=Depends(get_current_metadata_user),
) -> StreamingResponse:
network = _project_network(network, project_context)
scheme = _get_scheme_response(
network,
scheme_id,
current_user,
project_context,
)
if (
payload.sensor_location != scheme["sensor_location"]
and not scheme["can_edit"]
):
raise HTTPException(status_code=403, detail="无权导出该方案的未保存草稿")
try:
workbook = await run_in_threadpool(
build_sensor_placement_workbook,
network=network,
scheme=scheme,
sensor_location=payload.sensor_location,
adjustment_status=payload.adjustment_status,
)
except SensorPlacementValidationError as exc:
raise _service_http_error(exc) from exc
filename = f"{scheme['scheme_name']}_监测点清单.xlsx"
encoded_filename = quote(filename)
return StreamingResponse(
workbook,
media_type=(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
),
headers={
"Content-Disposition": (
f"attachment; filename*=UTF-8''{encoded_filename}"
)
},
)
+31 -72
View File
@@ -1,11 +1,10 @@
from typing import Any, List, Optional
from datetime import datetime, timedelta
import json
import os
import shutil
import threading
from fastapi import APIRouter, HTTPException, File, UploadFile, Query, Path, Body
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
from fastapi.responses import PlainTextResponse
from app.auth.keycloak_dependencies import get_current_keycloak_username
import app.services.simulation as simulation
import app.services.globals as globals
from app.services.tjnetwork import (
@@ -28,7 +27,6 @@ from app.algorithms.sensor import (
pressure_sensor_placement_kmeans,
)
from app.services.network_import import network_update
from app.services.simulation_ops import (
project_management,
scheduling_simulation,
@@ -141,7 +139,7 @@ def run_simulation_manually_by_date(
# 必须用这个PlainTextResponse,不然每个key都有引号
@router.get("/runproject/", response_class=PlainTextResponse, summary="运行项目模拟", description="基于指定的管网项目运行标准水力模拟,返回纯文本格式的模拟报告。")
@router.post("/project-runs", response_class=PlainTextResponse, summary="运行项目模拟", description="基于指定的管网项目运行标准水力模拟,返回纯文本格式的模拟报告。")
async def run_project_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> str:
"""
运行项目模拟
@@ -157,7 +155,7 @@ async def run_project_endpoint(network: str = Query(..., description="管网名
# output 和 report
# output 是 json
# report 是 text
@router.get("/runprojectreturndict/", summary="运行项目模拟(返回字典)", description="基于指定的管网项目运行标准水力模拟,返回JSON格式的字典,包含输出数据和报告文本。")
@router.post("/project-return-dict-runs", summary="运行项目模拟(返回字典)", description="基于指定的管网项目运行标准水力模拟,返回JSON格式的字典,包含输出数据和报告文本。")
async def run_project_return_dict_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""
运行项目模拟返回字典
@@ -174,7 +172,7 @@ async def run_project_return_dict_endpoint(network: str = Query(..., description
# put in inp folder, name without extension
@router.get("/runinp/", summary="运行INP文件", description="运行指定INP文件格式的管网模型进行水力模拟。INP文件应该放在inp文件夹中,参数为文件名不含扩展名。")
@router.post("/inp-runs", summary="运行INP文件", description="运行指定INP文件格式的管网模型进行水力模拟。INP文件应该放在inp文件夹中,参数为文件名不含扩展名。")
async def run_inp_endpoint(network: str = Query(..., description="inp文件名(不含扩展名)")) -> str:
"""
运行INP文件
@@ -187,7 +185,7 @@ async def run_inp_endpoint(network: str = Query(..., description="inp文件名
# path is absolute path
@router.get("/dumpoutput/", summary="导出模拟输出", description="导出指定路径的模拟输出文件内容。参数应为绝对路径。")
@router.get("/outputs", summary="导出模拟输出", description="导出指定路径的模拟输出文件内容。参数应为绝对路径。")
async def dump_output_endpoint(output: str = Query(..., description="模拟输出文件的绝对路径")) -> str:
"""
导出模拟输出
@@ -200,8 +198,7 @@ async def dump_output_endpoint(output: str = Query(..., description="模拟输
# Analysis Endpoints
@router.get("/burst-analysis", summary="爆管分析(高级)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。")
@router.get("/burst_analysis/", summary="爆管分析(高级,旧路径)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。", deprecated=True)
@router.post("/burst-analyses", summary="爆管分析(高级)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。")
async def fastapi_burst_analysis(
network: str = Query(..., description="管网名称(或数据库名称)"),
modify_pattern_start_time: str = Query(..., description="模式修改开始时间(ISO 8601格式)"),
@@ -209,6 +206,7 @@ async def fastapi_burst_analysis(
burst_size: list[float] = Query(..., description="对应各爆管点的爆管流量大小列表(L/s)"),
modify_total_duration: int = Query(..., description="模拟总时长(秒)"),
scheme_name: str = Query(..., description="分析方案名称"),
username: str = Depends(get_current_keycloak_username),
) -> str:
"""
爆管分析高级版本
@@ -229,11 +227,12 @@ async def fastapi_burst_analysis(
burst_size=burst_size,
modify_total_duration=modify_total_duration,
scheme_name=scheme_name,
username=username,
)
return "success"
@router.get("/valve_close_analysis/", response_class=PlainTextResponse, summary="阀门关闭分析(高级)", description="高级版本的阀门关闭分析,支持同时关闭多个阀门,并在指定持续时间内进行模拟。返回纯文本格式的分析结果。")
@router.post("/valve-closure-analyses", response_class=PlainTextResponse, summary="阀门关闭分析(高级)", description="高级版本的阀门关闭分析,支持同时关闭多个阀门,并在指定持续时间内进行模拟。返回纯文本格式的分析结果。")
async def fastapi_valve_close_analysis(
network: str = Query(..., description="管网名称(或数据库名称)"),
start_time: str = Query(..., description="阀门关闭开始时间(ISO 8601格式)"),
@@ -262,8 +261,7 @@ async def fastapi_valve_close_analysis(
return result or "success"
@router.get("/valve-isolation-analysis", summary="阀门隔离分析", description="分析当发生突发事件时,通过关闭指定阀门进行隔离,确定哪些阀门必须关闭、哪些可选关闭,以及隔离的可行性。")
@router.get("/valve_isolation_analysis/", summary="阀门隔离分析(旧路径)", description="分析当发生突发事件时,通过关闭指定阀门进行隔离,确定哪些阀门必须关闭、哪些可选关闭,以及隔离的可行性。", deprecated=True)
@router.post("/valve-isolation-analyses", summary="阀门隔离分析", description="分析当发生突发事件时,通过关闭指定阀门进行隔离,确定哪些阀门必须关闭、哪些可选关闭,以及隔离的可行性。")
async def valve_isolation_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
accident_element: List[str] = Query(..., description="发生事故的管段/节点ID列表"),
@@ -279,7 +277,8 @@ async def valve_isolation_endpoint(
返回隔离方案包括
- must_close_valves: 必须关闭的阀门列表
- optional_valves: 可选关闭的阀门列表
- affected_nodes: 受影响的节点列表
- affected_nodes: 受影响的节点列表不可隔离时为空列表
- affected_node_count: 受影响的节点总数
- isolatable: 是否可以有效隔离
"""
# result = {
@@ -303,8 +302,7 @@ async def valve_isolation_endpoint(
return result
@router.get("/flushing-analysis", response_class=PlainTextResponse, summary="冲洗分析(高级)", description="高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。")
@router.get("/flushing_analysis/", response_class=PlainTextResponse, summary="冲洗分析(高级,旧路径)", description="高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。", deprecated=True)
@router.post("/flushing-analyses", response_class=PlainTextResponse, summary="冲洗分析(高级)", description="高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。")
async def fastapi_flushing_analysis(
network: str = Query(..., description="管网名称(或数据库名称)"),
start_time: str = Query(..., description="冲洗开始时间(ISO 8601格式)"),
@@ -314,6 +312,7 @@ async def fastapi_flushing_analysis(
flush_flow: float = Query(0, description="冲洗流量(L/s),0表示自动计算"),
duration: int | None = Query(None, description="模拟持续时间(秒),默认900秒"),
scheme_name: str = Query(..., description="冲洗方案名称"),
username: str = Depends(get_current_keycloak_username),
) -> str:
"""
冲洗分析高级版本
@@ -340,12 +339,12 @@ async def fastapi_flushing_analysis(
drainage_node_ID=drainage_node_ID,
flushing_flow=flush_flow,
scheme_name=scheme_name,
username=username,
)
return result or "success"
@router.get("/contaminant-simulation", response_class=PlainTextResponse, summary="污染物模拟", description="对管网中的污染物扩散进行模拟,评估污染源对管网的影响范围和浓度分布。支持指定污染源位置、污染浓度和扩散模式。")
@router.get("/contaminant_simulation/", response_class=PlainTextResponse, summary="污染物模拟(旧路径)", description="对管网中的污染物扩散进行模拟,评估污染源对管网的影响范围和浓度分布。支持指定污染源位置、污染浓度和扩散模式。", deprecated=True)
@router.post("/contaminant-simulations", response_class=PlainTextResponse, summary="污染物模拟", description="对管网中的污染物扩散进行模拟,评估污染源对管网的影响范围和浓度分布。支持指定污染源位置、污染浓度和扩散模式。")
async def fastapi_contaminant_simulation(
network: str = Query(..., description="管网名称(或数据库名称)"),
start_time: str = Query(..., description="污染开始时间(ISO 8601格式)"),
@@ -354,6 +353,7 @@ async def fastapi_contaminant_simulation(
duration: int = Query(..., description="模拟持续时间(秒)"),
scheme_name: str = Query(..., description="模拟方案名称"),
pattern: str | None = Query(None, description="污染源模式ID(可选)"),
username: str = Depends(get_current_keycloak_username),
) -> str:
"""
污染物模拟
@@ -376,11 +376,12 @@ async def fastapi_contaminant_simulation(
source=source,
concentration=concentration,
source_pattern=pattern,
username=username,
)
return result or "success"
@router.get("/age_analysis/", response_class=PlainTextResponse, summary="水龄分析(高级)", description="高级版本的水龄分析,在指定时间点进行分析,支持自定义模拟持续时间。返回纯文本格式的分析结果。")
@router.post("/water-age-analyses", response_class=PlainTextResponse, summary="水龄分析(高级)", description="高级版本的水龄分析,在指定时间点进行分析,支持自定义模拟持续时间。返回纯文本格式的分析结果。")
async def fastapi_age_analysis(
network: str = Query(..., description="管网名称(或数据库名称)"),
start_time: str = Query(..., description="分析开始时间(ISO 8601格式)"),
@@ -404,7 +405,7 @@ async def fastapi_age_analysis(
# return scheduling_analysis(network)
@router.get("/pressureregulation/", summary="压力调节(基础)", description="对管网的压力进行调节分析,通过控制泵的运行来维持目标节点的目标压力。此为基础版本。")
@router.post("/pressure-regulation-calculations", summary="压力调节(基础)", description="对管网的压力进行调节分析,通过控制泵的运行来维持目标节点的目标压力。此为基础版本。")
async def pressure_regulation_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
target_node: str = Query(..., description="目标节点ID"),
@@ -422,7 +423,7 @@ async def pressure_regulation_endpoint(
return pressure_regulation(network, target_node, target_pressure)
@router.post("/pressure_regulation/", summary="压力调节(高级)", description="高级版本的压力调节分析,通过JSON请求体提供详细的控制参数,包括固定泵和变速泵的独立控制、水箱初始水位等。")
@router.post("/pressure-regulation-analyses", summary="压力调节(高级)", description="高级版本的压力调节分析,通过JSON请求体提供详细的控制参数,包括固定泵和变速泵的独立控制、水箱初始水位等。")
async def fastapi_pressure_regulation(data: PressureRegulation = Body(..., description="压力调节控制参数")) -> str:
"""
压力调节高级版本
@@ -460,7 +461,7 @@ async def fastapi_pressure_regulation(data: PressureRegulation = Body(..., descr
return "success"
@router.post("/project_management/", summary="项目管理(高级)", description="高级版本的项目管理,通过JSON请求体提供详细的控制参数,包括泵控制策略、水箱初始水位和区域需水量控制。")
@router.post("/project-managements", summary="项目管理(高级)", description="高级版本的项目管理,通过JSON请求体提供详细的控制参数,包括泵控制策略、水箱初始水位和区域需水量控制。")
async def fastapi_project_management(data: ProjectManagement = Body(..., description="项目管理控制参数")) -> str:
"""
项目管理高级版本
@@ -489,7 +490,7 @@ async def fastapi_project_management(data: ProjectManagement = Body(..., descrip
# return daily_scheduling_analysis(network)
@router.post("/scheduling_analysis/", summary="排程分析", description="对管网的供水排程进行分析,优化泵的运行时间和出水流量,平衡水厂出水、水箱进出水,满足用户需求。")
@router.post("/scheduling-analyses", summary="排程分析", description="对管网的供水排程进行分析,优化泵的运行时间和出水流量,平衡水厂出水、水箱进出水,满足用户需求。")
async def fastapi_scheduling_analysis(data: SchedulingAnalysis = Body(..., description="排程分析参数")) -> str:
"""
排程分析
@@ -515,7 +516,7 @@ async def fastapi_scheduling_analysis(data: SchedulingAnalysis = Body(..., descr
)
@router.post("/daily_scheduling_analysis/", summary="日排程分析", description="对管网的每日供水排程进行分析,优化水库、水厂、水箱和用户需求的协调,制定合理的每日排程方案。")
@router.post("/daily-scheduling-analyses", summary="日排程分析", description="对管网的每日供水排程进行分析,优化水库、水厂、水箱和用户需求的协调,制定合理的每日排程方案。")
async def fastapi_daily_scheduling_analysis(data: DailySchedulingAnalysis = Body(..., description="日排程分析参数")) -> str:
"""
日排程分析
@@ -542,52 +543,12 @@ async def fastapi_daily_scheduling_analysis(data: DailySchedulingAnalysis = Body
)
@router.post("/network_project/", summary="导入网络项目", description="通过上传INP格式的管网文件导入新的网络项目。系统将自动处理文件并执行模拟。")
async def fastapi_network_project(file: UploadFile = File(..., description="INP格式的管网文件")) -> str:
"""
导入网络项目
- **file**: 上传的INP格式管网文件
系统将上传的文件保存到inp文件夹并执行模拟
"""
temp_file_dir = "./inp/"
if not os.path.exists(temp_file_dir):
os.mkdir(temp_file_dir)
temp_file_name = f'network_project_{datetime.now().strftime("%Y%m%d")}'
temp_file_path = f"{temp_file_dir}{temp_file_name}.inp"
with open(temp_file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
return run_inp(temp_file_name)
@router.post("/network_update/", summary="管网更新(高级)", description="通过上传更新文件对管网进行高级的更新操作。系统将处理更新文件并应用到数据库。")
async def fastapi_network_update(file: UploadFile = File(..., description="包含管网更新信息的文件")) -> str:
"""
管网更新高级版本
- **file**: 包含管网更新信息的文件
系统将处理上传的文件并应用管网更新
"""
default_folder = "./"
temp_file_name = f'network_update_{datetime.now().strftime("%Y%m%d")}'
temp_file_path = os.path.join(default_folder, temp_file_name)
try:
with open(temp_file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
network_update(temp_file_path)
return json.dumps({"message": "管网更新成功"})
except Exception as exc:
raise HTTPException(status_code=500, detail=f"数据库操作失败: {exc}")
# @router.get("/pumpfailure/")
# async def pump_failure_endpoint(network: str, pump_id: str, time: str):
# return pump_failure(network, pump_id, time)
@router.post("/pump_failure/", summary="泵故障管理", description="记录和管理泵的故障状态,包括故障发生时间和受影响的泵列表。系统将记录故障日志并更新泵状态。")
@router.post("/pump-failure-events", summary="泵故障管理", description="记录和管理泵的故障状态,包括故障发生时间和受影响的泵列表。系统将记录故障日志并更新泵状态。")
async def fastapi_pump_failure(data: PumpFailureState = Body(..., description="泵故障状态信息")) -> str:
"""
泵故障管理
@@ -631,7 +592,7 @@ async def fastapi_pump_failure(data: PumpFailureState = Body(..., description="
return json.dumps("SUCCESS")
@router.get("/pressuresensorplacementsensitivity/", summary="压力传感器放置-灵敏度分析(基础)", description="基于灵敏度分析方法,为指定管网项目确定最优的压力传感器放置位置。此为基础版本。")
@router.post("/pressure-sensor-placement-sensitivity-calculations", summary="压力传感器放置-灵敏度分析(基础)", description="基于灵敏度分析方法,为指定管网项目确定最优的压力传感器放置位置。此为基础版本。")
async def pressure_sensor_placement_sensitivity_endpoint(
name: str = Query(..., description="管网名称(或数据库名称)"),
scheme_name: str = Query(..., description="放置方案名称"),
@@ -655,7 +616,7 @@ async def pressure_sensor_placement_sensitivity_endpoint(
)
@router.post("/pressure_sensor_placement_sensitivity/", summary="压力传感器放置-灵敏度分析(高级)", description="高级版本的压力传感器放置分析,通过JSON请求体提供详细参数。基于灵敏度分析方法确定最优放置位置。")
@router.post("/pressure-sensor-placement-sensitivities", summary="压力传感器放置-灵敏度分析(高级)", description="高级版本的压力传感器放置分析,通过JSON请求体提供详细参数。基于灵敏度分析方法确定最优放置位置。")
async def fastapi_pressure_sensor_placement_sensitivity(
data: PressureSensorPlacement = Body(..., description="传感器放置分析参数"),
) -> None:
@@ -681,7 +642,7 @@ async def fastapi_pressure_sensor_placement_sensitivity(
)
@router.get("/pressuresensorplacementkmeans/", summary="压力传感器放置-KMeans聚类分析(基础)", description="基于KMeans聚类算法,为指定管网项目确定压力传感器的最优放置位置。此为基础版本。")
@router.post("/pressure-sensor-placement-kmeans-calculations", summary="压力传感器放置-KMeans聚类分析(基础)", description="基于KMeans聚类算法,为指定管网项目确定压力传感器的最优放置位置。此为基础版本。")
async def pressure_sensor_placement_kmeans_endpoint(
name: str = Query(..., description="管网名称(或数据库名称)"),
scheme_name: str = Query(..., description="放置方案名称"),
@@ -705,7 +666,7 @@ async def pressure_sensor_placement_kmeans_endpoint(
)
@router.post("/pressure_sensor_placement_kmeans/", summary="压力传感器放置-KMeans聚类分析(高级)", description="高级版本的压力传感器放置分析,通过JSON请求体提供详细参数。基于KMeans聚类算法确定最优放置位置。")
@router.post("/pressure-sensor-placement-kmeans", summary="压力传感器放置-KMeans聚类分析(高级)", description="高级版本的压力传感器放置分析,通过JSON请求体提供详细参数。基于KMeans聚类算法确定最优放置位置。")
async def fastapi_pressure_sensor_placement_kmeans(
data: PressureSensorPlacement = Body(..., description="传感器放置分析参数"),
) -> None:
@@ -732,7 +693,6 @@ async def fastapi_pressure_sensor_placement_kmeans(
@router.post("/sensor-placement-schemes", summary="传感器放置方案创建", description="创建新的传感器放置方案,支持灵敏度分析和KMeans聚类两种方法。根据指定的方法自动计算最优的传感器放置位置。")
@router.post("/sensorplacementscheme/create", summary="传感器放置方案创建(旧路径)", description="创建新的传感器放置方案,支持灵敏度分析和KMeans聚类两种方法。根据指定的方法自动计算最优的传感器放置位置。", deprecated=True)
async def fastapi_pressure_sensor_placement(
network: str = Query(..., description="管网名称(或数据库名称)"),
scheme_name: str = Query(..., description="放置方案名称"),
@@ -780,8 +740,7 @@ async def fastapi_pressure_sensor_placement(
return "success"
@router.post("/simulations/run-by-date", summary="手动运行日期指定模拟", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。")
@router.post("/runsimulationmanuallybydate/", summary="手动运行日期指定模拟(旧路径)", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。", deprecated=True)
@router.post("/simulation-runs", summary="手动运行日期指定模拟", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。")
async def fastapi_run_simulation_manually_by_date(
data: RunSimulationManuallyByDate = Body(..., description="模拟运行参数"),
) -> dict[str, str]:
+24 -29
View File
@@ -1,4 +1,5 @@
from fastapi import APIRouter, Request, Query
from fastapi import APIRouter, Depends, Request, Query
from app.auth.permissions import SIMULATION_RUN, require_permission
from app.services.tjnetwork import (
ChangeSet,
get_current_operation,
@@ -22,7 +23,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/getcurrentoperationid/", summary="获取当前操作ID", description="获取网络当前的操作ID")
@router.get("/current-operation-ids", summary="获取当前操作ID", description="获取网络当前的操作ID")
async def get_current_operation_id_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> int:
"""
获取当前操作ID
@@ -31,7 +32,7 @@ async def get_current_operation_id_endpoint(network: str = Query(..., descriptio
"""
return get_current_operation(network)
@router.post("/undo/", summary="撤销操作", description="撤销网络上最后的一个操作")
@router.post("/undos", summary="撤销操作", description="撤销网络上最后的一个操作")
async def undo_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")):
"""
撤销操作
@@ -40,7 +41,7 @@ async def undo_endpoint(network: str = Query(..., description="管网名称(
"""
return execute_undo(network)
@router.post("/redo/", summary="重做操作", description="重做网络上被撤销的操作")
@router.post("/redos", summary="重做操作", description="重做网络上被撤销的操作")
async def redo_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")):
"""
重做操作
@@ -49,7 +50,7 @@ async def redo_endpoint(network: str = Query(..., description="管网名称(
"""
return execute_redo(network)
@router.get("/getsnapshots/", summary="获取快照列表", description="获取网络中的所有快照")
@router.get("/snapshots", summary="获取快照列表", description="获取网络中的所有快照")
async def list_snapshot_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[tuple[int, str]]:
"""
获取快照列表
@@ -58,7 +59,7 @@ async def list_snapshot_endpoint(network: str = Query(..., description="管网
"""
return list_snapshot(network)
@router.get("/havesnapshot/", summary="检查快照是否存在", description="检查指定标签的快照是否存在")
@router.get("/snapshots/existence", summary="检查快照是否存在", description="检查指定标签的快照是否存在")
async def have_snapshot_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), tag: str = Query(..., description="快照标签")) -> bool:
"""
检查快照是否存在
@@ -67,7 +68,7 @@ async def have_snapshot_endpoint(network: str = Query(..., description="管网
"""
return have_snapshot(network, tag)
@router.get("/havesnapshotforoperation/", summary="检查操作快照是否存在", description="检查指定操作ID的快照是否存在")
@router.get("/snapshot-for-operations", summary="检查操作快照是否存在", description="检查指定操作ID的快照是否存在")
async def have_snapshot_for_operation_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), operation: int = Query(..., description="操作ID")) -> bool:
"""
检查操作快照是否存在
@@ -76,7 +77,7 @@ async def have_snapshot_for_operation_endpoint(network: str = Query(..., descrip
"""
return have_snapshot_for_operation(network, operation)
@router.get("/havesnapshotforcurrentoperation/", summary="检查当前操作快照是否存在", description="检查当前操作的快照是否存在")
@router.get("/snapshot-for-current-operations", summary="检查当前操作快照是否存在", description="检查当前操作的快照是否存在")
async def have_snapshot_for_current_operation_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> bool:
"""
检查当前操作快照是否存在
@@ -85,7 +86,7 @@ async def have_snapshot_for_current_operation_endpoint(network: str = Query(...,
"""
return have_snapshot_for_current_operation(network)
@router.post("/takesnapshotforoperation/", summary="为操作创建快照", description="为指定的操作创建快照")
@router.post("/snapshot-for-operations", summary="为操作创建快照", description="为指定的操作创建快照")
async def take_snapshot_for_operation_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
operation: int = Query(..., description="操作ID"),
@@ -98,7 +99,7 @@ async def take_snapshot_for_operation_endpoint(
"""
return take_snapshot_for_operation(network, operation, tag)
@router.post("/takesnapshotforcurrentoperation", summary="为当前操作创建快照", description="为当前操作创建快照")
@router.post("/snapshot-for-current-operations", summary="为当前操作创建快照", description="为当前操作创建快照")
async def take_snapshot_for_current_operation_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), tag: str = Query(..., description="快照标签")) -> None:
"""
为当前操作创建快照
@@ -107,17 +108,7 @@ async def take_snapshot_for_current_operation_endpoint(network: str = Query(...,
"""
return take_snapshot_for_current_operation(network, tag)
# 兼容旧拼写: takenapshotforcurrentoperation
@router.post("/takenapshotforcurrentoperation", summary="为当前操作创建快照(兼容模式)", description="为当前操作创建快照(兼容旧的API路径)")
async def take_snapshot_for_current_operation_legacy_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), tag: str = Query(..., description="快照标签")) -> None:
"""
为当前操作创建快照兼容模式
兼容旧的API路径为网络当前操作创建一个快照
"""
return take_snapshot_for_current_operation(network, tag)
@router.post("/takesnapshot/", summary="创建快照", description="为网络创建一个快照")
@router.post("/snapshots", summary="创建快照", description="为网络创建一个快照")
async def take_snapshot_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), tag: str = Query(..., description="快照标签")) -> None:
"""
创建快照
@@ -126,7 +117,7 @@ async def take_snapshot_endpoint(network: str = Query(..., description="管网
"""
return take_snapshot(network, tag)
@router.post("/picksnapshot/", summary="选择快照", description="选择并恢复到指定的快照", response_model=None)
@router.patch("/snapshots", summary="选择快照", description="选择并恢复到指定的快照", response_model=None)
async def pick_snapshot_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), tag: str = Query(..., description="快照标签"), discard: bool = Query(False, description="是否丢弃当前更改")) -> ChangeSet:
"""
选择快照
@@ -135,7 +126,7 @@ async def pick_snapshot_endpoint(network: str = Query(..., description="管网
"""
return pick_snapshot(network, tag, discard)
@router.post("/pickoperation/", summary="选择操作", description="选择并恢复到指定的操作", response_model=None)
@router.patch("/operations", summary="选择操作", description="选择并恢复到指定的操作", response_model=None)
async def pick_operation_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
operation: int = Query(..., description="操作ID"),
@@ -148,8 +139,12 @@ async def pick_operation_endpoint(
"""
return pick_operation(network, operation, discard)
@router.get("/syncwithserver/", summary="与服务器同步", description="将网络与服务器同步到指定操作", response_model=None)
async def sync_with_server_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), operation: int = Query(..., description="目标操作ID")) -> ChangeSet:
@router.post("/with-servers", summary="与服务器同步", description="将网络与服务器同步到指定操作", response_model=None)
async def sync_with_server_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
operation: int = Query(..., description="目标操作ID"),
_=Depends(require_permission(SIMULATION_RUN)),
) -> ChangeSet:
"""
与服务器同步
@@ -157,7 +152,7 @@ async def sync_with_server_endpoint(network: str = Query(..., description="管
"""
return sync_with_server(network, operation)
@router.post("/batch/", summary="执行批量命令", description="执行多个网络操作命令", response_model=None)
@router.post("/network-command-batches", summary="执行批量命令", description="执行多个网络操作命令", response_model=None)
async def execute_batch_commands_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None) -> ChangeSet:
"""
执行批量命令
@@ -170,7 +165,7 @@ async def execute_batch_commands_endpoint(network: str = Query(..., description=
rcs = execute_batch_commands(network, cs)
return rcs
@router.post("/compressedbatch/", summary="执行压缩批量命令", description="执行压缩的批量命令", response_model=None)
@router.post("/network-command-batches/compressed", summary="执行压缩批量命令", description="执行压缩的批量命令", response_model=None)
async def execute_compressed_batch_commands_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
@@ -185,7 +180,7 @@ async def execute_compressed_batch_commands_endpoint(
cs.operations = jo_root["operations"]
return execute_batch_command(network, cs)
@router.get("/getrestoreoperation/", summary="获取恢复操作ID", description="获取网络的恢复操作ID")
@router.get("/restore-operations", summary="获取恢复操作ID", description="获取网络的恢复操作ID")
async def get_restore_operation_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> int:
"""
获取恢复操作ID
@@ -194,7 +189,7 @@ async def get_restore_operation_endpoint(network: str = Query(..., description="
"""
return get_restore_operation(network)
@router.post("/setrestoreoperation/", summary="设置恢复操作ID", description="设置网络的恢复操作ID")
@router.patch("/restore-operations", summary="设置恢复操作ID", description="设置网络的恢复操作ID")
async def set_restore_operation_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), operation: int = Query(..., description="操作ID")) -> None:
"""
设置恢复操作ID
+5 -5
View File
@@ -8,7 +8,7 @@ from .dependencies import get_timescale_connection, get_postgres_connection
router = APIRouter()
@router.get("/composite/scada-simulation", summary="获取SCADA关联的模拟数据")
@router.get("/timeseries/views/scada-simulations", summary="获取SCADA关联的模拟数据")
async def get_scada_associated_simulation_data(
start_time: datetime = Query(..., description="查询开始时间"),
end_time: datetime = Query(..., description="查询结束时间"),
@@ -73,7 +73,7 @@ async def get_scada_associated_simulation_data(
raise HTTPException(status_code=400, detail=str(e))
@router.get("/composite/element-simulation", summary="获取管网元素的模拟数据")
@router.get("/timeseries/views/element-simulations", summary="获取管网元素的模拟数据")
async def get_feature_simulation_data(
start_time: datetime = Query(..., description="查询开始时间"),
end_time: datetime = Query(..., description="查询结束时间"),
@@ -143,7 +143,7 @@ async def get_feature_simulation_data(
raise HTTPException(status_code=400, detail=str(e))
@router.get("/composite/element-scada", summary="获取管网元素关联的SCADA监测数据")
@router.get("/timeseries/views/element-scada-readings", summary="获取管网元素关联的SCADA监测数据")
async def get_element_associated_scada_data(
element_id: str = Query(..., description="管网元素ID(管道或节点)"),
start_time: datetime = Query(..., description="查询开始时间"),
@@ -185,7 +185,7 @@ async def get_element_associated_scada_data(
raise HTTPException(status_code=400, detail=str(e))
@router.post("/composite/clean-scada", summary="清洗SCADA监测数据")
@router.post("/timeseries/scada-cleaning-runs", summary="清洗SCADA监测数据")
async def clean_scada_data(
device_ids: str = Query(..., description="设备ID列表或 'all' 表示清洗所有设备"),
start_time: datetime = Query(..., description="清洗数据的开始时间"),
@@ -228,7 +228,7 @@ async def clean_scada_data(
raise HTTPException(status_code=400, detail=str(e))
@router.get("/composite/pipeline-health-prediction", summary="预测管道健康状况")
@router.get("/pipeline-health-predictions", summary="预测管道健康状况")
async def predict_pipeline_health(
query_time: datetime = Query(..., description="查询时间"),
network_name: str = Query(..., description="管网名称(或数据库名称)"),
+10 -10
View File
@@ -13,7 +13,7 @@ TIME_RANGE_START_DESC = f"时间范围开始时间。{TIME_WITH_TZ_DESC}"
TIME_RANGE_END_DESC = f"时间范围结束时间。{TIME_WITH_TZ_DESC}"
@router.post("/realtime/links/batch", status_code=201, summary="批量插入实时管道数据")
@router.post("/timeseries/realtime/links/batches", status_code=201, summary="批量插入实时管道数据")
async def insert_realtime_links(
data: List[dict] = Body(..., description="管道数据列表,每项包含管道ID、时间戳等信息"),
conn: AsyncConnection = Depends(get_timescale_connection)
@@ -34,7 +34,7 @@ async def insert_realtime_links(
@router.get(
"/realtime/links",
"/timeseries/realtime/links",
summary="查询实时管道数据",
description="按时间范围查询实时管道数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。",
)
@@ -60,7 +60,7 @@ async def get_realtime_links(
@router.delete(
"/realtime/links",
"/timeseries/realtime/links",
summary="删除实时管道数据",
description="按时间范围删除实时管道数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端按请求中的绝对时间删除对应 UTC 数据。",
)
@@ -85,7 +85,7 @@ async def delete_realtime_links(
return {"message": "Deleted successfully"}
@router.patch("/realtime/links/{link_id}/field", summary="更新实时管道字段")
@router.patch("/timeseries/realtime/links/{link_id}/field", summary="更新实时管道字段")
async def update_realtime_link_field(
link_id: str = Path(..., description="管道ID"),
time: datetime = Query(..., description=f"要更新记录的时间戳。{TIME_WITH_TZ_DESC}"),
@@ -117,7 +117,7 @@ async def update_realtime_link_field(
raise HTTPException(status_code=400, detail=str(e))
@router.post("/realtime/nodes/batch", status_code=201, summary="批量插入实时节点数据")
@router.post("/timeseries/realtime/nodes/batches", status_code=201, summary="批量插入实时节点数据")
async def insert_realtime_nodes(
data: List[dict] = Body(..., description="节点数据列表,每项包含节点ID、时间戳等信息"),
conn: AsyncConnection = Depends(get_timescale_connection)
@@ -138,7 +138,7 @@ async def insert_realtime_nodes(
@router.get(
"/realtime/nodes",
"/timeseries/realtime/nodes",
summary="查询实时节点数据",
description="按时间范围查询实时节点数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。",
)
@@ -164,7 +164,7 @@ async def get_realtime_nodes(
@router.delete(
"/realtime/nodes",
"/timeseries/realtime/nodes",
summary="删除实时节点数据",
description="按时间范围删除实时节点数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端按请求中的绝对时间删除对应 UTC 数据。",
)
@@ -191,7 +191,7 @@ async def delete_realtime_nodes(
@router.post("/realtime/simulation/store", status_code=201, summary="存储实时模拟结果")
@router.post("/timeseries/realtime/simulation-results", status_code=201, summary="存储实时模拟结果")
async def store_realtime_simulation_result(
node_result_list: List[dict] = Body(..., description="节点模拟结果列表"),
link_result_list: List[dict] = Body(..., description="管道模拟结果列表"),
@@ -218,7 +218,7 @@ async def store_realtime_simulation_result(
@router.get(
"/realtime/query/by-time-property",
"/timeseries/realtime/records",
summary="按时间和属性查询实时数据",
description="查询指定时间点的实时属性值。query_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。",
)
@@ -254,7 +254,7 @@ async def query_realtime_records_by_time_property(
@router.get(
"/realtime/query/by-id-time",
"/timeseries/realtime/simulation-results",
summary="按ID和时间查询实时模拟数据",
description="查询指定元素在某一时间点的实时模拟结果。query_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。",
)
+5 -5
View File
@@ -9,7 +9,7 @@ from .dependencies import get_timescale_connection
router = APIRouter()
@router.post("/scada/batch", status_code=201, summary="批量插入SCADA监测数据")
@router.post("/timeseries/scada-readings/batches", status_code=201, summary="批量插入SCADA监测数据")
async def insert_scada_data(
data: List[dict] = Body(..., description="SCADA设备监测数据列表"),
conn: AsyncConnection = Depends(get_timescale_connection),
@@ -29,7 +29,7 @@ async def insert_scada_data(
return {"message": f"Inserted {len(data)} records"}
@router.get("/scada/by-ids-time-range", summary="按设备ID和时间范围查询SCADA数据")
@router.get("/timeseries/scada-readings", summary="按设备ID和时间范围查询SCADA数据")
async def get_scada_by_ids_time_range(
start_time: datetime = Query(..., description="查询开始时间"),
end_time: datetime = Query(..., description="查询结束时间"),
@@ -60,7 +60,7 @@ async def get_scada_by_ids_time_range(
@router.get(
"/scada/by-ids-field-time-range", summary="按设备ID、字段和时间范围查询SCADA数据"
"/timeseries/scada-readings/fields", summary="按设备ID、字段和时间范围查询SCADA数据"
)
async def get_scada_field_by_ids_time_range(
start_time: datetime = Query(..., description="查询开始时间"),
@@ -101,7 +101,7 @@ async def get_scada_field_by_ids_time_range(
raise HTTPException(status_code=400, detail=str(e))
@router.patch("/scada/{device_id}/field", summary="更新SCADA设备字段")
@router.patch("/timeseries/scada-readings/{device_id}/field", summary="更新SCADA设备字段")
async def update_scada_field(
device_id: str = Path(..., description="设备ID"),
time: datetime = Query(..., description="更新数据的时间戳"),
@@ -133,7 +133,7 @@ async def update_scada_field(
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/scada/by-id-time-range", summary="按设备ID和时间范围删除SCADA数据")
@router.delete("/timeseries/scada-readings", summary="按设备ID和时间范围删除SCADA数据")
async def delete_scada_data(
device_id: str = Query(..., description="设备ID"),
start_time: datetime = Query(..., description="删除开始时间"),
+12 -12
View File
@@ -9,7 +9,7 @@ from .dependencies import get_timescale_connection
router = APIRouter()
@router.post("/scheme/links/batch", status_code=201, summary="批量插入方案管道数据")
@router.post("/timeseries/schemes/links/batches", status_code=201, summary="批量插入方案管道数据")
async def insert_scheme_links(
data: List[dict] = Body(..., description="方案管道数据列表"),
conn: AsyncConnection = Depends(get_timescale_connection),
@@ -29,7 +29,7 @@ async def insert_scheme_links(
return {"message": f"Inserted {len(data)} records"}
@router.get("/scheme/links", summary="查询方案管道数据")
@router.get("/timeseries/schemes/links", summary="查询方案管道数据")
async def get_scheme_links(
scheme_type: str = Query(..., description="方案类型"),
scheme_name: str = Query(..., description="方案名称"),
@@ -56,7 +56,7 @@ async def get_scheme_links(
)
@router.get("/scheme/links/{link_id}/field", summary="查询方案管道字段数据")
@router.get("/timeseries/schemes/links/{link_id}/field", summary="查询方案管道字段数据")
async def get_scheme_link_field(
link_id: str = Path(..., description="管道ID"),
scheme_type: str = Query(..., description="方案类型"),
@@ -93,7 +93,7 @@ async def get_scheme_link_field(
raise HTTPException(status_code=400, detail=str(e))
@router.patch("/scheme/links/{link_id}/field", summary="更新方案管道字段")
@router.patch("/timeseries/schemes/links/{link_id}/field", summary="更新方案管道字段")
async def update_scheme_link_field(
link_id: str = Path(..., description="管道ID"),
scheme_type: str = Query(..., description="方案类型"),
@@ -131,7 +131,7 @@ async def update_scheme_link_field(
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/scheme/links", summary="删除方案管道数据")
@router.delete("/timeseries/schemes/links", summary="删除方案管道数据")
async def delete_scheme_links(
scheme_type: str = Query(..., description="方案类型"),
scheme_name: str = Query(..., description="方案名称"),
@@ -159,7 +159,7 @@ async def delete_scheme_links(
return {"message": "Deleted successfully"}
@router.post("/scheme/nodes/batch", status_code=201, summary="批量插入方案节点数据")
@router.post("/timeseries/schemes/nodes/batches", status_code=201, summary="批量插入方案节点数据")
async def insert_scheme_nodes(
data: List[dict] = Body(..., description="方案节点数据列表"),
conn: AsyncConnection = Depends(get_timescale_connection),
@@ -179,7 +179,7 @@ async def insert_scheme_nodes(
return {"message": f"Inserted {len(data)} records"}
@router.get("/scheme/nodes/{node_id}/field", summary="查询方案节点字段数据")
@router.get("/timeseries/schemes/nodes/{node_id}/field", summary="查询方案节点字段数据")
async def get_scheme_node_field(
node_id: str = Path(..., description="节点ID"),
scheme_type: str = Query(..., description="方案类型"),
@@ -216,7 +216,7 @@ async def get_scheme_node_field(
raise HTTPException(status_code=400, detail=str(e))
@router.patch("/scheme/nodes/{node_id}/field", summary="更新方案节点字段")
@router.patch("/timeseries/schemes/nodes/{node_id}/field", summary="更新方案节点字段")
async def update_scheme_node_field(
node_id: str = Path(..., description="节点ID"),
scheme_type: str = Query(..., description="方案类型"),
@@ -254,7 +254,7 @@ async def update_scheme_node_field(
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/scheme/nodes", summary="删除方案节点数据")
@router.delete("/timeseries/schemes/nodes", summary="删除方案节点数据")
async def delete_scheme_nodes(
scheme_type: str = Query(..., description="方案类型"),
scheme_name: str = Query(..., description="方案名称"),
@@ -282,7 +282,7 @@ async def delete_scheme_nodes(
return {"message": "Deleted successfully"}
@router.post("/scheme/simulation/store", status_code=201, summary="存储方案模拟结果")
@router.post("/timeseries/schemes/simulation-results", status_code=201, summary="存储方案模拟结果")
async def store_scheme_simulation_result(
scheme_type: str = Query(..., description="方案类型"),
scheme_name: str = Query(..., description="方案名称"),
@@ -318,7 +318,7 @@ async def store_scheme_simulation_result(
@router.get(
"/scheme/query/by-scheme-time-property", summary="按方案、时间和属性查询数据"
"/timeseries/schemes/records", summary="按方案、时间和属性查询数据"
)
async def query_scheme_records_by_scheme_time_property(
scheme_type: str = Query(..., description="方案类型"),
@@ -355,7 +355,7 @@ async def query_scheme_records_by_scheme_time_property(
raise HTTPException(status_code=400, detail=str(e))
@router.get("/scheme/query/by-id-time", summary="按ID和时间查询方案模拟数据")
@router.get("/timeseries/schemes/simulation-results", summary="按ID和时间查询方案模拟数据")
async def query_scheme_simulation_by_id_time(
scheme_type: str = Query(..., description="方案类型"),
scheme_name: str = Query(..., description="方案名称"),
+3 -3
View File
@@ -8,7 +8,7 @@ router = APIRouter()
# user 39
###########################################################
@router.get("/getuserschema/", summary="获取用户模式", description="获取指定网络的用户模式定义")
@router.get("/network-schemas/user", summary="获取用户模式", description="获取指定网络的用户模式定义")
async def fastapi_get_user_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[Any, Any]]:
"""
获取用户模式定义
@@ -17,7 +17,7 @@ async def fastapi_get_user_schema(network: str = Query(..., description="管网
"""
return get_user_schema(network)
@router.get("/getuser/", summary="获取单个用户", description="获取指定网络中的单个用户信息")
@router.get("/users/detail", summary="获取单个用户", description="获取指定网络中的单个用户信息")
async def fastapi_get_user(network: str = Query(..., description="管网名称(或数据库名称)"), user_name: str = Query(..., description="用户名")) -> dict[Any, Any]:
"""
获取用户信息
@@ -26,7 +26,7 @@ async def fastapi_get_user(network: str = Query(..., description="管网名称
"""
return get_user(network, user_name)
@router.get("/getallusers/", summary="获取所有用户", description="获取指定网络的所有用户列表")
@router.get("/users", summary="获取所有用户", description="获取指定网络的所有用户列表")
async def fastapi_get_all_users(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[Any, Any]]:
"""
获取所有用户列表
+1 -1
View File
@@ -13,7 +13,7 @@ router = APIRouter()
@router.post(
"/web-search",
"/web-searches",
summary="Web Search",
description="调用 Bocha Web Search API 获取实时网页搜索结果",
)
+371
View File
@@ -0,0 +1,371 @@
from __future__ import annotations
import inspect
import re
from collections.abc import Iterable
from copy import copy
from functools import wraps
from typing import Any, Generic, TypeVar, get_args, get_origin
from fastapi import APIRouter, Depends, Query
from fastapi.routing import APIRoute
from pydantic import BaseModel, JsonValue, create_model
from app.api.problem_details import ProblemDetails
from app.api.v1.router import api_router as handler_api_router
from app.auth.metadata_dependencies import get_current_metadata_user
from app.auth.project_dependencies import ProjectContext, get_project_context
T = TypeVar("T")
class Page(BaseModel, Generic[T]):
items: list[T]
total: int
limit: int
offset: int
_NAME_IS_NETWORK = {
"pressure_sensor_placement_sensitivity_endpoint",
"pressure_sensor_placement_kmeans_endpoint",
}
_DERIVE_USERNAME = {
"pressure_sensor_placement_sensitivity_endpoint": "username",
"pressure_sensor_placement_kmeans_endpoint": "username",
"fastapi_pressure_sensor_placement": "user_name",
}
_PUBLIC_PARAMETER_RENAMES = {
"burst_ID": "burst_id",
"drainage_node_ID": "drainage_node_id",
}
_MODEL_NAME_IS_NETWORK = {"RunSimulationManuallyByDate"}
_MODEL_USERNAME_FROM_AUTH: set[str] = set()
def _clean_name(name: str) -> str:
for prefix in ("fastapi_", "fast_"):
if name.startswith(prefix):
name = name[len(prefix) :]
break
if name.endswith("_endpoint"):
name = name[: -len("_endpoint")]
return name
def _rest_body_model(annotation):
if not inspect.isclass(annotation) or not issubclass(annotation, BaseModel):
return None
project_fields = {
name
for name in ("network", "network_name")
if name in annotation.model_fields
}
if annotation.__name__ in _MODEL_NAME_IS_NETWORK and "name" in annotation.model_fields:
project_fields.add("name")
username_fields = (
{
name
for name in ("username", "user_name")
if name in annotation.model_fields
}
if annotation.__name__ in _MODEL_USERNAME_FROM_AUTH
else set()
)
excluded_fields = project_fields | username_fields
if not excluded_fields:
return None
public_fields = {
name: (field.annotation, copy(field))
for name, field in annotation.model_fields.items()
if name not in excluded_fields
}
public_model = create_model(
f"{annotation.__name__}Rest",
__module__=annotation.__module__,
**public_fields,
)
return annotation, public_model, project_fields, username_fields
def _with_header_project_context(endpoint, route_name: str):
signature = inspect.signature(endpoint)
network_parameters = [
name for name in ("network", "network_name") if name in signature.parameters
]
if route_name in _NAME_IS_NETWORK and "name" in signature.parameters:
network_parameters.append("name")
username_parameter = _DERIVE_USERNAME.get(route_name)
parameter_renames = {
internal: public
for internal, public in _PUBLIC_PARAMETER_RENAMES.items()
if internal in signature.parameters
}
body_models = {
name: body_model
for name, parameter in signature.parameters.items()
if (body_model := _rest_body_model(parameter.annotation)) is not None
}
model_has_username = any(model[3] for model in body_models.values())
if (
not network_parameters
and not username_parameter
and not parameter_renames
and not body_models
):
return endpoint
existing_context_parameter = next(
(
name
for name, parameter in signature.parameters.items()
if parameter.annotation is ProjectContext
),
None,
)
injected_context_name = existing_context_parameter or "_rest_project_context"
injected_user_name = "_rest_current_user"
@wraps(endpoint)
async def wrapper(*args, **kwargs):
project_context = kwargs.get(injected_context_name)
if not isinstance(project_context, ProjectContext):
raise RuntimeError("REST project context was not resolved")
if not existing_context_parameter:
kwargs.pop(injected_context_name, None)
for parameter_name in network_parameters:
kwargs[parameter_name] = project_context.project_code
if username_parameter:
kwargs[username_parameter] = kwargs[injected_user_name].username
kwargs.pop(injected_user_name, None)
for internal_name, public_name in parameter_renames.items():
kwargs[internal_name] = kwargs.pop(public_name)
for parameter_name, (
original_model,
_public_model,
project_fields,
username_fields,
) in body_models.items():
data = kwargs[parameter_name].model_dump()
data.update(
{field_name: project_context.project_code for field_name in project_fields}
)
if username_fields:
current_user = kwargs[injected_user_name]
data.update(
{field_name: current_user.username for field_name in username_fields}
)
kwargs[parameter_name] = original_model.model_validate(data)
if model_has_username:
kwargs.pop(injected_user_name, None)
result = endpoint(*args, **kwargs)
if inspect.isawaitable(result):
return await result
return result
parameters = []
for name, parameter in signature.parameters.items():
if name in network_parameters or name == username_parameter:
continue
public_name = parameter_renames.get(name, name)
if public_name != name:
default = copy(parameter.default)
default.alias = public_name
default.validation_alias = public_name
default.serialization_alias = public_name
parameter = parameter.replace(name=public_name, default=default)
if name in body_models:
parameter = parameter.replace(annotation=body_models[name][1])
parameters.append(parameter)
if not existing_context_parameter:
parameters.append(
inspect.Parameter(
injected_context_name,
kind=inspect.Parameter.KEYWORD_ONLY,
annotation=ProjectContext,
default=Depends(get_project_context),
)
)
if username_parameter or model_has_username:
parameters.append(
inspect.Parameter(
injected_user_name,
kind=inspect.Parameter.KEYWORD_ONLY,
default=Depends(get_current_metadata_user),
)
)
wrapper.__signature__ = signature.replace(parameters=parameters)
return wrapper
def _with_pagination(endpoint):
signature = inspect.signature(endpoint)
handler_limit_parameter = "limit" if "limit" in signature.parameters else None
handler_offset_parameter = next(
(
parameter_name
for parameter_name in ("offset", "skip")
if parameter_name in signature.parameters
),
None,
)
handler_handles_pagination = bool(
handler_limit_parameter or handler_offset_parameter
)
@wraps(endpoint)
async def wrapper(*args, **kwargs):
if handler_handles_pagination:
limit = kwargs.get(handler_limit_parameter, 0)
offset = kwargs.get(handler_offset_parameter, 0)
else:
limit = kwargs.pop("_rest_limit")
offset = kwargs.pop("_rest_offset")
result = endpoint(*args, **kwargs)
if inspect.isawaitable(result):
result = await result
if not isinstance(result, list):
return result
if handler_handles_pagination:
return Page(
items=result,
total=offset + len(result),
limit=limit or len(result),
offset=offset,
)
return Page(
items=result[offset : offset + limit],
total=len(result),
limit=limit,
offset=offset,
)
parameters = list(signature.parameters.values())
if not handler_handles_pagination:
parameters.extend(
[
inspect.Parameter(
"_rest_limit",
kind=inspect.Parameter.KEYWORD_ONLY,
annotation=int,
default=Query(100, ge=1, le=1000, alias="limit"),
),
inspect.Parameter(
"_rest_offset",
kind=inspect.Parameter.KEYWORD_ONLY,
annotation=int,
default=Query(0, ge=0, alias="offset"),
),
]
)
wrapper.__signature__ = signature.replace(parameters=parameters)
return wrapper
def _adapt_route(route: APIRoute) -> APIRoute:
methods = route.methods or set()
if len(methods) != 1:
raise RuntimeError(
f"REST route {route.name!r} must declare exactly one HTTP method"
)
method = next(iter(methods))
responses = dict(route.responses or {})
for status_code, description in (
(401, "Authentication required"),
(403, "Insufficient permission"),
(404, "Resource not found"),
(409, "Resource conflict"),
(422, "Validation error"),
(503, "Dependency unavailable"),
):
responses.setdefault(
status_code,
{"model": ProblemDetails, "description": description},
)
endpoint = _with_header_project_context(route.endpoint, route.name)
response_model = route.response_model
if get_origin(response_model) is list:
item_type = get_args(response_model)[0] if get_args(response_model) else JsonValue
response_model = Page[item_type]
endpoint = _with_pagination(endpoint)
clean_name = _clean_name(route.name)
creates_resource = clean_name.startswith(
("add_", "create_", "copy_", "import_", "insert_", "store_", "take_", "upload_")
) or route.name == "fastapi_pressure_sensor_placement"
status_code = (
204
if method == "DELETE"
else 201
if method == "POST" and creates_resource
else route.status_code
)
if status_code == 204:
response_model = None
elif response_model is None:
response_model = JsonValue
return APIRoute(
path=route.path,
endpoint=endpoint,
response_model=response_model,
status_code=status_code,
tags=route.tags,
dependencies=route.dependencies,
summary=route.summary,
description=route.description,
response_description=route.response_description,
responses=responses,
deprecated=False,
name=route.name,
methods={method},
operation_id=f"{method.lower()}_{re.sub(r'[^a-z0-9]+', '_', route.path).strip('_')}",
response_model_include=route.response_model_include,
response_model_exclude=route.response_model_exclude,
response_model_by_alias=route.response_model_by_alias,
response_model_exclude_unset=route.response_model_exclude_unset,
response_model_exclude_defaults=route.response_model_exclude_defaults,
response_model_exclude_none=route.response_model_exclude_none,
include_in_schema=route.include_in_schema,
response_class=route.response_class,
callbacks=route.callbacks,
openapi_extra=route.openapi_extra,
)
def build_rest_router(routes: Iterable[Any]) -> APIRouter:
router = APIRouter()
seen: dict[tuple[str, str], APIRoute] = {}
operation_ids: set[str] = set()
for route in routes:
if not isinstance(route, APIRoute):
continue
methods = route.methods or set()
if len(methods) != 1:
raise RuntimeError(
f"REST route {route.name!r} must declare exactly one HTTP method"
)
method = next(iter(methods))
key = (method, route.path)
if key in seen:
previous = seen[key]
raise RuntimeError(
"REST route collision for "
f"{method} {route.path}: {previous.name!r} and {route.name!r}."
)
adapted = _adapt_route(route)
if adapted.operation_id in operation_ids:
adapted.operation_id = f"{adapted.operation_id}_{route.name}"
seen[key] = route
operation_ids.add(adapted.operation_id or "")
router.routes.append(adapted)
return router
api_router = build_rest_router(handler_api_router.routes)
+208 -90
View File
@@ -1,118 +1,236 @@
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from app.api.v1.endpoints import (
access,
admin_metadata,
agent_auth,
project,
simulation,
scada,
extension,
snapshots,
# data_query,
users,
schemes,
misc,
risk,
cache,
leakage,
audit,
burst_detection,
burst_location,
audit, # 新增:审计日志
meta,
web_search,
cache,
extension,
geocoding,
)
from app.api.v1.endpoints.network import (
general,
junctions,
reservoirs,
tanks,
pipes,
pumps,
valves,
tags,
demands,
geometry,
regions,
leakage,
meta,
misc,
model_import,
project,
project_data,
risk,
scada,
schemes,
sensor_placement,
simulation,
snapshots,
users,
web_search,
)
from app.api.v1.endpoints.components import (
curves,
patterns,
controls,
curves,
options,
patterns,
quality,
visuals,
)
from app.api.v1.endpoints import project_data
from app.api.v1.endpoints.network import (
demands,
general,
geometry,
junctions,
pipes,
pumps,
regions,
reservoirs,
tags,
tanks,
valves,
)
from app.api.v1.endpoints.timeseries import (
realtime as ts_realtime,
scheme as ts_scheme,
scada as ts_scada,
composite as ts_composite,
realtime as ts_realtime,
scada as ts_scada,
scheme as ts_scheme,
)
from app.auth.permissions import (
BURST_RUN,
OPTIMIZATION_RUN,
RISK_RUN,
SCADA_CLEAN,
SCADA_VIEW,
SIMULATION_RUN,
SIMULATION_VIEW,
WEBGIS_EDIT,
WEBGIS_VIEW,
require_method_permission,
require_permission,
)
api_router = APIRouter()
# Core Services
webgis_access = Depends(
require_method_permission(
read_permission=WEBGIS_VIEW,
write_permission=WEBGIS_EDIT,
)
)
scada_access = Depends(
require_method_permission(
read_permission=SCADA_VIEW,
write_permission=SCADA_CLEAN,
)
)
simulation_access = Depends(
require_method_permission(
read_permission=SIMULATION_VIEW,
write_permission=SIMULATION_RUN,
)
)
webgis_view_access = Depends(require_permission(WEBGIS_VIEW))
simulation_run_access = Depends(require_permission(SIMULATION_RUN))
burst_run_access = Depends(require_permission(BURST_RUN))
risk_run_access = Depends(require_permission(RISK_RUN))
optimization_run_access = Depends(require_permission(OPTIMIZATION_RUN))
# Core services
api_router.include_router(access.router, tags=["Access Control"])
api_router.include_router(agent_auth.router, tags=["Agent Auth"])
api_router.include_router(
admin_metadata.router, prefix="/admin", tags=["Metadata Admin"]
admin_metadata.router,
tags=["Metadata Admin"],
)
api_router.include_router(audit.router, prefix="/audit", tags=["Audit Logs"]) # 新增
api_router.include_router(model_import.router, tags=["Model Administration"])
api_router.include_router(audit.router, tags=["Audit Logs"])
api_router.include_router(meta.router, tags=["Metadata"])
api_router.include_router(project.router, tags=["Project"])
# Network Elements (Node/Link Types)
api_router.include_router(general.router, tags=["Network General"])
api_router.include_router(junctions.router, tags=["Junctions"])
api_router.include_router(reservoirs.router, tags=["Reservoirs"])
api_router.include_router(tanks.router, tags=["Tanks"])
api_router.include_router(pipes.router, tags=["Pipes"])
api_router.include_router(pumps.router, tags=["Pumps"])
api_router.include_router(valves.router, tags=["Valves"])
# Network Features
api_router.include_router(tags.router, tags=["Tags"])
api_router.include_router(demands.router, tags=["Demands"])
api_router.include_router(geometry.router, tags=["Geometry & Coordinates"])
api_router.include_router(regions.router, tags=["Regions & DMAs"])
# Components & Controls
api_router.include_router(curves.router, tags=["Curves"])
api_router.include_router(patterns.router, tags=["Patterns"])
api_router.include_router(controls.router, tags=["Controls & Rules"])
api_router.include_router(options.router, tags=["Options"])
api_router.include_router(quality.router, tags=["Quality"])
api_router.include_router(visuals.router, tags=["Visuals"])
# Simulation & Data
api_router.include_router(simulation.router, tags=["Simulation Control"])
# api_router.include_router(data_query.router, tags=["Data Query & InfluxDB"])
api_router.include_router(scada.router)
api_router.include_router(snapshots.router, tags=["Snapshots"])
api_router.include_router(users.router, tags=["Users"])
api_router.include_router(schemes.router, tags=["Schemes"])
api_router.include_router(misc.router, tags=["Misc"])
api_router.include_router(risk.router, tags=["Risk"])
api_router.include_router(cache.router, tags=["Cache"])
api_router.include_router(web_search.router, tags=["Web Search"])
api_router.include_router(geocoding.router, tags=["Geocoding"])
api_router.include_router(leakage.router, prefix="/leakage", tags=["Leakage"])
api_router.include_router(
burst_detection.router, prefix="/burst-detection", tags=["Burst Detection"]
)
api_router.include_router(
burst_location.router, prefix="/burst-location", tags=["Burst Location"]
project.router,
tags=["Project"],
dependencies=[webgis_access],
)
# TimescaleDB Data Access
api_router.include_router(ts_realtime.router, tags=["TimescaleDB - Realtime"])
api_router.include_router(ts_scheme.router, tags=["TimescaleDB - Scheme"])
api_router.include_router(ts_scada.router, tags=["TimescaleDB - SCADA"])
api_router.include_router(ts_composite.router, tags=["TimescaleDB - Composite"])
# WebGIS data
for endpoint_router, tag in (
(general.router, "Network General"),
(junctions.router, "Junctions"),
(reservoirs.router, "Reservoirs"),
(tanks.router, "Tanks"),
(pipes.router, "Pipes"),
(pumps.router, "Pumps"),
(valves.router, "Valves"),
(tags.router, "Tags"),
(demands.router, "Demands"),
(geometry.router, "Geometry & Coordinates"),
(regions.router, "Regions & DMAs"),
(curves.router, "Curves"),
(patterns.router, "Patterns"),
(controls.router, "Controls & Rules"),
(options.router, "Options"),
(quality.router, "Quality"),
(visuals.router, "Visuals"),
):
api_router.include_router(
endpoint_router,
tags=[tag],
dependencies=[webgis_access],
)
# Project Data (PostgreSQL)
api_router.include_router(project_data.router, tags=["Project Data"])
# Simulation and analysis
api_router.include_router(
simulation.router,
tags=["Simulation Control"],
dependencies=[simulation_run_access],
)
api_router.include_router(scada.router, dependencies=[scada_access])
api_router.include_router(
sensor_placement.router,
tags=["Sensor Placement"],
dependencies=[optimization_run_access],
)
api_router.include_router(
snapshots.router,
tags=["Snapshots"],
dependencies=[simulation_access],
)
api_router.include_router(
users.router,
tags=["Users"],
dependencies=[webgis_view_access],
)
api_router.include_router(
schemes.router,
tags=["Schemes"],
dependencies=[simulation_access],
)
api_router.include_router(
misc.router,
tags=["Misc"],
dependencies=[webgis_view_access],
)
api_router.include_router(
risk.router,
tags=["Risk"],
dependencies=[risk_run_access],
)
api_router.include_router(
cache.router,
tags=["Cache"],
dependencies=[simulation_run_access],
)
api_router.include_router(
web_search.router,
tags=["Web Search"],
dependencies=[webgis_view_access],
)
api_router.include_router(
geocoding.router,
tags=["Geocoding"],
dependencies=[webgis_view_access],
)
api_router.include_router(
leakage.router,
tags=["Leakage"],
dependencies=[burst_run_access],
)
api_router.include_router(
burst_detection.router,
tags=["Burst Detection"],
dependencies=[burst_run_access],
)
api_router.include_router(
burst_location.router,
tags=["Burst Location"],
dependencies=[burst_run_access],
)
# Extension
api_router.include_router(extension.router, tags=["Extension"])
# TimescaleDB data
for endpoint_router, tag in (
(ts_realtime.router, "TimescaleDB - Realtime"),
(ts_scheme.router, "TimescaleDB - Scheme"),
):
api_router.include_router(
endpoint_router,
tags=[tag],
dependencies=[simulation_access],
)
for endpoint_router, tag in (
(ts_scada.router, "TimescaleDB - SCADA"),
(ts_composite.router, "TimescaleDB - Composite"),
):
api_router.include_router(
endpoint_router,
tags=[tag],
dependencies=[scada_access],
)
api_router.include_router(
project_data.router,
tags=["Project Data"],
dependencies=[webgis_view_access],
)
api_router.include_router(
extension.router,
tags=["Extension"],
dependencies=[webgis_access],
)
+9 -5
View File
@@ -73,14 +73,18 @@ async def get_current_keycloak_sub(
) from exc
async def get_current_keycloak_username(
payload: dict = Depends(get_current_keycloak_payload),
) -> str:
username = payload.get("preferred_username") or payload.get("username")
def get_keycloak_preferred_username(payload: dict) -> str:
username = payload.get("preferred_username")
if not username:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing username claim",
detail="Missing preferred_username claim",
headers={"WWW-Authenticate": "Bearer"},
)
return str(username)
async def get_current_keycloak_username(
payload: dict = Depends(get_current_keycloak_payload),
) -> str:
return get_keycloak_preferred_username(payload)
+8 -9
View File
@@ -6,7 +6,10 @@ from fastapi import Depends, HTTPException, status
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.keycloak_dependencies import get_current_keycloak_payload
from app.auth.keycloak_dependencies import (
get_current_keycloak_payload,
get_keycloak_preferred_username,
)
from app.infra.db.metadb.database import get_metadata_session
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
@@ -38,11 +41,6 @@ def _keycloak_sub_from_payload(payload: dict) -> UUID:
) from exc
def _username_from_payload(payload: dict) -> str | None:
username = payload.get("preferred_username") or payload.get("username")
return str(username) if username else None
def _email_from_payload(payload: dict) -> str | None:
email = payload.get("email")
return str(email) if email else None
@@ -53,6 +51,7 @@ async def get_current_metadata_user(
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
):
keycloak_sub = _keycloak_sub_from_payload(keycloak_payload)
username = get_keycloak_preferred_username(keycloak_payload)
try:
user = await metadata_repo.get_user_by_keycloak_id(keycloak_sub)
except SQLAlchemyError as exc:
@@ -62,7 +61,7 @@ async def get_current_metadata_user(
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Metadata database error: {exc}",
detail="Metadata database is unavailable",
) from exc
if not user or not user.is_active:
raise HTTPException(
@@ -71,7 +70,7 @@ async def get_current_metadata_user(
try:
user = await metadata_repo.refresh_user_keycloak_snapshot(
user,
username=_username_from_payload(keycloak_payload),
username=username,
email=_email_from_payload(keycloak_payload),
)
except SQLAlchemyError as exc:
@@ -81,7 +80,7 @@ async def get_current_metadata_user(
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Metadata database error: {exc}",
detail="Metadata database is unavailable",
) from exc
return user
+162
View File
@@ -0,0 +1,162 @@
from collections.abc import Awaitable, Callable
from typing import Any
from fastapi import Depends, HTTPException, Request, status
from app.auth.project_dependencies import ProjectContext, get_project_context
WEBGIS_VIEW = "webgis.view"
WEBGIS_EDIT = "webgis.edit"
SCADA_VIEW = "scada.view"
SCADA_CLEAN = "scada.clean"
SIMULATION_VIEW = "simulation.view"
SIMULATION_RUN = "simulation.run"
BURST_VIEW = "burst.view"
BURST_RUN = "burst.run"
RISK_VIEW = "risk.view"
RISK_RUN = "risk.run"
OPTIMIZATION_VIEW = "optimization.view"
OPTIMIZATION_RUN = "optimization.run"
MODEL_IMPORT = "model.import"
AUDIT_VIEW = "audit.view"
ENVIRONMENT_MANAGE = "environment.manage"
MEMBERSHIP_MANAGE = "membership.manage"
PROJECT_MEMBER_PERMISSIONS = frozenset(
{
WEBGIS_VIEW,
WEBGIS_EDIT,
SCADA_VIEW,
SCADA_CLEAN,
SIMULATION_VIEW,
SIMULATION_RUN,
BURST_VIEW,
BURST_RUN,
RISK_VIEW,
RISK_RUN,
OPTIMIZATION_VIEW,
OPTIMIZATION_RUN,
}
)
PROJECT_VIEWER_PERMISSIONS = frozenset(
{
WEBGIS_VIEW,
SCADA_VIEW,
SIMULATION_VIEW,
}
)
SYSTEM_ADMIN_PERMISSIONS = frozenset(
{
MODEL_IMPORT,
AUDIT_VIEW,
ENVIRONMENT_MANAGE,
MEMBERSHIP_MANAGE,
}
)
PROJECT_ROLE_PERMISSIONS: dict[str, frozenset[str]] = {
"member": PROJECT_MEMBER_PERMISSIONS,
"viewer": PROJECT_VIEWER_PERMISSIONS,
}
def resolve_permissions(
*,
project_role: str | None,
system_role: str,
is_superuser: bool,
) -> frozenset[str]:
permissions = set(PROJECT_ROLE_PERMISSIONS.get(project_role or "", frozenset()))
if is_superuser or system_role == "admin":
permissions.update(SYSTEM_ADMIN_PERMISSIONS)
return frozenset(permissions)
def permissions_for_context(ctx: ProjectContext) -> frozenset[str]:
return resolve_permissions(
project_role=ctx.project_role,
system_role=ctx.system_role,
is_superuser=ctx.is_superuser,
)
def _permission_denied(permission: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"code": "permission_denied",
"permission": permission,
},
)
async def _enforce_project_scope(request: Request, ctx: ProjectContext) -> None:
requested_network = (
request.path_params.get("network")
or request.query_params.get("network")
)
if not requested_network:
content_type = request.headers.get("content-type", "")
if content_type.startswith("application/json"):
try:
payload = await request.json()
except (ValueError, RuntimeError):
payload = None
if isinstance(payload, dict):
requested_network = payload.get("network")
if requested_network and str(requested_network) != ctx.project_code:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"code": "project_scope_denied",
"project_id": str(ctx.project_id),
},
)
def require_permission(
permission: str,
) -> Callable[..., Awaitable[ProjectContext]]:
async def dependency(
request: Request,
ctx: ProjectContext = Depends(get_project_context),
) -> ProjectContext:
if permission not in permissions_for_context(ctx):
raise _permission_denied(permission)
await _enforce_project_scope(request, ctx)
return ctx
return dependency
def require_method_permission(
*,
read_permission: str,
write_permission: str,
) -> Callable[..., Awaitable[ProjectContext]]:
async def dependency(
request: Request,
ctx: ProjectContext = Depends(get_project_context),
) -> ProjectContext:
permission = (
read_permission
if request.method.upper() in {"GET", "HEAD", "OPTIONS"}
else write_permission
)
if permission not in permissions_for_context(ctx):
raise _permission_denied(permission)
await _enforce_project_scope(request, ctx)
return ctx
return dependency
def has_permission(user: Any, project_role: str | None, permission: str) -> bool:
return permission in resolve_permissions(
project_role=project_role,
system_role=str(getattr(user, "role", "user")),
is_superuser=bool(getattr(user, "is_superuser", False)),
)
+82 -90
View File
@@ -1,18 +1,21 @@
import logging
from collections.abc import AsyncGenerator
from dataclasses import dataclass
from typing import AsyncGenerator
from uuid import UUID
import logging
from fastapi import Depends, Header, HTTPException, status
from psycopg import AsyncConnection
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.keycloak_dependencies import get_current_keycloak_sub
from app.auth.metadata_dependencies import get_current_metadata_user
from app.core.config import settings
from app.infra.db.dynamic_manager import project_connection_manager
from app.infra.db.metadb.database import get_metadata_session
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.infra.db.metadb.repositories.metadata_repository import (
MetadataRepository,
ProjectDbRouting,
)
DB_ROLE_BIZ_DATA = "biz_data"
DB_ROLE_IOT_DATA = "iot_data"
@@ -28,6 +31,8 @@ class ProjectContext:
project_code: str
user_id: UUID
project_role: str
system_role: str = "user"
is_superuser: bool = False
async def get_metadata_repository(
@@ -36,10 +41,10 @@ async def get_metadata_repository(
return MetadataRepository(session)
async def get_project_context(
x_project_id: str = Header(..., alias="X-Project-Id"),
keycloak_sub: UUID = Depends(get_current_keycloak_sub),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
async def resolve_project_context(
x_project_id: str,
current_user,
metadata_repo: MetadataRepository,
) -> ProjectContext:
try:
project_uuid = UUID(x_project_id)
@@ -59,17 +64,9 @@ async def get_project_context(
status_code=status.HTTP_403_FORBIDDEN, detail="Project is not active"
)
user = await metadata_repo.get_user_by_keycloak_id(keycloak_sub)
if not user:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="User not registered"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
)
membership_role = await metadata_repo.get_membership_role(project_uuid, user.id)
membership_role = await metadata_repo.get_membership_role(
project_uuid, current_user.id
)
if not membership_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="No access to project"
@@ -81,44 +78,71 @@ async def get_project_context(
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Metadata database error: {exc}",
detail="Metadata database is unavailable",
) from exc
return ProjectContext(
project_id=project.id,
project_code=project.code,
user_id=user.id,
user_id=current_user.id,
project_role=membership_role,
system_role=current_user.role,
is_superuser=current_user.is_superuser,
)
async def get_project_context(
x_project_id: str = Header(..., alias="X-Project-Id"),
current_user=Depends(get_current_metadata_user),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> ProjectContext:
return await resolve_project_context(x_project_id, current_user, metadata_repo)
async def _get_project_routing(
metadata_repo: MetadataRepository,
project_id: UUID,
db_role: str,
expected_db_type: str,
database_label: str,
) -> ProjectDbRouting:
try:
routing = await metadata_repo.get_project_db_routing(project_id, db_role)
except ValueError as exc:
logger.error(
"Invalid project %s routing DSN configuration",
database_label,
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Project {database_label} routing DSN is invalid: {exc}",
) from exc
if not routing:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Project {database_label} not configured",
)
if routing.db_type != expected_db_type:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Project {database_label} type mismatch",
)
return routing
async def get_project_pg_session(
ctx: ProjectContext = Depends(get_project_context),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> AsyncGenerator[AsyncSession, None]:
try:
routing = await metadata_repo.get_project_db_routing(
ctx.project_id, DB_ROLE_BIZ_DATA
)
except ValueError as exc:
logger.error(
"Invalid project PostgreSQL routing DSN configuration",
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Project PostgreSQL routing DSN is invalid: {exc}",
) from exc
if not routing:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Project PostgreSQL not configured",
)
if routing.db_type != DB_TYPE_POSTGRES:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Project PostgreSQL type mismatch",
)
routing = await _get_project_routing(
metadata_repo,
ctx.project_id,
DB_ROLE_BIZ_DATA,
DB_TYPE_POSTGRES,
"PostgreSQL",
)
pool_min_size = routing.pool_min_size or settings.PROJECT_PG_POOL_SIZE
pool_max_size = routing.pool_max_size or settings.PROJECT_PG_POOL_SIZE
@@ -137,29 +161,13 @@ async def get_project_pg_connection(
ctx: ProjectContext = Depends(get_project_context),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> AsyncGenerator[AsyncConnection, None]:
try:
routing = await metadata_repo.get_project_db_routing(
ctx.project_id, DB_ROLE_BIZ_DATA
)
except ValueError as exc:
logger.error(
"Invalid project PostgreSQL routing DSN configuration",
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Project PostgreSQL routing DSN is invalid: {exc}",
) from exc
if not routing:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Project PostgreSQL not configured",
)
if routing.db_type != DB_TYPE_POSTGRES:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Project PostgreSQL type mismatch",
)
routing = await _get_project_routing(
metadata_repo,
ctx.project_id,
DB_ROLE_BIZ_DATA,
DB_TYPE_POSTGRES,
"PostgreSQL",
)
pool_min_size = routing.pool_min_size or settings.PROJECT_PG_POOL_SIZE
pool_max_size = routing.pool_max_size or settings.PROJECT_PG_POOL_SIZE
@@ -178,29 +186,13 @@ async def get_project_timescale_connection(
ctx: ProjectContext = Depends(get_project_context),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> AsyncGenerator[AsyncConnection, None]:
try:
routing = await metadata_repo.get_project_db_routing(
ctx.project_id, DB_ROLE_IOT_DATA
)
except ValueError as exc:
logger.error(
"Invalid project TimescaleDB routing DSN configuration",
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Project TimescaleDB routing DSN is invalid: {exc}",
) from exc
if not routing:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Project TimescaleDB not configured",
)
if routing.db_type != DB_TYPE_TIMESCALE:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Project TimescaleDB type mismatch",
)
routing = await _get_project_routing(
metadata_repo,
ctx.project_id,
DB_ROLE_IOT_DATA,
DB_TYPE_TIMESCALE,
"TimescaleDB",
)
pool_min_size = routing.pool_min_size or settings.PROJECT_TS_POOL_MIN_SIZE
pool_max_size = routing.pool_max_size or settings.PROJECT_TS_POOL_MAX_SIZE
-1
View File
@@ -8,7 +8,6 @@ class Settings(BaseSettings):
PROJECT_NAME: str = "TJWater Server"
ENVIRONMENT: str = "production"
API_V1_STR: str = "/api/v1"
NETWORK_NAME: str = "default_network"
# 敏感配置加密密钥 (Fernet)
+13
View File
@@ -0,0 +1,13 @@
from uuid import UUID
from pydantic import BaseModel
class AccessContextResponse(BaseModel):
user_id: UUID
username: str
system_role: str
is_system_admin: bool
project_id: UUID | None = None
project_role: str | None = None
permissions: list[str]
+2 -2
View File
@@ -5,8 +5,8 @@ from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, model_validator
BusinessRole = Literal["admin", "user", "operator", "viewer"]
ProjectRole = Literal["owner", "admin", "member", "viewer"]
BusinessRole = Literal["admin", "user"]
ProjectRole = Literal["member", "viewer"]
ProjectStatus = Literal["active", "inactive", "archived"]
ProjectDbRole = Literal["biz_data", "iot_data"]
+88
View File
@@ -0,0 +1,88 @@
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,
max_length=200,
)
sensor_location: list[str] = Field(..., min_length=1, max_length=200)
@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, max_length=200)
adjustment_status: dict[str, AdjustmentStatus] = Field(
default_factory=dict,
max_length=200,
)
@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
+2 -18
View File
@@ -58,6 +58,8 @@ class AuditMiddleware(BaseHTTPMiddleware):
"/meta/projects",
"/api/v1/openproject/",
"/openproject/",
"/api/v1/audit/session-events",
"/audit/session-events",
}
EXCLUDED_PATH_PREFIXES = (
)
@@ -80,27 +82,9 @@ class AuditMiddleware(BaseHTTPMiddleware):
request_data = None
if should_capture_body:
try:
# 注意:读取 body 后需要重新设置,避免影响后续处理
original_receive = request._receive
body = await request.body()
if body:
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:
logger.warning(f"Failed to read request body for audit: {e}")
@@ -211,6 +211,7 @@ class MetadataRepository:
gs_workspace: str,
map_extent: dict | None,
status: str,
creator_user_id: UUID | None = None,
) -> models.Project:
project = models.Project(
id=uuid4(),
@@ -224,6 +225,15 @@ class MetadataRepository:
updated_at=_utcnow(),
)
self.session.add(project)
if creator_user_id is not None:
self.session.add(
models.UserProjectMembership(
id=uuid4(),
user_id=creator_user_id,
project_id=project.id,
project_role="member",
)
)
await self.session.commit()
await self.session.refresh(project)
return project
@@ -483,7 +493,7 @@ class MetadataRepository:
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
status=project.status,
project_role="owner",
project_role="member",
)
for project in result.scalars().all()
]
+4 -1
View File
@@ -6,7 +6,8 @@ import logging
from datetime import datetime
import app.services.project_info as project_info
from app.api.v1.router import api_router
from app.api.problem_details import install_problem_details_handlers
from app.api.v1.rest_router import api_router
from app.infra.db.timescaledb.database import db as tsdb
from app.infra.db.postgresql.database import db as pgdb
from app.infra.db.dynamic_manager import project_connection_manager
@@ -64,11 +65,13 @@ app = FastAPI(
docs_url=None if is_production else "/docs",
redoc_url=None if is_production else "/redoc",
openapi_url=None if is_production else "/openapi.json",
redirect_slashes=False,
)
# Include Routers
app.include_router(api_router, prefix="/api/v1")
install_problem_details_handlers(app)
# Legcy Routers without version prefix
# app.include_router(api_router)
+11 -2
View File
@@ -320,7 +320,11 @@ from .s23_options_util import (
from .s23_options_util import get_option_v3_schema, get_option_v3
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 (
get_vertex_schema,
@@ -468,6 +472,11 @@ from .s41_pipe_risk_probability import (
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
+14 -2
View File
@@ -20,6 +20,17 @@ def _close_connection(connection: pg.Connection) -> None:
connection.close()
def _is_healthy(connection: pg.Connection) -> bool:
if _is_closed(connection):
return False
try:
with connection.cursor() as cur:
cur.execute("SELECT 1")
except pg.Error:
return False
return True
def _get_project_lock(name: str) -> RLock:
with _registry_lock:
lock = _project_locks.get(name)
@@ -32,7 +43,7 @@ def _get_project_lock(name: str) -> RLock:
def open_connection(name: str) -> pg.Connection:
with _get_project_lock(name):
connection = g_conn_dict.get(name)
if connection is None or _is_closed(connection):
if connection is None or not _is_healthy(connection):
if connection is not None:
_close_connection(connection)
connection = pg.connect(
@@ -47,8 +58,9 @@ def is_connection_open(name: str) -> bool:
connection = g_conn_dict.get(name)
if connection is None:
return False
if _is_closed(connection):
if not _is_healthy(connection):
del g_conn_dict[name]
_close_connection(connection)
return False
return True
+107 -5
View File
@@ -1,7 +1,109 @@
from .database import *
from .s0_base import *
from .s42_sensor_placement import *
import json
from typing import Any
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")
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()
+1 -1
View File
@@ -476,7 +476,7 @@ def _get_simulation_scheme_burst_ids(
) -> list[str]:
if not scheme_name:
return []
rows = query_scheme_list(network) or []
rows = query_scheme_list(network, scheme_type=scheme_type) or []
for row in rows:
if len(row) < 7:
continue
+118 -3
View File
@@ -154,10 +154,16 @@ def delete_scheme_info(name: str, scheme_name: str) -> None:
# 2025/03/23
def query_scheme_list(name: str) -> list:
def query_scheme_list(
name: str,
scheme_type: str | None = None,
query_date: date | None = None,
) -> list:
"""
查询pg数据库中的scheme_list按照 create_time 降序排列离现在时间最近的记录排在最前面
:param name: 项目名称数据库名称
:param scheme_type: 方案类型为空时返回全部类型
:param query_date: 查询日期为空时不按日期过滤
:return: 返回查询结果的所有行
"""
try:
@@ -166,8 +172,38 @@ def query_scheme_list(name: str) -> list:
# 连接到 PostgreSQL 数据库(这里是数据库 "bb"
with psycopg.connect(conn_string) as conn:
with conn.cursor() as cur:
# 按 create_time 降序排列
cur.execute("SELECT * FROM scheme_list ORDER BY create_time DESC")
if scheme_type and query_date is not None:
cur.execute(
"""
SELECT *
FROM scheme_list
WHERE scheme_type = %s AND DATE(create_time) = %s
ORDER BY create_time DESC
""",
(scheme_type, query_date),
)
elif scheme_type:
cur.execute(
"""
SELECT *
FROM scheme_list
WHERE scheme_type = %s
ORDER BY create_time DESC
""",
(scheme_type,),
)
elif query_date is not None:
cur.execute(
"""
SELECT *
FROM scheme_list
WHERE DATE(create_time) = %s
ORDER BY create_time DESC
""",
(query_date,),
)
else:
cur.execute("SELECT * FROM scheme_list ORDER BY create_time DESC")
rows = cur.fetchall()
return rows
@@ -175,6 +211,85 @@ def query_scheme_list(name: str) -> list:
print(f"查询错误:{e}")
def _filter_scheme_detail_scope(
result: dict,
name: str,
scheme_type: str | None = None,
) -> dict:
if not result:
return {}
if scheme_type and result.get("scheme_type") != scheme_type:
return {}
network = result.get("network")
if network not in (None, name):
return {}
return result
def query_scheme_detail(
name: str,
scheme_name: str,
scheme_type: str | None = None,
) -> dict:
if scheme_type == "dma_leak_identification":
return _filter_scheme_detail_scope(
query_leakage_identify_scheme_detail(name, scheme_name),
name,
scheme_type,
)
if scheme_type == "burst_detection":
return _filter_scheme_detail_scope(
query_burst_detection_scheme_detail(name, scheme_name),
name,
scheme_type,
)
if scheme_type == "burst_location":
return _filter_scheme_detail_scope(
query_burst_location_scheme_detail(name, scheme_name),
name,
scheme_type,
)
conn_string = get_pgconn_string(db_name=name)
with psycopg.connect(conn_string) as conn:
with conn.cursor() as cur:
if scheme_type:
cur.execute(
"""
SELECT scheme_id, scheme_name, scheme_type, username, create_time, scheme_start_time, scheme_detail
FROM public.scheme_list
WHERE scheme_name = %s AND scheme_type = %s
LIMIT 1
""",
(scheme_name, scheme_type),
)
else:
cur.execute(
"""
SELECT scheme_id, scheme_name, scheme_type, username, create_time, scheme_start_time, scheme_detail
FROM public.scheme_list
WHERE scheme_name = %s
LIMIT 1
""",
(scheme_name,),
)
row = cur.fetchone()
if row is None:
return {}
detail = row[6] if isinstance(row[6], dict) else {}
return _filter_scheme_detail_scope({
"scheme_id": row[0],
"scheme_name": row[1],
"scheme_type": row[2],
"username": row[3],
"create_time": row[4],
"scheme_start_time": row[5],
"scheme_detail": detail,
"network": detail.get("network"),
"result_payload": detail.get("result_payload", {}),
}, name, scheme_type)
def store_leakage_identify_result(
name: str,
scheme_name: str,
+257
View File
@@ -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
+28 -5
View File
@@ -1312,8 +1312,34 @@ def get_scheme_schema(name: str) -> dict[str, dict[str, Any]]:
def get_scheme(name: str, schema_name: str) -> dict[str, Any]:
return api.get_scheme(name, schema_name)
def get_all_schemes(name: str) -> list[dict[str, Any]]:
return api.get_all_schemes(name)
def get_all_schemes(
name: str,
scheme_type: str | None = None,
query_date: Any | None = None,
) -> list[dict[str, Any]]:
if scheme_type is None and query_date is None:
return api.get_all_schemes(name)
from app.services.scheme_management import query_scheme_list
rows = query_scheme_list(name, scheme_type=scheme_type, query_date=query_date) or []
columns = [
"scheme_id",
"scheme_name",
"scheme_type",
"username",
"create_time",
"scheme_start_time",
"scheme_detail",
]
result = []
for row in rows:
item = dict(zip(columns, row, strict=False))
detail = item.get("scheme_detail")
if isinstance(detail, dict) and detail.get("network") not in (None, name):
continue
result.append(item)
return result
############################################################
# pipe_risk_probability 41
@@ -1344,6 +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]]:
return api.get_all_burst_locate_results(name)
+24 -54
View File
@@ -32,24 +32,18 @@ def test_load_auth_context_supports_aliases(monkeypatch):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_PROJECT_ID", "p1")
monkeypatch.setenv("TJWATER_USERNAME", "tester")
monkeypatch.setenv("TJWATER_NETWORK", "net1")
auth = core.load_auth_context(auth_stdin=False)
assert auth.server == "http://server"
assert auth.access_token == "abc"
assert auth.project_id == "p1"
assert auth.username == "tester"
assert auth.network == "net1"
def test_build_runtime_context_uses_default_server(monkeypatch):
monkeypatch.delenv("TJWATER_SERVER", raising=False)
monkeypatch.delenv("TJWATER_ACCESS_TOKEN", raising=False)
monkeypatch.delenv("TJWATER_PROJECT_ID", raising=False)
monkeypatch.delenv("TJWATER_USERNAME", raising=False)
monkeypatch.delenv("TJWATER_NETWORK", raising=False)
monkeypatch.delenv("TJWATER_EXTRA_HEADERS", raising=False)
runtime = core.build_runtime_context(
@@ -68,7 +62,7 @@ def test_auth_stdin_can_be_reused_with_runtime_context_cache(monkeypatch):
def fake_request_json(ctx, **kwargs):
observed_runtime_ids.append(id(ctx))
assert ctx.auth.access_token == "token-1"
assert kwargs["params"] == {"network": "tjwater", "junction": "11"}
assert kwargs["params"] == {"junction": "11"}
return {"id": "11"}, 5
monkeypatch.setattr(common, "request_json", fake_request_json)
@@ -81,7 +75,6 @@ def test_auth_stdin_can_be_reused_with_runtime_context_cache(monkeypatch):
"server": "http://server",
"access_token": "token-1",
"project_id": "project-1",
"network": "tjwater",
}
),
)
@@ -105,7 +98,6 @@ def test_network_get_junction_properties_uses_network_context(monkeypatch):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-junction-properties", "--junction", "J1"])
@@ -116,8 +108,8 @@ def test_network_get_junction_properties_uses_network_context(monkeypatch):
assert payload["data"] == {"id": "J1"}
assert captured == {
"access_token": "abc",
"path": "/getjunctionproperties/",
"params": {"network": "tjwater", "junction": "J1"},
"path": "/junctions/properties",
"params": {"junction": "J1"},
}
@@ -132,7 +124,6 @@ def test_network_get_pipe_properties_uses_network_context(monkeypatch):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-pipe-properties", "--pipe", "P1"])
@@ -143,8 +134,8 @@ def test_network_get_pipe_properties_uses_network_context(monkeypatch):
assert payload["data"] == {"id": "P1"}
assert captured == {
"access_token": "abc",
"path": "/getpipeproperties/",
"params": {"network": "tjwater", "pipe": "P1"},
"path": "/pipes/properties",
"params": {"pipe": "P1"},
}
@@ -159,7 +150,6 @@ def test_network_get_all_pipes_properties_uses_network_context(monkeypatch):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-all-pipes-properties"])
@@ -170,8 +160,8 @@ def test_network_get_all_pipes_properties_uses_network_context(monkeypatch):
assert payload["data"] == [{"id": "P1"}]
assert captured == {
"access_token": "abc",
"path": "/getallpipeproperties/",
"params": {"network": "tjwater"},
"path": "/pipes",
"params": {},
}
@@ -186,7 +176,6 @@ def test_network_get_reservoir_properties_uses_network_context(monkeypatch):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-reservoir-properties", "--reservoir", "R1"])
@@ -197,8 +186,8 @@ def test_network_get_reservoir_properties_uses_network_context(monkeypatch):
assert payload["data"] == {"id": "R1"}
assert captured == {
"access_token": "abc",
"path": "/getreservoirproperties/",
"params": {"network": "tjwater", "reservoir": "R1"},
"path": "/reservoirs/properties",
"params": {"reservoir": "R1"},
}
@@ -213,7 +202,6 @@ def test_network_get_all_reservoir_properties_uses_network_context(monkeypatch):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-all-reservoirs-properties"])
@@ -224,8 +212,8 @@ def test_network_get_all_reservoir_properties_uses_network_context(monkeypatch):
assert payload["data"] == [{"id": "R1"}]
assert captured == {
"access_token": "abc",
"path": "/getallreservoirproperties/",
"params": {"network": "tjwater"},
"path": "/reservoirs",
"params": {},
}
@@ -240,7 +228,6 @@ def test_network_get_tank_properties_uses_network_context(monkeypatch):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-tank-properties", "--tank", "T1"])
@@ -251,8 +238,8 @@ def test_network_get_tank_properties_uses_network_context(monkeypatch):
assert payload["data"] == {"id": "T1"}
assert captured == {
"access_token": "abc",
"path": "/gettankproperties/",
"params": {"network": "tjwater", "tank": "T1"},
"path": "/tanks/properties",
"params": {"tank": "T1"},
}
@@ -267,7 +254,6 @@ def test_network_get_all_tank_properties_uses_network_context(monkeypatch):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-all-tanks-properties"])
@@ -278,8 +264,8 @@ def test_network_get_all_tank_properties_uses_network_context(monkeypatch):
assert payload["data"] == [{"id": "T1"}]
assert captured == {
"access_token": "abc",
"path": "/getalltankproperties/",
"params": {"network": "tjwater"},
"path": "/tanks",
"params": {},
}
@@ -294,7 +280,6 @@ def test_network_get_pump_properties_uses_network_context(monkeypatch):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-pump-properties", "--pump", "PU1"])
@@ -305,8 +290,8 @@ def test_network_get_pump_properties_uses_network_context(monkeypatch):
assert payload["data"] == {"id": "PU1"}
assert captured == {
"access_token": "abc",
"path": "/getpumpproperties/",
"params": {"network": "tjwater", "pump": "PU1"},
"path": "/pumps/properties",
"params": {"pump": "PU1"},
}
@@ -321,7 +306,6 @@ def test_network_get_all_pump_properties_uses_network_context(monkeypatch):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-all-pumps-properties"])
@@ -332,8 +316,8 @@ def test_network_get_all_pump_properties_uses_network_context(monkeypatch):
assert payload["data"] == [{"id": "PU1"}]
assert captured == {
"access_token": "abc",
"path": "/getallpumpproperties/",
"params": {"network": "tjwater"},
"path": "/pumps",
"params": {},
}
@@ -348,7 +332,6 @@ def test_network_get_valve_properties_uses_network_context(monkeypatch):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-valve-properties", "--valve", "V1"])
@@ -359,8 +342,8 @@ def test_network_get_valve_properties_uses_network_context(monkeypatch):
assert payload["data"] == {"id": "V1"}
assert captured == {
"access_token": "abc",
"path": "/getvalveproperties/",
"params": {"network": "tjwater", "valve": "V1"},
"path": "/valves/properties",
"params": {"valve": "V1"},
}
@@ -375,7 +358,6 @@ def test_network_get_all_valve_properties_uses_network_context(monkeypatch):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-all-valves-properties"])
@@ -386,8 +368,8 @@ def test_network_get_all_valve_properties_uses_network_context(monkeypatch):
assert payload["data"] == [{"id": "V1"}]
assert captured == {
"access_token": "abc",
"path": "/getallvalveproperties/",
"params": {"network": "tjwater"},
"path": "/valves",
"params": {},
}
@@ -525,7 +507,6 @@ def test_realtime_property_help_lists_supported_fields():
def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: Path):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "demo")
burst_path = tmp_path / "burst.json"
burst_path.write_text('[{"id":"P1","size":3.5}]', encoding="utf-8")
@@ -559,7 +540,6 @@ def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path:
def test_analysis_contaminant_sends_required_scheme_name(monkeypatch):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "demo")
captured = {}
def fake_request(**kwargs):
@@ -588,7 +568,6 @@ def test_analysis_contaminant_sends_required_scheme_name(monkeypatch):
assert result.exit_code == 0
assert captured["params"] == {
"network": "demo",
"start_time": "2025-01-02T03:04:05+08:00",
"source": "N1",
"concentration": 10.0,
@@ -600,7 +579,6 @@ def test_analysis_contaminant_sends_required_scheme_name(monkeypatch):
def test_analysis_flushing_sends_required_scheme_name(monkeypatch, tmp_path: Path):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "demo")
captured = {}
valve_path = tmp_path / "valve.json"
valve_path.write_text('[{"valve":"V1","opening":0.5}]', encoding="utf-8")
@@ -633,11 +611,10 @@ def test_analysis_flushing_sends_required_scheme_name(monkeypatch, tmp_path: Pat
assert result.exit_code == 0
assert captured["params"] == {
"network": "demo",
"start_time": "2025-01-02T03:04:05+08:00",
"valves": ["V1"],
"valves_k": [0.5],
"drainage_node_ID": "N1",
"drainage_node_id": "N1",
"flush_flow": 100.0,
"duration": 900,
"scheme_name": "flush_case_01",
@@ -647,7 +624,6 @@ def test_analysis_flushing_sends_required_scheme_name(monkeypatch, tmp_path: Pat
def test_analysis_valve_close_sends_required_scheme_name(monkeypatch):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "demo")
captured = {}
def fake_request(**kwargs):
@@ -676,7 +652,6 @@ def test_analysis_valve_close_sends_required_scheme_name(monkeypatch):
assert result.exit_code == 0
assert captured["params"] == {
"network": "demo",
"start_time": "2025-01-02T03:04:05+08:00",
"valves": ["V1"],
"duration": 900,
@@ -687,7 +662,6 @@ def test_analysis_valve_close_sends_required_scheme_name(monkeypatch):
def test_analysis_contaminant_requires_scheme(monkeypatch, capsys):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "demo")
exit_code = main(
[
@@ -713,7 +687,6 @@ def test_analysis_contaminant_requires_scheme(monkeypatch, capsys):
def test_analysis_flushing_requires_scheme(monkeypatch, tmp_path: Path, capsys):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "demo")
valve_path = tmp_path / "valve.json"
valve_path.write_text('[{"valve":"V1","opening":0.5}]', encoding="utf-8")
@@ -741,7 +714,6 @@ def test_analysis_flushing_requires_scheme(monkeypatch, tmp_path: Path, capsys):
def test_analysis_valve_close_requires_scheme(monkeypatch, capsys):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "demo")
exit_code = main(
[
@@ -921,7 +893,6 @@ def test_main_bare_analysis_returns_typer_help_without_json_error(capsys):
def test_simulation_run_translates_rfc3339(monkeypatch):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "demo")
captured = {}
def fake_request(**kwargs):
@@ -944,7 +915,6 @@ def test_simulation_run_translates_rfc3339(monkeypatch):
assert result.exit_code == 0
assert captured["json"] == {
"name": "demo",
"start_time": "2025-01-02T03:04:05+08:00",
"duration": 30,
}
+50 -78
View File
@@ -27,8 +27,6 @@ from .core import (
parse_time_with_timezone,
parse_valve_setting_file,
request_json,
require_network,
require_username,
resolve_scheme,
)
from .option_types import DataSource, ValveMode
@@ -41,11 +39,9 @@ def simulation_run(
duration: Annotated[int, typer.Option("--duration", help="持续分钟数")],
) -> None:
runtime = runtime_context(ctx)
network = require_network(runtime)
parsed = parse_time_with_timezone(start_time, option_name="--start-time")
end_time = (parsed + timedelta(minutes=duration)).isoformat()
body = {
"name": network,
"start_time": parsed.replace(microsecond=0).isoformat(),
"duration": duration,
}
@@ -53,10 +49,9 @@ def simulation_run(
ctx,
summary="触发模拟成功",
method="POST",
path="/simulations/run-by-date",
path="/simulation-runs",
json_body=body,
require_auth=True,
require_network_ctx=True,
next_commands=[
f"tjwater-cli data timeseries realtime links --start-time {parsed.isoformat()} --end-time {end_time}",
f"tjwater-cli data timeseries realtime nodes --start-time {parsed.isoformat()} --end-time {end_time}",
@@ -76,9 +71,8 @@ def analysis_burst(
ids, sizes = parse_burst_file(burst_file)
scheme_name = resolve_scheme(runtime, scheme, required=True)
params = {
"network": require_network(runtime),
"modify_pattern_start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"burst_ID": ids,
"burst_id": ids,
"burst_size": sizes,
"modify_total_duration": duration,
"scheme_name": scheme_name,
@@ -86,11 +80,10 @@ def analysis_burst(
emit_api(
ctx,
summary="爆管分析执行成功",
method="GET",
path="/burst-analysis",
method="POST",
path="/burst-analyses",
params=params,
require_auth=True,
require_network_ctx=True,
next_commands=[
f"tjwater-cli data scheme get --name {scheme_name}",
"tjwater-cli data scheme list",
@@ -110,7 +103,6 @@ def analysis_valve(
scheme: Annotated[str | None, typer.Option("--scheme", help="close 模式的方案名称")] = None,
) -> None:
runtime = runtime_context(ctx)
network = require_network(runtime)
if mode == ValveMode.CLOSE:
if not start_time or not valve:
raise CLIError(
@@ -120,7 +112,6 @@ def analysis_valve(
exit_code=2,
)
params = {
"network": network,
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"valves": valve,
"duration": duration or 900,
@@ -129,11 +120,10 @@ def analysis_valve(
emit_api(
ctx,
summary="阀门关闭分析执行成功",
method="GET",
path="/valve_close_analysis/",
method="POST",
path="/valve-isolation-analyses",
params=params,
require_auth=True,
require_network_ctx=True,
)
return
if mode == ValveMode.ISOLATION:
@@ -144,17 +134,16 @@ def analysis_valve(
message="isolation mode requires at least one --element",
exit_code=2,
)
params = {"network": network, "accident_element": element}
params = {"accident_element": element}
if disabled_valve:
params["disabled_valves"] = disabled_valve
emit_api(
ctx,
summary="阀门隔离分析执行成功",
method="GET",
path="/valve-isolation-analysis",
method="POST",
path="/valve-isolation-analyses",
params=params,
require_auth=True,
require_network_ctx=True,
)
return
raise AssertionError(f"unreachable valve mode: {mode}")
@@ -173,11 +162,10 @@ def analysis_flushing(
runtime = runtime_context(ctx)
valves, openings = parse_valve_setting_file(valve_setting_file)
params = {
"network": require_network(runtime),
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"valves": valves,
"valves_k": openings,
"drainage_node_ID": drainage_node,
"drainage_node_id": drainage_node,
"flush_flow": flow,
"duration": duration or 900,
"scheme_name": resolve_scheme(runtime, scheme, required=True),
@@ -185,11 +173,10 @@ def analysis_flushing(
emit_api(
ctx,
summary="冲洗分析执行成功",
method="GET",
path="/flushing-analysis",
method="POST",
path="/flushing-analyses",
params=params,
require_auth=True,
require_network_ctx=True,
)
@@ -203,15 +190,13 @@ def analysis_age(
emit_api(
ctx,
summary="水龄分析执行成功",
method="GET",
path="/age_analysis/",
method="POST",
path="/water-age-analyses",
params={
"network": require_network(runtime),
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"duration": duration,
},
require_auth=True,
require_network_ctx=True,
)
@@ -227,7 +212,6 @@ def analysis_contaminant(
) -> None:
runtime = runtime_context(ctx)
params = {
"network": require_network(runtime),
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"source": source_node,
"concentration": concentration,
@@ -239,11 +223,10 @@ def analysis_contaminant(
emit_api(
ctx,
summary="污染物模拟执行成功",
method="GET",
path="/contaminant-simulation",
method="POST",
path="/contaminant-simulations",
params=params,
require_auth=True,
require_network_ctx=True,
)
@@ -256,21 +239,17 @@ def analysis_sensor_placement_kmeans(
) -> None:
runtime = runtime_context(ctx)
body = {
"name": require_network(runtime),
"scheme_name": resolve_scheme(runtime, scheme, required=True),
"sensor_number": count,
"min_diameter": min_diameter,
"username": require_username(runtime),
}
emit_api(
ctx,
summary="传感器选址执行成功",
method="POST",
path="/pressure_sensor_placement_kmeans/",
path="/pressure-sensor-placement-kmeans",
json_body=body,
require_auth=True,
require_network_ctx=True,
require_username_ctx=True,
)
@@ -283,7 +262,6 @@ def analysis_leakage_identify(
) -> None:
runtime = runtime_context(ctx)
body = {
"network": require_network(runtime),
"scada_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"scada_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
"scheme_name": resolve_scheme(runtime, scheme, required=True),
@@ -292,10 +270,9 @@ def analysis_leakage_identify(
ctx,
summary="漏损识别执行成功",
method="POST",
path="/leakage/identify/",
path="/leakage-identifications",
json_body=body,
require_auth=True,
require_network_ctx=True,
)
@@ -306,10 +283,11 @@ def analysis_leakage_schemes_list(ctx: typer.Context) -> None:
ctx,
summary="读取漏损方案列表成功",
method="GET",
path="/leakage/schemes/",
params={"network": require_network(runtime)},
path="/schemes",
params={
"scheme_type": "dma_leak_identification",
},
require_auth=True,
require_network_ctx=True,
)
@@ -323,10 +301,11 @@ def analysis_leakage_schemes_get(
ctx,
summary="读取漏损方案详情成功",
method="GET",
path=f"/leakage/schemes/{scheme_name}",
params={"network": require_network(runtime)},
path=f"/schemes/{scheme_name}",
params={
"scheme_type": "dma_leak_identification",
},
require_auth=True,
require_network_ctx=True,
)
@@ -339,7 +318,6 @@ def analysis_burst_detection_detect(
) -> None:
runtime = runtime_context(ctx)
body = {
"network": require_network(runtime),
"scada_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"scada_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
"scheme_name": resolve_scheme(runtime, scheme, required=True),
@@ -348,10 +326,9 @@ def analysis_burst_detection_detect(
ctx,
summary="爆管检测执行成功",
method="POST",
path="/burst-detection/detect/",
path="/burst-detections",
json_body=body,
require_auth=True,
require_network_ctx=True,
)
@@ -362,10 +339,11 @@ def analysis_burst_detection_schemes_list(ctx: typer.Context) -> None:
ctx,
summary="读取爆管检测方案列表成功",
method="GET",
path="/burst-detection/schemes/",
params={"network": require_network(runtime)},
path="/schemes",
params={
"scheme_type": "burst_detection",
},
require_auth=True,
require_network_ctx=True,
)
@@ -379,10 +357,11 @@ def analysis_burst_detection_schemes_get(
ctx,
summary="读取爆管检测方案详情成功",
method="GET",
path=f"/burst-detection/schemes/{scheme_name}",
params={"network": require_network(runtime)},
path=f"/schemes/{scheme_name}",
params={
"scheme_type": "burst_detection",
},
require_auth=True,
require_network_ctx=True,
)
@@ -404,7 +383,6 @@ def analysis_burst_location_locate(
pressure_payload = parse_optional_dataset_file(pressure_file, label="pressure") or {}
flow_payload = parse_optional_dataset_file(flow_file, label="flow") or {}
body = {
"network": require_network(runtime),
"scheme_name": resolve_scheme(runtime, scheme, required=True),
"data_source": data_source.value,
"scada_burst_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
@@ -424,10 +402,9 @@ def analysis_burst_location_locate(
ctx,
summary="爆管定位执行成功",
method="POST",
path="/burst-location/locate/",
path="/burst-locations",
json_body=body,
require_auth=True,
require_network_ctx=True,
)
@@ -438,10 +415,11 @@ def analysis_burst_location_schemes_list(ctx: typer.Context) -> None:
ctx,
summary="读取爆管定位方案列表成功",
method="GET",
path="/burst-location/schemes/",
params={"network": require_network(runtime)},
path="/schemes",
params={
"scheme_type": "burst_location",
},
require_auth=True,
require_network_ctx=True,
)
@@ -455,10 +433,11 @@ def analysis_burst_location_schemes_get(
ctx,
summary="读取爆管定位方案详情成功",
method="GET",
path=f"/burst-location/schemes/{scheme_name}",
params={"network": require_network(runtime)},
path=f"/schemes/{scheme_name}",
params={
"scheme_type": "burst_location",
},
require_auth=True,
require_network_ctx=True,
)
@@ -472,10 +451,9 @@ def analysis_risk_pipe_now(
ctx,
summary="读取当前管道风险成功",
method="GET",
path="/getpiperiskprobabilitynow/",
params={"network": require_network(runtime), "pipe_id": pipe},
path="/pipes/risk-probability-now",
params={"pipe_id": pipe},
require_auth=True,
require_network_ctx=True,
)
@@ -489,32 +467,26 @@ def analysis_risk_pipe_history(
ctx,
summary="读取历史管道风险成功",
method="GET",
path="/getpiperiskprobability/",
params={"network": require_network(runtime), "pipe_id": pipe},
path="/pipes/risk-probability",
params={"pipe_id": pipe},
require_auth=True,
require_network_ctx=True,
)
@analysis_risk_app.command("network")
def analysis_risk_network(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
network = require_network(runtime)
probabilities, duration_prob = request_json(
runtime,
method="GET",
path="/getnetworkpiperiskprobabilitynow/",
params={"network": network},
path="/network-pipe-risk-probability-nows",
require_auth=True,
require_network_ctx=True,
)
geometries, duration_geo = request_json(
runtime,
method="GET",
path="/getpiperiskprobabilitygeometries/",
params={"network": network},
path="/pipes/risk-probability-geometries",
require_auth=True,
require_network_ctx=True,
)
emit_success(
summary="读取全网风险成功",
+20 -30
View File
@@ -13,7 +13,7 @@ from .apps import (
data_timeseries_scheme_app,
)
from .common import emit_api, runtime_context
from .core import CLIError, parse_time_with_timezone, require_network, resolve_scheme
from .core import CLIError, parse_time_with_timezone, resolve_scheme
from .option_types import (
CompositeKind,
ElementType,
@@ -73,7 +73,7 @@ def data_realtime_links(
ctx,
summary="读取实时管道数据成功",
method="GET",
path="/realtime/links",
path="/timeseries/realtime/links",
params={
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
@@ -93,7 +93,7 @@ def data_realtime_nodes(
ctx,
summary="读取实时节点数据成功",
method="GET",
path="/realtime/nodes",
path="/timeseries/realtime/nodes",
params={
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
@@ -114,7 +114,7 @@ def data_realtime_simulation_by_id_time(
ctx,
summary="读取实时模拟数据成功",
method="GET",
path="/realtime/query/by-id-time",
path="/timeseries/realtime/simulation-results",
params={
"id": id,
"type": type.value,
@@ -137,7 +137,7 @@ def data_realtime_simulation_by_time_property(
ctx,
summary="读取实时属性聚合数据成功",
method="GET",
path="/realtime/query/by-time-property",
path="/timeseries/realtime/records",
params={
"type": type.value,
"query_time": parse_time_with_timezone(time, option_name="--time").isoformat(),
@@ -161,7 +161,7 @@ def data_scheme_links(
ctx,
summary="读取方案管道数据成功",
method="GET",
path="/scheme/links",
path="/timeseries/schemes/links",
params={
"scheme_name": resolve_scheme(runtime, scheme, required=True),
"scheme_type": _scheme_type_option(scheme_type),
@@ -189,7 +189,7 @@ def data_scheme_node_field(
ctx,
summary="读取方案节点字段成功",
method="GET",
path=f"/scheme/nodes/{node}/field",
path=f"/timeseries/schemes/nodes/{node}/field",
params={
"field": field,
"scheme_name": resolve_scheme(runtime, scheme, required=True),
@@ -233,7 +233,7 @@ def data_scheme_simulation(
ctx,
summary="读取方案单点模拟数据成功",
method="GET",
path="/scheme/query/by-id-time",
path="/timeseries/schemes/simulation-results",
params=params,
require_auth=True,
require_project=True,
@@ -253,7 +253,7 @@ def data_scheme_simulation(
ctx,
summary="读取方案属性聚合数据成功",
method="GET",
path="/scheme/query/by-scheme-time-property",
path="/timeseries/schemes/records",
params=params,
require_auth=True,
require_project=True,
@@ -270,7 +270,7 @@ def data_scada_query(
end_time: Annotated[str, typer.Option("--end-time", help="结束时间")],
field: Annotated[str | None, typer.Option("--field", help="字段名,仅支持 monitored_value|cleaned_value")] = None,
) -> None:
path = "/scada/by-ids-field-time-range" if field else "/scada/by-ids-time-range"
path = "/timeseries/scada-readings/fields" if field else "/timeseries/scada-readings"
params = {
"device_ids": ",".join(device_id),
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
@@ -334,7 +334,7 @@ def data_timeseries_composite(
ctx,
summary="读取复合 SCADA-模拟数据成功",
method="GET",
path="/composite/scada-simulation",
path="/timeseries/views/scada-simulations",
params=params,
require_auth=True,
require_project=True,
@@ -357,7 +357,7 @@ def data_timeseries_composite(
ctx,
summary="读取复合元素模拟数据成功",
method="GET",
path="/composite/element-simulation",
path="/timeseries/views/element-simulations",
params=params,
require_auth=True,
require_project=True,
@@ -377,7 +377,7 @@ def data_timeseries_composite(
ctx,
summary="读取元素关联 SCADA 数据成功",
method="GET",
path="/composite/element-scada",
path="/timeseries/views/element-scada-readings",
params=params,
require_auth=True,
require_project=True,
@@ -398,21 +398,19 @@ def data_composite_pipeline_health(
ctx,
summary="读取管道健康预测成功",
method="GET",
path="/composite/pipeline-health-prediction",
path="/pipeline-health-predictions",
params={
"network_name": require_network(runtime_context(ctx)),
"query_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
},
require_auth=True,
require_project=True,
require_network_ctx=True,
)
def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]:
mapping = {
("info", "get"): ("/getscadainfo/", {"id_param": "id"}),
("info", "list"): ("/getallscadainfo/", {}),
("info", "get"): ("/scada-info/detail", {"id_param": "id"}),
("info", "list"): ("/scada-info", {}),
}
result = mapping.get((kind, action))
if result is None:
@@ -433,7 +431,7 @@ def data_scada_get(
) -> None:
runtime = runtime_context(ctx)
path, meta = _scada_mapping(kind.value, "get")
params = {"network": require_network(runtime), meta["id_param"]: id}
params = {meta["id_param"]: id}
emit_api(
ctx,
summary="读取 SCADA 数据成功",
@@ -441,7 +439,6 @@ def data_scada_get(
path=path,
params=params,
require_auth=True,
require_network_ctx=True,
)
@@ -457,9 +454,7 @@ def data_scada_list(
summary="读取 SCADA 列表成功",
method="GET",
path=path,
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@@ -470,10 +465,8 @@ def data_scheme_schema(ctx: typer.Context) -> None:
ctx,
summary="读取方案 schema 成功",
method="GET",
path="/getschemeschema/",
params={"network": require_network(runtime)},
path="/network-schemas/scheme",
require_auth=True,
require_network_ctx=True,
)
@@ -487,10 +480,9 @@ def data_scheme_get(
ctx,
summary="读取方案成功",
method="GET",
path="/getscheme/",
params={"network": require_network(runtime), "schema_name": name},
path="/schemes/detail",
params={"schema_name": name},
require_auth=True,
require_network_ctx=True,
)
@@ -502,7 +494,5 @@ def data_scheme_list(ctx: typer.Context) -> None:
summary="读取方案列表成功",
method="GET",
path="/schemes",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
+34 -60
View File
@@ -5,8 +5,8 @@ from typing import Annotated
import typer
from .apps import component_option_app, network_app
from .common import emit_api, runtime_context
from .core import CLIError, require_network
from .common import emit_api
from .core import CLIError
from .option_types import ComponentOptionKind
@@ -15,15 +15,13 @@ def network_get_junction_properties(
ctx: typer.Context,
junction: Annotated[str, typer.Option("--junction", help="节点 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取节点属性成功",
method="GET",
path="/getjunctionproperties/",
params={"network": require_network(runtime), "junction": junction},
path="/junctions/properties",
params={"junction": junction},
require_auth=True,
require_network_ctx=True,
)
@@ -32,29 +30,25 @@ def network_get_pipe_properties(
ctx: typer.Context,
pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取管道属性成功",
method="GET",
path="/getpipeproperties/",
params={"network": require_network(runtime), "pipe": pipe},
path="/pipes/properties",
params={"pipe": pipe},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-all-pipes-properties")
def network_get_all_pipes_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取全部管道属性成功",
method="GET",
path="/getallpipeproperties/",
params={"network": require_network(runtime)},
path="/pipes",
params={},
require_auth=True,
require_network_ctx=True,
)
@@ -63,29 +57,25 @@ def network_get_reservoir_properties(
ctx: typer.Context,
reservoir: Annotated[str, typer.Option("--reservoir", help="水库 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取水库属性成功",
method="GET",
path="/getreservoirproperties/",
params={"network": require_network(runtime), "reservoir": reservoir},
path="/reservoirs/properties",
params={"reservoir": reservoir},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-all-reservoirs-properties")
def network_get_all_reservoir_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取全部水库属性成功",
method="GET",
path="/getallreservoirproperties/",
params={"network": require_network(runtime)},
path="/reservoirs",
params={},
require_auth=True,
require_network_ctx=True,
)
@@ -94,29 +84,25 @@ def network_get_tank_properties(
ctx: typer.Context,
tank: Annotated[str, typer.Option("--tank", help="水箱 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取水箱属性成功",
method="GET",
path="/gettankproperties/",
params={"network": require_network(runtime), "tank": tank},
path="/tanks/properties",
params={"tank": tank},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-all-tanks-properties")
def network_get_all_tank_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取全部水箱属性成功",
method="GET",
path="/getalltankproperties/",
params={"network": require_network(runtime)},
path="/tanks",
params={},
require_auth=True,
require_network_ctx=True,
)
@@ -125,29 +111,25 @@ def network_get_pump_properties(
ctx: typer.Context,
pump: Annotated[str, typer.Option("--pump", help="水泵 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取水泵属性成功",
method="GET",
path="/getpumpproperties/",
params={"network": require_network(runtime), "pump": pump},
path="/pumps/properties",
params={"pump": pump},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-all-pumps-properties")
def network_get_all_pump_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取全部水泵属性成功",
method="GET",
path="/getallpumpproperties/",
params={"network": require_network(runtime)},
path="/pumps",
params={},
require_auth=True,
require_network_ctx=True,
)
@@ -156,29 +138,25 @@ def network_get_valve_properties(
ctx: typer.Context,
valve: Annotated[str, typer.Option("--valve", help="阀门 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取阀门属性成功",
method="GET",
path="/getvalveproperties/",
params={"network": require_network(runtime), "valve": valve},
path="/valves/properties",
params={"valve": valve},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-all-valves-properties")
def network_get_all_valve_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取全部阀门属性成功",
method="GET",
path="/getallvalveproperties/",
params={"network": require_network(runtime)},
path="/valves",
params={},
require_auth=True,
require_network_ctx=True,
)
@@ -188,9 +166,8 @@ def component_option_schema(
kind: Annotated[ComponentOptionKind, typer.Option("--kind", help="选项类型,仅支持 time|energy|pump-energy|network")],
pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None,
) -> None:
runtime = runtime_context(ctx)
path = _component_option_path(kind.value, schema=True)
params = {"network": require_network(runtime)}
params: dict[str, str] = {}
if kind == ComponentOptionKind.PUMP_ENERGY and pump:
params["pump"] = pump
emit_api(
@@ -200,7 +177,6 @@ def component_option_schema(
path=path,
params=params,
require_auth=True,
require_network_ctx=True,
)
@@ -210,9 +186,8 @@ def component_option_get(
kind: Annotated[ComponentOptionKind, typer.Option("--kind", help="选项类型,仅支持 time|energy|pump-energy|network")],
pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None,
) -> None:
runtime = runtime_context(ctx)
path = _component_option_path(kind.value, schema=False)
params = {"network": require_network(runtime)}
params: dict[str, str] = {}
if kind == ComponentOptionKind.PUMP_ENERGY:
if not pump:
raise CLIError(
@@ -229,20 +204,19 @@ def component_option_get(
path=path,
params=params,
require_auth=True,
require_network_ctx=True,
)
def _component_option_path(kind: str, *, schema: bool) -> str:
routes = {
("time", True): "/gettimeschema",
("time", False): "/gettimeproperties/",
("energy", True): "/getenergyschema/",
("energy", False): "/getenergyproperties/",
("pump-energy", True): "/getpumpenergyschema/",
("pump-energy", False): "/getpumpenergyproperties//",
("network", True): "/getoptionschema/",
("network", False): "/getoptionproperties/",
("time", True): "/network-schemas/time",
("time", False): "/network-options/time",
("energy", True): "/network-schemas/energy",
("energy", False): "/network-options/energy",
("pump-energy", True): "/network-schemas/pump-energy",
("pump-energy", False): "/network-options/pump-energy",
("network", True): "/network-schemas/option",
("network", False): "/network-options",
}
path = routes.get((kind, schema))
if path is None:
-4
View File
@@ -38,8 +38,6 @@ def emit_api(
json_body: Any = None,
require_auth: bool = True,
require_project: bool = False,
require_network_ctx: bool = False,
require_username_ctx: bool = False,
next_commands: list[str] | None = None,
) -> None:
runtime = runtime_context(ctx)
@@ -51,8 +49,6 @@ def emit_api(
json_body=json_body,
require_auth=require_auth,
require_project=require_project,
require_network_ctx=require_network_ctx,
require_username_ctx=require_username_ctx,
)
emit_success(
summary=summary,
+23 -59
View File
@@ -17,8 +17,6 @@ SCHEMA_VERSION = "tjwater-cli/v1"
CLI_NAME = "tjwater-cli"
DEFAULT_TIMEOUT = 180
DEFAULT_SERVER = "http://192.168.1.114:8000"
class CLIError(Exception):
def __init__(
self,
@@ -46,8 +44,6 @@ class AuthContext:
server: str | None = None
access_token: str | None = None
project_id: str | None = None
username: str | None = None
network: str | None = None
headers: dict[str, str] = field(default_factory=dict)
@@ -97,8 +93,6 @@ def load_auth_context(auth_stdin: bool = False) -> AuthContext:
"server": os.getenv("TJWATER_SERVER"),
"access_token": os.getenv("TJWATER_ACCESS_TOKEN"),
"project_id": os.getenv("TJWATER_PROJECT_ID"),
"username": os.getenv("TJWATER_USERNAME"),
"network": os.getenv("TJWATER_NETWORK"),
"headers": json.loads(extra_headers) if extra_headers else {},
}
@@ -115,8 +109,6 @@ def load_auth_context(auth_stdin: bool = False) -> AuthContext:
server=_pick(raw, "server", "base_url"),
access_token=_pick(raw, "access_token", "token", "accessToken"),
project_id=_pick(raw, "project_id", "projectId", "x_project_id"),
username=_pick(raw, "username", "preferred_username"),
network=_pick(raw, "network", "project_code", "projectCode", "project"),
headers={str(key): str(value) for key, value in headers.items()},
)
@@ -175,30 +167,6 @@ def require_project_id(ctx: RuntimeContext) -> str:
)
def require_network(ctx: RuntimeContext) -> str:
if ctx.auth.network:
return ctx.auth.network
raise CLIError(
"认证失败",
code="NETWORK_CONTEXT_REQUIRED",
message="missing network in auth context for legacy network-based endpoints",
exit_code=3,
next_commands=["add network to auth context"],
)
def require_username(ctx: RuntimeContext) -> str:
if ctx.auth.username:
return ctx.auth.username
raise CLIError(
"认证失败",
code="USERNAME_CONTEXT_REQUIRED",
message="missing username in auth context",
exit_code=3,
next_commands=["add username to auth context"],
)
def resolve_scheme(ctx: RuntimeContext, explicit_scheme: str | None, *, required: bool = False) -> str | None:
scheme = explicit_scheme or ctx.scheme
if required and not scheme:
@@ -254,14 +222,14 @@ def parse_burst_file(path: Path) -> tuple[list[str], list[float]]:
raw = read_json_input(path, label="burst")
if isinstance(raw, dict) and "bursts" in raw:
raw = raw["bursts"]
if isinstance(raw, dict) and "burst_ID" in raw and "burst_size" in raw:
ids = [str(item) for item in raw["burst_ID"]]
if isinstance(raw, dict) and "burst_id" in raw and "burst_size" in raw:
ids = [str(item) for item in raw["burst_id"]]
sizes = [float(item) for item in raw["burst_size"]]
if len(ids) != len(sizes):
raise CLIError(
"CLI 参数错误",
code="BURST_FILE_INVALID",
message="burst file burst_ID and burst_size must have the same length",
message="burst file burst_id and burst_size must have the same length",
exit_code=2,
)
return ids, sizes
@@ -282,7 +250,7 @@ def parse_burst_file(path: Path) -> tuple[list[str], list[float]]:
raise CLIError(
"CLI 参数错误",
code="BURST_FILE_INVALID",
message="burst file must be a JSON array or object with burst_ID/burst_size",
message="burst file must be a JSON array or object with burst_id/burst_size",
exit_code=2,
)
@@ -404,12 +372,13 @@ def _parse_response_body(response: requests.Response) -> Any:
return {}
def _with_network_param(params: dict[str, Any] | None, network: str) -> dict[str, Any]:
params = dict(params or {})
if "network" in params or "network_name" in params or "name" in params:
return params
params["network"] = network
return params
def _prepare_public_request(
method: str,
path: str,
params: dict[str, Any] | None,
json_body: Any,
) -> tuple[str, str, dict[str, Any] | None, Any]:
return method.upper(), path.rstrip("/") or "/", params or None, json_body
def request_json(
@@ -421,18 +390,14 @@ def request_json(
json_body: Any = None,
require_auth: bool = True,
require_project: bool = False,
require_network_ctx: bool = False,
require_username_ctx: bool = False,
) -> tuple[Any, int]:
require_server(ctx)
network = None
if require_network_ctx:
network = require_network(ctx)
if require_username_ctx:
require_username(ctx)
if network and (params is not None or json_body is None):
params = _with_network_param(params, network)
method, path, params, json_body = _prepare_public_request(
method,
path,
params,
json_body,
)
url = f"{require_server(ctx)}/api/v1{path}"
headers = build_headers(ctx, require_auth=require_auth, require_project=require_project)
started = time.monotonic()
@@ -482,15 +447,14 @@ def request_bytes(
params: dict[str, Any] | None = None,
require_auth: bool = True,
require_project: bool = False,
require_network_ctx: bool = False,
) -> tuple[bytes, int]:
require_server(ctx)
network = None
if require_network_ctx:
network = require_network(ctx)
if network:
params = _with_network_param(params, network)
method, path, params, _ = _prepare_public_request(
method,
path,
params,
None,
)
url = f"{require_server(ctx)}/api/v1{path}"
headers = build_headers(ctx, require_auth=require_auth, require_project=require_project)
started = time.monotonic()
+35 -35
View File
@@ -35,73 +35,73 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("network", "get-junction-properties"): CommandDoc(
path=("network", "get-junction-properties"),
summary="读取节点属性",
description="调用 /getjunctionproperties/",
description="调用 GET /api/v1/junctions/{junction_id}/properties。",
options=(CommandOptionDoc("junction", "节点 ID", required=True),),
examples=("tjwater-cli network get-junction-properties --junction J1",),
),
("network", "get-pipe-properties"): CommandDoc(
path=("network", "get-pipe-properties"),
summary="读取管道属性",
description="调用 /getpipeproperties/",
description="调用 GET /api/v1/pipes/{pipe_id}/properties。",
options=(CommandOptionDoc("pipe", "管道 ID", required=True),),
examples=("tjwater-cli network get-pipe-properties --pipe P1",),
),
("network", "get-all-pipes-properties"): CommandDoc(
path=("network", "get-all-pipes-properties"),
summary="读取全部管道属性",
description="调用 /getallpipeproperties/",
description="调用 GET /api/v1/pipes/properties。",
examples=("tjwater-cli network get-all-pipes-properties",),
),
("network", "get-reservoir-properties"): CommandDoc(
path=("network", "get-reservoir-properties"),
summary="读取水库属性",
description="调用 /getreservoirproperties/",
description="调用 GET /api/v1/reservoirs/{reservoir_id}/properties。",
options=(CommandOptionDoc("reservoir", "水库 ID", required=True),),
examples=("tjwater-cli network get-reservoir-properties --reservoir R1",),
),
("network", "get-all-reservoirs-properties"): CommandDoc(
path=("network", "get-all-reservoirs-properties"),
summary="读取全部水库属性",
description="调用 /getallreservoirproperties/",
description="调用 GET /api/v1/reservoirs/properties。",
examples=("tjwater-cli network get-all-reservoirs-properties",),
),
("network", "get-tank-properties"): CommandDoc(
path=("network", "get-tank-properties"),
summary="读取水箱属性",
description="调用 /gettankproperties/",
description="调用 GET /api/v1/tanks/{tank_id}/properties。",
options=(CommandOptionDoc("tank", "水箱 ID", required=True),),
examples=("tjwater-cli network get-tank-properties --tank T1",),
),
("network", "get-all-tanks-properties"): CommandDoc(
path=("network", "get-all-tanks-properties"),
summary="读取全部水箱属性",
description="调用 /getalltankproperties/",
description="调用 GET /api/v1/tanks/properties。",
examples=("tjwater-cli network get-all-tanks-properties",),
),
("network", "get-pump-properties"): CommandDoc(
path=("network", "get-pump-properties"),
summary="读取水泵属性",
description="调用 /getpumpproperties/",
description="调用 GET /api/v1/pumps/{pump_id}/properties。",
options=(CommandOptionDoc("pump", "水泵 ID", required=True),),
examples=("tjwater-cli network get-pump-properties --pump PU1",),
),
("network", "get-all-pumps-properties"): CommandDoc(
path=("network", "get-all-pumps-properties"),
summary="读取全部水泵属性",
description="调用 /getallpumpproperties/",
description="调用 GET /api/v1/pumps/properties。",
examples=("tjwater-cli network get-all-pumps-properties",),
),
("network", "get-valve-properties"): CommandDoc(
path=("network", "get-valve-properties"),
summary="读取阀门属性",
description="调用 /getvalveproperties/",
description="调用 GET /api/v1/valves/{valve_id}/properties。",
options=(CommandOptionDoc("valve", "阀门 ID", required=True),),
examples=("tjwater-cli network get-valve-properties --valve V1",),
),
("network", "get-all-valves-properties"): CommandDoc(
path=("network", "get-all-valves-properties"),
summary="读取全部阀门属性",
description="调用 /getallvalveproperties/",
description="调用 GET /api/v1/valves/properties。",
examples=("tjwater-cli network get-all-valves-properties",),
),
("component", "option", "schema"): CommandDoc(
@@ -137,7 +137,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("simulation", "run"): CommandDoc(
path=("simulation", "run"),
summary="触发指定绝对时间的模拟运行",
description="把显式带时区的 RFC3339 start-time 直接传给 /simulations/run-by-date;服务端按带时区时间处理并统一按 UTC 存储结果,实时数据需后续通过 data timeseries 在对应时间段查询。duration 单位为分钟。",
description="把显式带时区的 RFC3339 start-time 直接传给 POST /api/v1/simulation-runs;服务端按带时区时间处理并统一按 UTC 存储结果,实时数据需后续通过 data timeseries 在对应时间段查询。duration 单位为分钟。",
options=(
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
CommandOptionDoc("duration", "持续分钟数", required=True),
@@ -152,7 +152,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("analysis", "burst"): CommandDoc(
path=("analysis", "burst"),
summary="执行爆管分析",
description="读取 burst-file 并转换为 burst_ID[] / burst_size[];接口本身只返回分析执行结果,方案数据需后续通过 data scheme 命令获取。duration 单位为秒。",
description="读取 burst-file burst_id[] / burst_size[] 并调用 POST /api/v1/burst-analyses;接口本身只返回分析执行结果,方案数据需后续通过 data scheme 命令获取。duration 单位为秒。",
options=(
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
CommandOptionDoc("duration", "持续秒数", required=True),
@@ -201,7 +201,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("analysis", "age"): CommandDoc(
path=("analysis", "age"),
summary="执行水龄分析",
description="调用 /age_analysis/。duration 单位为秒。",
description="调用 POST /api/v1/water-age-analyses。duration 单位为秒。",
options=(
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
CommandOptionDoc("duration", "持续秒数", required=True),
@@ -211,7 +211,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("analysis", "contaminant"): CommandDoc(
path=("analysis", "contaminant"),
summary="执行污染物模拟",
description="调用 /contaminant-simulation。duration 单位为秒。",
description="调用 POST /api/v1/contaminant-simulations。duration 单位为秒。",
options=(
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
CommandOptionDoc("duration", "持续秒数", required=True),
@@ -247,19 +247,19 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("analysis", "leakage", "schemes", "list"): CommandDoc(
path=("analysis", "leakage", "schemes", "list"),
summary="列出漏损方案",
description="调用 /leakage/schemes/",
description="调用 GET /api/v1/schemes,并传入 scheme_type=dma_leak_identification",
examples=("tjwater-cli analysis leakage schemes list",),
),
("analysis", "leakage", "schemes", "get"): CommandDoc(
path=("analysis", "leakage", "schemes", "get"),
summary="读取漏损方案详情",
description="调用 /leakage/schemes/{scheme_name}",
description="调用 GET /api/v1/schemes/{scheme_name},并传入 scheme_type=dma_leak_identification",
examples=("tjwater-cli analysis leakage schemes get my_scheme",),
),
("analysis", "burst-detection", "detect"): CommandDoc(
path=("analysis", "burst-detection", "detect"),
summary="执行爆管检测",
description="调用 /burst-detection/detect/",
description="调用 POST /api/v1/burst-detections",
options=(
CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True),
CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True),
@@ -270,19 +270,19 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("analysis", "burst-detection", "schemes", "list"): CommandDoc(
path=("analysis", "burst-detection", "schemes", "list"),
summary="列出爆管检测方案",
description="调用 /burst-detection/schemes/",
description="调用 GET /api/v1/schemes,并传入 scheme_type=burst_detection。",
examples=("tjwater-cli analysis burst-detection schemes list",),
),
("analysis", "burst-detection", "schemes", "get"): CommandDoc(
path=("analysis", "burst-detection", "schemes", "get"),
summary="读取爆管检测方案详情",
description="调用 /burst-detection/schemes/{scheme_name}",
description="调用 GET /api/v1/schemes/{scheme_name},并传入 scheme_type=burst_detection",
examples=("tjwater-cli analysis burst-detection schemes get my_scheme",),
),
("analysis", "burst-location", "locate"): CommandDoc(
path=("analysis", "burst-location", "locate"),
summary="执行爆管定位",
description="调用 /burst-location/locate/;需要 burst-leakage。支持 monitoring 和 simulation 两种数据源。",
description="调用 POST /api/v1/burst-locations;需要 burst-leakage。支持 monitoring 和 simulation 两种数据源。",
options=(
CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True),
CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True),
@@ -303,26 +303,26 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("analysis", "burst-location", "schemes", "list"): CommandDoc(
path=("analysis", "burst-location", "schemes", "list"),
summary="列出爆管定位方案",
description="调用 /burst-location/schemes/",
description="调用 GET /api/v1/schemes,并传入 scheme_type=burst_location。",
examples=("tjwater-cli analysis burst-location schemes list",),
),
("analysis", "burst-location", "schemes", "get"): CommandDoc(
path=("analysis", "burst-location", "schemes", "get"),
summary="读取爆管定位方案详情",
description="调用 /burst-location/schemes/{scheme_name}",
description="调用 GET /api/v1/schemes/{scheme_name},并传入 scheme_type=burst_location",
examples=("tjwater-cli analysis burst-location schemes get my_scheme",),
),
("analysis", "risk", "pipe-now"): CommandDoc(
path=("analysis", "risk", "pipe-now"),
summary="读取单条管道当前风险",
description="调用 /getpiperiskprobabilitynow/",
description="调用 GET /api/v1/pipes/risk-probability-now。",
options=(CommandOptionDoc("pipe", "管道 ID", required=True),),
examples=("tjwater-cli analysis risk pipe-now --pipe P1",),
),
("analysis", "risk", "pipe-history"): CommandDoc(
path=("analysis", "risk", "pipe-history"),
summary="读取单条管道历史风险",
description="调用 /getpiperiskprobability/",
description="调用 GET /api/v1/pipes/risk-probability。",
options=(CommandOptionDoc("pipe", "管道 ID", required=True),),
examples=("tjwater-cli analysis risk pipe-history --pipe P1",),
),
@@ -335,7 +335,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("data", "timeseries", "realtime", "links"): CommandDoc(
path=("data", "timeseries", "realtime", "links"),
summary="查询实时管道时序",
description="调用 /realtime/links。",
description="调用 GET /api/v1/timeseries/realtime/links。",
options=(
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
@@ -345,7 +345,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("data", "timeseries", "realtime", "nodes"): CommandDoc(
path=("data", "timeseries", "realtime", "nodes"),
summary="查询实时节点时序",
description="调用 /realtime/nodes。",
description="调用 GET /api/v1/timeseries/realtime/nodes。",
options=(
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
@@ -355,7 +355,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("data", "timeseries", "realtime", "simulation-by-id-time"): CommandDoc(
path=("data", "timeseries", "realtime", "simulation-by-id-time"),
summary="按元素和时间查询实时模拟结果",
description="调用 /realtime/query/by-id-time",
description="调用 GET /api/v1/timeseries/realtime/by-element",
options=(
CommandOptionDoc("id", "元素 ID", required=True),
CommandOptionDoc("type", "元素类型:pipe 或 junctionlinks/nodes 是独立子命令,不是 type 取值", required=True),
@@ -369,7 +369,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("data", "timeseries", "realtime", "simulation-by-time-property"): CommandDoc(
path=("data", "timeseries", "realtime", "simulation-by-time-property"),
summary="按时间和属性查询实时模拟结果",
description="调用 /realtime/query/by-time-property。pipe 属性:flow、friction、headloss、quality、reaction、setting、status、velocityjunction 属性:actual_demand、total_head、pressure、quality。",
description="调用 GET /api/v1/timeseries/realtime/by-property。pipe 属性:flow、friction、headloss、quality、reaction、setting、status、velocityjunction 属性:actual_demand、total_head、pressure、quality。",
options=(
CommandOptionDoc("type", "元素类型:pipe 或 junctionlinks/nodes 是独立子命令,不是 type 取值", required=True),
CommandOptionDoc("time", "显式带时区的查询时间", required=True),
@@ -380,7 +380,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("data", "timeseries", "scheme", "links"): CommandDoc(
path=("data", "timeseries", "scheme", "links"),
summary="查询方案管道时序",
description="调用 /scheme/links。",
description="调用 GET /api/v1/timeseries/schemes/links。",
options=(
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
@@ -392,7 +392,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("data", "timeseries", "scheme", "node-field"): CommandDoc(
path=("data", "timeseries", "scheme", "node-field"),
summary="查询方案节点字段时序",
description="调用 /scheme/nodes/{node_id}/field。field 仅支持 actual_demand、total_head、pressure、quality。",
description="调用 GET /api/v1/timeseries/schemes/nodes/{node_id}/{field}。field 仅支持 actual_demand、total_head、pressure、quality。",
options=(
CommandOptionDoc("node", "节点 ID", required=True),
CommandOptionDoc("field", "字段名:actual_demand、total_head、pressure、quality", required=True),
@@ -458,7 +458,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("data", "timeseries", "composite", "pipeline-health"): CommandDoc(
path=("data", "timeseries", "composite", "pipeline-health"),
summary="查询管道健康预测",
description="调用 /composite/pipeline-health-prediction。",
description="调用 GET /api/v1/pipeline-health-predictions",
options=(
CommandOptionDoc("pipe", "管道 ID", required=True),
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
@@ -486,20 +486,20 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("data", "scheme", "schema"): CommandDoc(
path=("data", "scheme", "schema"),
summary="读取方案 schema",
description="调用 /getschemeschema/",
description="调用 GET /api/v1/network-schemas/scheme",
examples=("tjwater-cli data scheme schema",),
),
("data", "scheme", "get"): CommandDoc(
path=("data", "scheme", "get"),
summary="读取单条方案",
description="调用 /getscheme/",
description="调用 GET /api/v1/schemes/detail",
options=(CommandOptionDoc("name", "方案名称", required=True),),
examples=("tjwater-cli data scheme get --name my_scheme",),
),
("data", "scheme", "list"): CommandDoc(
path=("data", "scheme", "list"),
summary="列出方案",
description="调用 /schemes。",
description="调用 GET /api/v1/schemes。",
examples=("tjwater-cli data scheme list",),
),
}
+3 -3
View File
@@ -202,11 +202,11 @@ app/api/v1/endpoints/risk.py
| `tjwater-cli analysis contaminant --start-time TIME --duration SEC --source-node NODE --concentration VALUE --scheme SCHEME [--pattern PATTERN]` | `GET /contaminant-simulation` | 污染物模拟 |
| `tjwater-cli analysis sensor-placement kmeans --count N` | `GET /pressuresensorplacementkmeans/` | 基于 kmeans 的传感器放置分析;不包含创建方案 |
| `tjwater-cli analysis leakage identify --scheme SCHEME --start-time TIME --end-time TIME` | `POST /leakage/identify/` | 漏损识别 |
| `tjwater-cli analysis leakage schemes list\|get` | `GET /leakage/schemes/``GET /leakage/schemes/{scheme_name}` | 漏损方案查询 |
| `tjwater-cli analysis leakage schemes list\|get` | `GET /schemes?scheme_type=dma_leak_identification``GET /schemes/{scheme_name}?scheme_type=dma_leak_identification` | 漏损方案查询 |
| `tjwater-cli analysis burst-detection detect --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-detection/detect/` | 爆管检测 |
| `tjwater-cli analysis burst-detection schemes list\|get` | `GET /burst-detection/schemes/``GET /burst-detection/schemes/{scheme_name}` | 爆管检测方案查询 |
| `tjwater-cli analysis burst-detection schemes list\|get` | `GET /schemes?scheme_type=burst_detection``GET /schemes/{scheme_name}?scheme_type=burst_detection` | 爆管检测方案查询 |
| `tjwater-cli analysis burst-location locate --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-location/locate/` | 爆管定位 |
| `tjwater-cli analysis burst-location schemes list\|get` | `GET /burst-location/schemes/``GET /burst-location/schemes/{scheme_name}` | 爆管定位方案查询 |
| `tjwater-cli analysis burst-location schemes list\|get` | `GET /schemes?scheme_type=burst_location``GET /schemes/{scheme_name}?scheme_type=burst_location` | 爆管定位方案查询 |
| `tjwater-cli analysis risk pipe-now --pipe PIPE` | `GET /getpiperiskprobabilitynow/` | 单条管道当前风险 |
| `tjwater-cli analysis risk pipe-history --pipe PIPE` | `GET /getpiperiskprobability/` | 单条管道历史风险 |
| `tjwater-cli analysis risk network` | `GET /getnetworkpiperiskprobabilitynow/``GET /getpiperiskprobabilitygeometries/` | 当前 project 全网风险 |
+9
View File
@@ -0,0 +1,9 @@
{
"contract_version": "1.0.0",
"contracts": {
"server": {
"file": "server-v1.openapi.json",
"sha256": "d80a968d281fdb2953364a5979c2d61fda5151a1e1759c01cc96780b11a6d56c"
}
}
}
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
# TJWater REST API v1
This contract is the public API contract for coordinated TJWater
Server, Agent, Frontend, and CLI releases.
- Paths use lowercase kebab-case, have no trailing slash, and identify
resources rather than handler actions.
- JSON fields, query parameters, and path parameter names use snake_case.
- Project-scoped requests use `X-Project-Id`; `network` query parameters
are not part of the public contract.
- `GET` is read-only. Synchronous analysis and simulation requests use
`POST` and clients must not retry them automatically.
- JSON errors use `application/problem+json`.
- The static OpenAPI file and `contracts/manifest.json` are release
artifacts even when production runtime documentation is disabled.
Generate and validate the contract with:
```bash
conda run -n server python scripts/export_openapi.py
conda run -n server python scripts/check_openapi.py
```
+104
View File
@@ -0,0 +1,104 @@
# Keycloak 登录主题
`themes/tjwater` 是 TJWater 智慧水务平台的 Keycloak 登录主题。主题继承
`keycloak.v2`,只覆盖样式、消息和本地 SVG 资源,不修改认证模板或认证流程。
## 在管理控制台切换登录主题
确认 `themes/tjwater` 已挂载到容器的
`/opt/keycloak/themes/tjwater`,然后按以下步骤切换:
1. 打开 Keycloak 管理控制台:
`http://<Keycloak 地址>:<端口>/admin/`
2. 使用管理员账号登录。
3. 在左上角选择需要应用主题的 realm,例如 `tjwater`。不要停留在
`master`,除非确实要修改 `master` realm。
4. 在左侧菜单进入 `Realm settings`,打开 `Themes` 标签页。
5. 在 `Login theme` 下拉框中选择 `tjwater`
6. 点击 `Save` 保存。
7. 继续检查业务客户端是否单独指定了登录主题,再从业务前端重新进入登录页。
切回 Keycloak 默认登录页时,将 `Login theme` 改为 `keycloak` 并保存。
### 检查业务客户端的主题配置
Keycloak 的 realm 和 client 都可以设置登录主题。client 的配置优先于 realm。
因此,即使 `Realm settings > Themes > Login theme` 已选择 `tjwater`,业务
客户端如果仍指定 `keycloak`,从业务系统跳转后看到的还是默认登录页。
以授权地址中包含 `client_id=tjwater` 的业务系统为例:
1. 确认左上角当前 realm 是 `tjwater`
2. 在左侧菜单进入 `Clients`
3. 打开 `Client ID``tjwater` 的客户端。
4. 在 `Settings` 页面找到 `Login settings > Login theme`
5. 将该字段设置为以下任一选项:
- `Choose...`:不在 client 层指定主题,继承 realm 的 `tjwater` 主题,
推荐使用此方式。
- `tjwater`:在 client 层明确指定 `tjwater` 主题。
6. 不要保留 `keycloak`,否则它会覆盖 realm 的主题。
7. 点击 `Save`,关闭旧登录页,再从业务前端重新发起一次登录。
`Choose...` 不是未配置完成,而是表示当前 client 继承 realm 配置。管理控制台
登录、账户中心和业务系统可能使用不同的 client。某一个入口已经显示
`tjwater` 主题,并不能证明业务 client 也已正确配置。
验证时以业务系统实际生成的 OpenID Connect 授权地址为准,并检查其中的
`client_id`。浏览器加载的主题资源路径应包含
`/resources/<版本>/login/tjwater/`;如果路径仍包含
`/resources/<版本>/login/keycloak/`,说明该 client 仍在使用默认主题。
如果 `Login theme` 下拉框中没有 `tjwater`,先检查容器内的主题文件:
```bash
docker compose \
--env-file .env \
-f infra/docker/docker-compose.yml \
exec -T keycloak \
test -f /opt/keycloak/themes/tjwater/login/theme.properties
```
命令成功但控制台仍未显示主题时,重新创建 Keycloak 容器后再检查:
```bash
docker compose \
--env-file .env \
-f infra/docker/docker-compose.yml \
up -d --force-recreate keycloak
```
主题名称已经正确,但页面仍显示旧样式时,也执行上述命令,并在容器启动后使用
`Ctrl+F5` 强制刷新登录页,避免继续使用浏览器缓存的 CSS。
## 启用
先启动 `infra/docker/docker-compose.yml` 中的 Keycloak,再从仓库根目录执行:
```bash
bash infra/docker/keycloak/configure-theme.sh apply
```
脚本默认配置 `tjwater` realm、简体中文默认语言、中英文切换和
`TJWater 智慧水务平台` 品牌名,并清除 `tjwater` client 对登录主题的覆盖,
使其继承 realm 主题。其他环境可临时覆盖:
```bash
TJWATER_KEYCLOAK_REALM=example \
TJWATER_KEYCLOAK_CLIENT_ID=example-web \
TJWATER_KEYCLOAK_DISPLAY_NAME="示例智慧水务平台" \
bash infra/docker/keycloak/configure-theme.sh apply
```
管理员凭据继续使用 Compose 已注入的 `KC_BOOTSTRAP_ADMIN_USERNAME` /
`KC_BOOTSTRAP_ADMIN_PASSWORD`,并兼容现有的 `KEYCLOAK_ADMIN` /
`KEYCLOAK_ADMIN_PASSWORD`
## 验证与回滚
```bash
bash infra/docker/keycloak/configure-theme.sh verify
bash infra/docker/keycloak/configure-theme.sh rollback
```
使用 `latest` 镜像时,每次重新拉取 Keycloak 后都应重新执行 `verify`,并在
1280px、375px 和 320px 视口检查登录、错误提示、忘记密码和 OTP 页面。
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
set -euo pipefail
action="${1:-apply}"
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd -- "${script_dir}/../../.." && pwd)"
compose_file="${repo_root}/infra/docker/docker-compose.yml"
realm="${TJWATER_KEYCLOAK_REALM:-tjwater}"
client_id="${TJWATER_KEYCLOAK_CLIENT_ID:-tjwater}"
display_name="${TJWATER_KEYCLOAK_DISPLAY_NAME:-TJWater 智慧水务平台}"
case "${action}" in
apply|verify|rollback) ;;
*)
echo "用法: bash infra/docker/keycloak/configure-theme.sh [apply|verify|rollback]" >&2
exit 2
;;
esac
compose_args=(docker compose)
if [[ -f "${repo_root}/.env" ]]; then
compose_args+=(--env-file "${repo_root}/.env")
fi
compose_args+=(-f "${compose_file}")
"${compose_args[@]}" exec -T \
-e TJWATER_KEYCLOAK_ACTION="${action}" \
-e TJWATER_KEYCLOAK_REALM="${realm}" \
-e TJWATER_KEYCLOAK_CLIENT_ID="${client_id}" \
-e TJWATER_KEYCLOAK_DISPLAY_NAME="${display_name}" \
keycloak sh -s <<'KEYCLOAK_SCRIPT'
set -eu
action="${TJWATER_KEYCLOAK_ACTION}"
realm="${TJWATER_KEYCLOAK_REALM}"
client_id="${TJWATER_KEYCLOAK_CLIENT_ID}"
display_name="${TJWATER_KEYCLOAK_DISPLAY_NAME}"
server_url="${TJWATER_KEYCLOAK_SERVER_URL:-http://127.0.0.1:8080}"
admin_user="${KC_BOOTSTRAP_ADMIN_USERNAME:-${KEYCLOAK_ADMIN:-}}"
admin_password="${KC_BOOTSTRAP_ADMIN_PASSWORD:-${KEYCLOAK_ADMIN_PASSWORD:-}}"
config_file="/tmp/tjwater-kcadm-$$.config"
kcadm="/opt/keycloak/bin/kcadm.sh"
cleanup() {
rm -f "${config_file}"
}
trap cleanup EXIT
if [ -z "${admin_user}" ] || [ -z "${admin_password}" ]; then
echo "缺少 Keycloak 管理员用户名或密码环境变量。" >&2
exit 1
fi
if [ "${action}" = "apply" ] && [ ! -f /opt/keycloak/themes/tjwater/login/theme.properties ]; then
echo "未找到 tjwater 登录主题,请检查主题目录挂载。" >&2
exit 1
fi
"${kcadm}" config credentials \
--config "${config_file}" \
--server "${server_url}" \
--realm master \
--user "${admin_user}" \
--password "${admin_password}" >/dev/null
client_uuid="$(
"${kcadm}" get clients \
--config "${config_file}" \
--target-realm "${realm}" \
--query "clientId=${client_id}" \
--fields id \
--format csv \
--noquotes |
sed -n '1p'
)"
if [ -z "${client_uuid}" ]; then
echo "realm ${realm} 中未找到 client ${client_id}" >&2
echo "可通过 TJWATER_KEYCLOAK_CLIENT_ID 指定实际的 client ID。" >&2
exit 1
fi
case "${action}" in
apply)
"${kcadm}" update "realms/${realm}" \
--config "${config_file}" \
-s "displayName=${display_name}" \
-s "displayNameHtml=${display_name}" \
-s "loginTheme=tjwater" \
-s "internationalizationEnabled=true" \
-s 'supportedLocales=["zh-CN","en"]' \
-s "defaultLocale=zh-CN" >/dev/null
"${kcadm}" update "clients/${client_uuid}" \
--config "${config_file}" \
--target-realm "${realm}" \
--set attributes.login_theme= >/dev/null
echo "已为 realm ${realm} 启用 tjwater 登录主题。"
echo "client ${client_id} 已改为继承 realm 登录主题。"
;;
rollback)
"${kcadm}" update "realms/${realm}" \
--config "${config_file}" \
-s "loginTheme=keycloak" >/dev/null
echo "已将 realm ${realm} 恢复为 Keycloak 默认登录主题。"
;;
esac
"${kcadm}" get "realms/${realm}" \
--config "${config_file}" \
--fields realm,displayName,loginTheme,internationalizationEnabled,supportedLocales,defaultLocale
"${kcadm}" get "clients/${client_uuid}" \
--config "${config_file}" \
--target-realm "${realm}" \
--fields 'clientId,attributes(login_theme)'
KEYCLOAK_SCRIPT
@@ -0,0 +1,3 @@
loginAccountTitle=Account sign in
doLogIn=Sign in
doForgotPassword=Forgot password
@@ -0,0 +1,9 @@
loginAccountTitle=账号登录
usernameOrEmail=用户名或邮箱
doLogIn=登录
doForgotPassword=忘记密码
rememberMe=记住我
invalidUserMessage=用户名或密码错误
invalidUsernameOrPasswordMessage=用户名或密码错误
expiredCodeMessage=登录已超时,请重新登录
loginTimeout=登录已超时,请重新开始登录
@@ -0,0 +1,535 @@
:root {
--tjwater-canvas: oklch(0.965 0.014 205);
--tjwater-surface: oklch(0.995 0.004 205);
--tjwater-surface-soft: oklch(0.982 0.008 205);
--tjwater-ink: oklch(0.3 0.055 215);
--tjwater-muted: oklch(0.52 0.035 215);
--tjwater-line: oklch(0.86 0.025 210);
--tjwater-blue: oklch(0.57 0.16 242);
--tjwater-blue-dark: oklch(0.49 0.15 242);
--tjwater-teal: oklch(0.58 0.12 180);
--tjwater-danger: oklch(0.55 0.19 27);
--tjwater-radius-sm: 6px;
--tjwater-radius-md: 12px;
--tjwater-radius-lg: 18px;
}
html.login-pf {
height: 100%;
min-height: 100%;
overflow-x: hidden;
background: var(--tjwater-canvas);
}
body#keycloak-bg,
.login-pf body {
min-height: 100%;
margin: 0;
padding: 0;
color: var(--tjwater-ink);
background: var(--tjwater-canvas);
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI",
"PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.pf-v5-c-login,
.pf-v5-c-login * {
box-sizing: border-box;
}
.pf-v5-c-login {
min-height: 100svh;
padding: 0;
background-color: var(--tjwater-canvas);
background-image:
linear-gradient(
90deg,
transparent 0%,
transparent 50%,
oklch(0.975 0.01 205 / 62%) 68%,
oklch(0.975 0.01 205 / 82%) 100%
),
url("../img/network-blueprint.svg");
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
.pf-v5-c-login__container {
display: grid;
width: 100%;
max-width: 1720px;
min-height: 100svh;
margin: 0 auto;
padding: clamp(32px, 4.5vw, 76px) clamp(40px, 5vw, 88px);
grid-template-columns: minmax(360px, 1fr) minmax(400px, 460px);
grid-template-areas: "header main";
align-items: center;
gap: clamp(64px, 8vw, 152px);
}
#kc-header {
position: relative;
z-index: 0;
grid-area: header;
width: fit-content;
max-width: 100%;
align-self: center;
justify-self: start;
margin: 0;
padding: 0;
isolation: isolate;
animation: tjwater-enter 480ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
#kc-header::before {
position: absolute;
z-index: -1;
inset: -54px -72px;
background: radial-gradient(
ellipse at center,
oklch(0.925 0.018 205 / 98%) 0%,
oklch(0.925 0.018 205 / 94%) 48%,
oklch(0.925 0.018 205 / 62%) 65%,
transparent 82%
);
pointer-events: none;
content: "";
}
#kc-header-wrapper {
display: flex;
max-width: 680px;
margin: 0;
padding: 0;
flex-direction: column;
align-items: flex-start;
color: var(--tjwater-ink) !important;
font-size: clamp(34px, 3vw, 46px);
font-weight: 720;
line-height: 1.28;
letter-spacing: 0;
text-align: left;
text-transform: none;
text-wrap: balance;
}
#kc-header-wrapper::before {
width: 56px;
height: 56px;
margin-bottom: 24px;
background: url("../img/logo-mark.svg") center / contain no-repeat;
content: "";
}
#kc-header-wrapper::after {
width: 64px;
height: 3px;
margin-top: 26px;
border-radius: 999px;
background: var(--tjwater-teal);
content: "";
}
.pf-v5-c-login__main {
grid-area: main;
width: 100%;
max-width: 460px;
margin: 0;
align-self: center;
justify-self: stretch;
overflow: hidden;
border: 0;
border-radius: var(--tjwater-radius-lg);
background: oklch(0.995 0.004 205 / 97%);
box-shadow:
0 32px 80px rgb(22 65 75 / 16%),
0 5px 18px rgb(22 65 75 / 9%);
animation: tjwater-enter 520ms 70ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.pf-v5-c-login__main-header {
display: grid;
margin: 0;
padding: 36px 36px 18px;
grid-template-columns: minmax(0, 1fr) auto;
gap: 20px;
align-items: center;
border-top: 0;
}
#kc-page-title {
margin: 0;
color: var(--tjwater-ink);
font-size: 26px;
font-weight: 720;
line-height: 1.4;
letter-spacing: 0;
text-wrap: balance;
}
.pf-v5-c-login__main-header-utilities {
margin: 0;
}
.pf-v5-c-login__main-body {
margin: 0;
padding: 0 36px 38px;
}
.pf-v5-c-form {
gap: 20px;
}
.pf-v5-c-form__group {
margin: 0;
}
.pf-v5-c-form__group-label {
padding-bottom: 8px;
}
.pf-v5-c-form__label-text {
color: var(--tjwater-ink);
font-size: 14px;
font-weight: 650;
line-height: 1.6;
}
.pf-v5-c-form-control {
min-height: 48px;
overflow: hidden;
border: 1px solid var(--tjwater-line);
border-radius: var(--tjwater-radius-sm);
background: var(--tjwater-surface-soft);
box-shadow: none;
transition-property: border-color, box-shadow, background-color;
transition-duration: 160ms;
transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
.pf-v5-c-form-control::before,
.pf-v5-c-form-control::after {
border: 0;
}
.pf-v5-c-form-control:focus-within {
border-color: var(--tjwater-blue);
background: var(--tjwater-surface);
box-shadow: 0 0 0 3px oklch(0.78 0.1 235 / 28%);
}
.pf-v5-c-form-control > input,
.pf-v5-c-form-control > select {
min-height: 46px;
padding-inline: 14px;
color: var(--tjwater-ink);
font-size: 16px;
outline: 0;
}
.pf-v5-c-login__main-header-utilities .pf-v5-c-form-control {
width: 116px;
min-height: 40px;
background: var(--tjwater-surface);
}
#login-select-toggle {
width: 100%;
min-width: 0;
min-height: 38px;
padding-inline: 12px 32px;
color: var(--tjwater-muted);
font-size: 14px;
cursor: pointer;
}
.pf-v5-c-form-control.pf-m-error {
border-color: var(--tjwater-danger);
}
.pf-v5-c-input-group {
gap: 8px;
}
.pf-v5-c-input-group__item.pf-m-fill {
min-width: 0;
}
.pf-v5-c-button.pf-m-control {
min-width: 48px;
min-height: 48px;
border: 1px solid var(--tjwater-line);
border-radius: var(--tjwater-radius-sm);
color: var(--tjwater-muted);
background: var(--tjwater-surface-soft);
touch-action: manipulation;
transition-property: color, border-color, background-color, transform;
transition-duration: 160ms;
transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
.pf-v5-c-button.pf-m-control:active {
transform: scale(0.96);
}
.pf-v5-c-button.pf-m-control:focus-visible,
.pf-v5-c-button.pf-m-primary:focus-visible,
.pf-v5-c-button.pf-m-secondary:focus-visible,
a:focus-visible {
outline: 3px solid oklch(0.74 0.12 235 / 60%);
outline-offset: 2px;
}
.pf-v5-c-form__helper-text {
margin-top: 8px;
}
.pf-v5-c-helper-text {
min-height: 22px;
}
.pf-v5-c-helper-text__item-text {
color: var(--tjwater-muted);
line-height: 1.7;
}
.pf-v5-c-helper-text__item-text a,
#kc-registration a,
.pf-v5-c-login__main-footer a {
color: var(--tjwater-blue-dark);
font-weight: 600;
text-decoration: none;
text-underline-offset: 3px;
}
.kc-feedback-text.pf-m-error,
.pf-v5-c-helper-text__item.pf-m-error .kc-feedback-text {
color: var(--tjwater-danger);
}
.pf-v5-c-check__input {
accent-color: var(--tjwater-blue);
}
.pf-v5-c-check__label {
color: var(--tjwater-muted);
line-height: 1.7;
}
.pf-v5-c-form__actions {
padding-top: 6px;
}
.pf-v5-c-button.pf-m-primary {
min-height: 48px;
border: 0;
border-radius: var(--tjwater-radius-md);
color: oklch(0.99 0.004 230);
background: var(--tjwater-blue);
font-size: 16px;
font-weight: 700;
box-shadow: 0 8px 18px oklch(0.48 0.15 242 / 20%);
touch-action: manipulation;
transition-property: transform, background-color, box-shadow;
transition-duration: 160ms;
transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
.pf-v5-c-button.pf-m-primary:active {
transform: scale(0.96);
background: var(--tjwater-blue-dark);
box-shadow: 0 4px 10px oklch(0.48 0.15 242 / 18%);
}
.pf-v5-c-button.pf-m-secondary {
min-height: 44px;
border-radius: var(--tjwater-radius-md);
color: var(--tjwater-blue-dark);
border-color: var(--tjwater-line);
}
.pf-v5-c-alert {
border-radius: var(--tjwater-radius-md);
}
.pf-v5-c-login__main-footer {
color: var(--tjwater-muted);
line-height: 1.7;
}
.pf-v5-c-login__main-footer-band {
margin-top: 26px;
padding: 18px 0 0;
border-top: 1px solid var(--tjwater-line);
background: transparent;
}
@keyframes tjwater-enter {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (hover: hover) {
.pf-v5-c-button.pf-m-primary:hover {
background: var(--tjwater-blue-dark);
box-shadow: 0 10px 22px oklch(0.48 0.15 242 / 25%);
transform: translateY(-1px);
}
.pf-v5-c-button.pf-m-control:hover {
color: var(--tjwater-blue-dark);
border-color: oklch(0.69 0.08 230);
background: var(--tjwater-surface);
}
.pf-v5-c-helper-text__item-text a:hover,
#kc-registration a:hover,
.pf-v5-c-login__main-footer a:hover {
text-decoration: underline;
}
}
@media (max-width: 900px) {
.pf-v5-c-login {
background-image:
linear-gradient(oklch(0.965 0.014 205 / 34%), oklch(0.965 0.014 205 / 34%)),
url("../img/network-blueprint.svg");
background-position: 34% center;
}
.pf-v5-c-login__container {
max-width: 560px;
padding:
max(28px, env(safe-area-inset-top))
max(24px, env(safe-area-inset-right))
max(32px, env(safe-area-inset-bottom))
max(24px, env(safe-area-inset-left));
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"header"
"main";
align-content: center;
gap: 24px;
}
#kc-header-wrapper {
max-width: none;
flex-direction: row;
align-items: center;
gap: 14px;
font-size: clamp(22px, 5vw, 28px);
line-height: 1.4;
}
#kc-header::before {
inset: -24px -20px;
background: radial-gradient(
ellipse at center,
oklch(0.965 0.014 205 / 98%) 0%,
oklch(0.965 0.014 205 / 88%) 58%,
transparent 84%
);
}
#kc-header-wrapper::before {
width: 46px;
height: 46px;
margin: 0;
flex: 0 0 46px;
}
#kc-header-wrapper::after {
display: none;
}
.pf-v5-c-login__main {
max-width: none;
}
}
@media (max-width: 520px) {
.pf-v5-c-login__container {
gap: 18px;
padding-inline:
max(14px, env(safe-area-inset-left))
max(14px, env(safe-area-inset-right));
}
#kc-header-wrapper {
gap: 12px;
font-size: 21px;
}
#kc-header-wrapper::before {
width: 42px;
height: 42px;
flex-basis: 42px;
}
.pf-v5-c-login__main {
border-radius: 16px;
}
.pf-v5-c-login__main-header {
gap: 12px;
padding: 26px 22px 15px;
}
#kc-page-title {
font-size: 23px;
}
.pf-v5-c-login__main-header-utilities .pf-v5-c-form-control {
width: 108px;
}
.pf-v5-c-login__main-body {
padding: 0 22px 28px;
}
}
@media (max-height: 680px) and (min-width: 901px) {
.pf-v5-c-login__container {
padding-block: 24px;
}
#kc-header-wrapper::before {
width: 48px;
height: 48px;
margin-bottom: 18px;
}
#kc-header-wrapper::after {
margin-top: 20px;
}
.pf-v5-c-login__main-header {
padding-top: 28px;
}
.pf-v5-c-login__main-body {
padding-bottom: 30px;
}
}
@media (prefers-reduced-motion: reduce) {
#kc-header,
.pf-v5-c-login__main {
animation: none;
}
.pf-v5-c-button,
.pf-v5-c-form-control {
transition-duration: 0.01ms;
}
}
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" role="img" aria-labelledby="title">
<title id="title">TJWater</title>
<rect width="72" height="72" rx="18" fill="#1478d4"/>
<path d="M36 13c-7.8 11.1-16.1 19.4-16.1 29.3A16.1 16.1 0 0 0 36 58.4a16.1 16.1 0 0 0 16.1-16.1C52.1 32.4 43.8 24.1 36 13Z" fill="#f5fbfc"/>
<path d="M26.5 43.5h19M31 36.5l5 7 5-7" fill="none" stroke="#0b8f82" stroke-linecap="round" stroke-linejoin="round" stroke-width="3"/>
<circle cx="26.5" cy="43.5" r="2.7" fill="#0b8f82"/>
<circle cx="45.5" cy="43.5" r="2.7" fill="#0b8f82"/>
</svg>

After

Width:  |  Height:  |  Size: 585 B

@@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 900" preserveAspectRatio="xMidYMid slice">
<rect width="1440" height="900" fill="#edf5f5"/>
<path d="M0 0h790L650 900H0Z" fill="#dceced"/>
<path d="M0 182c187-78 303-73 451-16 153 59 288 47 409-25M0 552c193-45 327-19 461 74 128 90 266 100 410 47" fill="none" stroke="#c8dddd" stroke-width="2"/>
<g fill="none" stroke="#a9ccce" stroke-linecap="round" stroke-linejoin="round">
<path d="M-32 725 178 608l142 51 155-190 184 67 176-205" stroke-width="5"/>
<path d="m178 608 25-222 151-92 121 175" stroke-width="3"/>
<path d="m203 386-92-95 55-161M354 294l92-141 152 58 98-106" stroke-width="3"/>
<path d="m320 659-4 132 171 81M659 536l88 116 124-42" stroke-width="3"/>
</g>
<g fill="#edf5f5" stroke="#1478d4" stroke-width="4">
<circle cx="178" cy="608" r="10"/>
<circle cx="203" cy="386" r="9"/>
<circle cx="354" cy="294" r="9"/>
<circle cx="475" cy="469" r="11"/>
<circle cx="659" cy="536" r="10"/>
<circle cx="747" cy="652" r="9"/>
<circle cx="320" cy="659" r="8"/>
</g>
<g fill="#0b8f82">
<circle cx="111" cy="291" r="6"/>
<circle cx="166" cy="130" r="6"/>
<circle cx="446" cy="153" r="7"/>
<circle cx="598" cy="211" r="6"/>
<circle cx="696" cy="105" r="6"/>
<circle cx="316" cy="791" r="6"/>
<circle cx="487" cy="872" r="6"/>
<circle cx="835" cy="331" r="7"/>
</g>
<g fill="none" stroke="#86b8bb" stroke-width="2" opacity=".72">
<circle cx="615" cy="448" r="222"/>
<circle cx="615" cy="448" r="276"/>
<circle cx="615" cy="448" r="334"/>
</g>
<g fill="#1478d4" opacity=".08">
<rect x="40" y="40" width="118" height="10" rx="5"/>
<rect x="40" y="62" width="72" height="6" rx="3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,21 @@
const localizeLocaleOptions = () => {
const localeSelect = document.querySelector("#login-select-toggle");
if (!(localeSelect instanceof HTMLSelectElement)) return;
for (const option of localeSelect.options) {
const optionUrl = new URL(option.value, window.location.origin);
const locale = optionUrl.searchParams.get("kc_locale");
if (locale === "zh-CN") option.textContent = "简体中文";
if (locale === "en") option.textContent = "English";
}
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", localizeLocaleOptions, {
once: true,
});
} else {
localizeLocaleOptions();
}
@@ -0,0 +1,7 @@
parent=keycloak.v2
import=common/keycloak
styles=css/styles.css css/tjwater-login.css
scripts=js/locale-labels.js
locales=zh-CN,en
darkMode=false
@@ -42,6 +42,12 @@ ALTER TABLE users
ALTER TABLE users
ALTER COLUMN role SET DEFAULT 'user';
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_role_check;
ALTER TABLE users
ADD CONSTRAINT users_role_check
CHECK (role IN ('admin', 'user'));
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_keycloak_id ON users(keycloak_id);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active);
@@ -52,7 +58,9 @@ CREATE TABLE IF NOT EXISTS user_project_membership (
project_id UUID NOT NULL,
project_role VARCHAR(20) DEFAULT 'viewer' NOT NULL,
CONSTRAINT user_project_membership_role_check
CHECK (project_role IN ('owner', 'admin', 'member', 'viewer')),
CHECK (
project_role IN ('member', 'viewer')
),
CONSTRAINT user_project_membership_unique UNIQUE (user_id, project_id)
);
+32
View File
@@ -0,0 +1,32 @@
-- Normalize existing roles to the Web authorization model.
-- This migration is intentionally re-runnable.
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_role_check;
UPDATE users
SET role = 'user'
WHERE role NOT IN ('admin', 'user');
ALTER TABLE users
ADD CONSTRAINT users_role_check
CHECK (role IN ('admin', 'user'));
ALTER TABLE user_project_membership
DROP CONSTRAINT IF EXISTS user_project_membership_role_check;
UPDATE user_project_membership
SET project_role = CASE
WHEN project_role IN (
'owner',
'admin',
'modeler',
'dispatcher'
) THEN 'member'
ELSE 'viewer'
END
WHERE project_role NOT IN ('member', 'viewer');
ALTER TABLE user_project_membership
ADD CONSTRAINT user_project_membership_role_check
CHECK (project_role IN ('member', 'viewer'));
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sys
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
HTTP_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}
KEBAB_SEGMENT = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
SNAKE_PARAMETER = re.compile(r"^[a-z][a-z0-9_]*$")
def canonical_json(document: dict[str, Any]) -> bytes:
return (
json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8")
+ b"\n"
)
def current_contract_bytes() -> bytes:
os.environ.setdefault("ENVIRONMENT", "development")
from app.main import app
document = app.openapi()
document["info"]["version"] = "1.0.0"
return canonical_json(document)
def _iter_operations(document: dict[str, Any]):
for path, path_item in document.get("paths", {}).items():
for method, operation in path_item.items():
if method in HTTP_METHODS and isinstance(operation, dict):
yield path, method, operation
def validate(document: dict[str, Any]) -> list[str]:
errors: list[str] = []
operation_ids: set[str] = set()
for path, method, operation in _iter_operations(document):
if path != path.rstrip("/"):
errors.append(f"{method.upper()} {path}: trailing slash")
if "//" in path:
errors.append(f"{method.upper()} {path}: double slash")
for segment in path.split("/"):
if not segment or (segment.startswith("{") and segment.endswith("}")):
continue
if not KEBAB_SEGMENT.fullmatch(segment):
errors.append(f"{method.upper()} {path}: non-kebab segment {segment!r}")
operation_id = operation.get("operationId")
if not operation_id:
errors.append(f"{method.upper()} {path}: missing operationId")
elif operation_id in operation_ids:
errors.append(f"{method.upper()} {path}: duplicate operationId {operation_id}")
else:
operation_ids.add(operation_id)
if not operation.get("tags"):
errors.append(f"{method.upper()} {path}: missing tags")
if not operation.get("summary"):
errors.append(f"{method.upper()} {path}: missing summary")
for parameter in operation.get("parameters", []):
if (
parameter.get("in") in {"query", "path"}
and not SNAKE_PARAMETER.fullmatch(str(parameter.get("name", "")))
):
errors.append(
f"{method.upper()} {path}: non-snake parameter "
f"{parameter.get('name')!r}"
)
success_responses = [
(status, response)
for status, response in operation.get("responses", {}).items()
if str(status).startswith("2")
]
if not success_responses:
errors.append(f"{method.upper()} {path}: missing success response")
for status, response in success_responses:
if str(status) == "204":
continue
if "content" not in response:
errors.append(f"{method.upper()} {path}: success response has no content schema")
for media in response.get("content", {}).values():
if media.get("schema") == {}:
errors.append(f"{method.upper()} {path}: empty success schema")
return errors
def main() -> int:
parser = argparse.ArgumentParser(description="Validate TJWater REST OpenAPI invariants")
parser.add_argument(
"contract",
nargs="?",
type=Path,
default=Path("contracts/server-v1.openapi.json"),
)
parser.add_argument(
"--manifest",
type=Path,
default=Path("contracts/manifest.json"),
)
args = parser.parse_args()
raw = args.contract.read_bytes()
document = json.loads(raw)
errors = validate(document)
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
expected_hash = manifest["contracts"]["server"]["sha256"]
actual_hash = hashlib.sha256(raw).hexdigest()
if expected_hash != actual_hash:
errors.append(
f"contract hash mismatch: manifest={expected_hash}, actual={actual_hash}"
)
current = current_contract_bytes()
if raw != current:
errors.append(
"contract is stale: run "
"`python scripts/export_openapi.py` and commit the regenerated files"
)
if errors:
print("\n".join(f"- {error}" for error in errors))
return 1
print(
f"validated {len(document['paths'])} paths; "
f"sha256={actual_hash}; version={document['info']['version']}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sys
from pathlib import Path
from typing import Any
def _canonical_json(document: dict[str, Any]) -> bytes:
return (
json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8")
+ b"\n"
)
def main() -> int:
parser = argparse.ArgumentParser(description="Export the TJWater REST v1 OpenAPI contract")
parser.add_argument(
"--output",
type=Path,
default=Path("contracts/server-v1.openapi.json"),
)
parser.add_argument(
"--manifest",
type=Path,
default=Path("contracts/manifest.json"),
)
args = parser.parse_args()
os.environ.setdefault("ENVIRONMENT", "development")
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.main import app
document = app.openapi()
document["info"]["version"] = "1.0.0"
payload = _canonical_json(document)
digest = hashlib.sha256(payload).hexdigest()
args.output.parent.mkdir(parents=True, exist_ok=True)
args.manifest.parent.mkdir(parents=True, exist_ok=True)
args.output.write_bytes(payload)
args.manifest.write_bytes(
_canonical_json(
{
"contract_version": "1.0.0",
"contracts": {
"server": {
"file": args.output.name,
"sha256": digest,
}
},
}
)
)
print(f"exported {len(document['paths'])} paths to {args.output} ({digest})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+5 -1
View File
@@ -87,6 +87,7 @@ def burst_analysis(
modify_variable_pump_pattern: dict[str, list] = None,
modify_valve_opening: dict[str, float] = None,
scheme_name: str = None,
username: str | None = None,
) -> None:
"""
爆管模拟
@@ -101,6 +102,9 @@ def burst_analysis(
:param scheme_name: 方案名称
:return:
"""
if not username:
raise ValueError("username is required when storing burst analysis scheme")
scheme_detail: dict = {
"burst_ID": burst_ID,
"burst_size": burst_size,
@@ -225,7 +229,7 @@ def burst_analysis(
name=name,
scheme_name=scheme_name,
scheme_type="burst_Analysis",
username="admin",
username=username,
scheme_start_time=modify_pattern_start_time,
scheme_detail=scheme_detail,
)
+5 -1
View File
@@ -9,7 +9,6 @@ if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
echo " bash scripts/trigger-gitea-pipeline.sh"
echo " bash scripts/trigger-gitea-pipeline.sh origin latest"
echo " bash scripts/trigger-gitea-pipeline.sh gitea latest"
echo " bash scripts/trigger-gitea-pipeline.sh origin v2026.06.09.1"
exit 0
fi
@@ -30,6 +29,11 @@ resolve_default_remote() {
REMOTE="${1:-}"
TAG="${2:-latest}"
if [[ "$TAG" != "latest" ]]; then
echo "[ERROR] This deployment only supports the 'latest' tag."
exit 1
fi
if ! git rev-parse --git-dir >/dev/null 2>&1; then
echo "[ERROR] Current directory is not a git repository."
exit 1
+77
View File
@@ -0,0 +1,77 @@
from types import SimpleNamespace
from uuid import uuid4
from fastapi.testclient import TestClient
from app.api.v1.endpoints import access as access_endpoint
from app.auth.metadata_dependencies import (
get_current_metadata_user,
get_metadata_repository,
)
from tests.conftest import build_test_app
def _user(**overrides):
data = {
"id": uuid4(),
"username": "alice",
"role": "user",
"is_superuser": False,
}
data.update(overrides)
return SimpleNamespace(**data)
def _build_client(user, repo) -> TestClient:
app = build_test_app(access_endpoint.router, "/api/v1")
app.dependency_overrides[get_current_metadata_user] = lambda: user
app.dependency_overrides[get_metadata_repository] = lambda: repo
return TestClient(app)
def test_access_context_returns_global_admin_permissions_without_project():
user = _user(role="admin")
repo = SimpleNamespace()
client = _build_client(user, repo)
response = client.get("/api/v1/access-context")
assert response.status_code == 200
payload = response.json()
assert payload["is_system_admin"] is True
assert payload["project_id"] is None
assert "environment.manage" in payload["permissions"]
assert "webgis.view" not in payload["permissions"]
def test_access_context_returns_project_member_permissions():
project_id = uuid4()
user = _user()
async def get_project_by_id(value):
assert value == project_id
return SimpleNamespace(id=project_id, code="demo", status="active")
async def get_membership_role(value, user_id):
assert value == project_id
assert user_id == user.id
return "member"
repo = SimpleNamespace(
get_project_by_id=get_project_by_id,
get_membership_role=get_membership_role,
)
client = _build_client(user, repo)
response = client.get(
"/api/v1/access-context",
headers={"X-Project-Id": str(project_id)},
)
assert response.status_code == 200
payload = response.json()
assert payload["project_id"] == str(project_id)
assert payload["project_role"] == "member"
assert "scada.clean" in payload["permissions"]
assert "optimization.run" in payload["permissions"]
assert "model.import" not in payload["permissions"]
+17 -12
View File
@@ -138,7 +138,7 @@ async def test_batch_sync_metadata_users_returns_per_user_results(monkeypatch):
keycloak_id=users[1].keycloak_id,
username="bob",
email="bob@example.com",
role="viewer",
role="user",
is_active=True,
),
]
@@ -156,7 +156,7 @@ async def test_batch_sync_metadata_users_returns_per_user_results(monkeypatch):
@pytest.mark.anyio
async def test_update_metadata_user_updates_role_and_active_status(monkeypatch):
user_id = uuid4()
updated = _user(id=user_id, role="operator", is_active=False)
updated = _user(id=user_id, role="user", is_active=False)
repo = SimpleNamespace(
session=object(),
update_user_admin=AsyncMock(return_value=updated),
@@ -165,7 +165,7 @@ async def test_update_metadata_user_updates_role_and_active_status(monkeypatch):
response = await admin_metadata.update_metadata_user(
MetadataUserUpdateRequest(
role="operator",
role="user",
is_active=False,
),
user_id=user_id,
@@ -175,9 +175,9 @@ async def test_update_metadata_user_updates_role_and_active_status(monkeypatch):
repo.update_user_admin.assert_awaited_once_with(
user_id,
updates={"role": "operator", "is_active": False},
updates={"role": "user", "is_active": False},
)
assert response.role == "operator"
assert response.role == "user"
admin_metadata.log_audit_event.assert_awaited_once()
@@ -192,7 +192,7 @@ async def test_update_metadata_user_rejects_self_update(monkeypatch):
with pytest.raises(HTTPException) as exc:
await admin_metadata.update_metadata_user(
MetadataUserUpdateRequest(role="viewer"),
MetadataUserUpdateRequest(role="user"),
user_id=current_user.id,
current_user=current_user,
metadata_repo=repo,
@@ -221,6 +221,7 @@ async def test_create_project_audits_metadata_admin_change(monkeypatch):
create_project=AsyncMock(return_value=project),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
current_user = _user(role="admin", is_superuser=True)
response = await admin_metadata.create_admin_project(
AdminProjectCreateRequest(
@@ -231,12 +232,16 @@ async def test_create_project_audits_metadata_admin_change(monkeypatch):
map_extent={"bbox": [1, 2, 3, 4]},
status="active",
),
current_user=_user(role="admin", is_superuser=True),
current_user=current_user,
metadata_repo=repo,
)
assert response.project_id == project.id
repo.create_project.assert_awaited_once()
assert (
repo.create_project.await_args.kwargs["creator_user_id"]
== current_user.id
)
admin_metadata.log_audit_event.assert_awaited_once()
@@ -482,7 +487,7 @@ async def test_update_project_member_role_audits_change(monkeypatch):
membership = _membership(
user_id=user_id,
project_id=project_id,
project_role="admin",
project_role="member",
)
repo = SimpleNamespace(
session=object(),
@@ -492,16 +497,16 @@ async def test_update_project_member_role_audits_change(monkeypatch):
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
response = await admin_metadata.update_project_member(
ProjectMemberUpdateRequest(project_role="admin"),
ProjectMemberUpdateRequest(project_role="member"),
project_id=project_id,
user_id=user_id,
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert response.project_role == "admin"
assert response.project_role == "member"
repo.update_project_member_role.assert_awaited_once_with(
project_id, user_id, "admin"
project_id, user_id, "member"
)
admin_metadata.log_audit_event.assert_awaited_once()
@@ -519,7 +524,7 @@ async def test_update_project_member_rejects_self_membership_change(monkeypatch)
with pytest.raises(HTTPException) as exc:
await admin_metadata.update_project_member(
ProjectMemberUpdateRequest(project_role="admin"),
ProjectMemberUpdateRequest(project_role="member"),
project_id=project_id,
user_id=current_user.id,
current_user=current_user,
+18 -4
View File
@@ -30,7 +30,7 @@ def test_agent_auth_context_returns_metadata_user_and_project_context():
project_id=project_id,
project_code="fengyang",
user_id=user_id,
project_role="editor",
project_role="member",
),
current_user=SimpleNamespace(
id=user_id,
@@ -41,7 +41,7 @@ def test_agent_auth_context_returns_metadata_user_and_project_context():
),
)
response = client.get("/api/v1/agent/auth/context")
response = client.get("/api/v1/agent-auth-context")
assert response.status_code == 200
assert response.json() == {
@@ -52,7 +52,21 @@ def test_agent_auth_context_returns_metadata_user_and_project_context():
"is_superuser": False,
"project_id": str(project_id),
"network": "fengyang",
"project_role": "editor",
"project_role": "member",
"permissions": [
"burst.run",
"burst.view",
"optimization.run",
"optimization.view",
"risk.run",
"risk.view",
"scada.clean",
"scada.view",
"simulation.run",
"simulation.view",
"webgis.edit",
"webgis.view",
],
"token_expires_at": "2026-06-11T13:10:00+00:00",
}
@@ -76,7 +90,7 @@ def test_agent_auth_context_propagates_project_auth_failures():
app.dependency_overrides[get_current_keycloak_payload] = lambda: {"exp": 1781183400}
client = TestClient(app)
response = client.get("/api/v1/agent/auth/context")
response = client.get("/api/v1/agent-auth-context")
assert response.status_code == 403
assert response.json()["detail"] == "No access to project"

Some files were not shown because too many files have changed in this diff Show More