feat(server): add project RBAC and guarded workflows

This commit is contained in:
2026-07-30 16:45:09 +08:00
parent 3fbb17bb30
commit ae1a657554
29 changed files with 1431 additions and 412 deletions
+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),
)
+1
View File
@@ -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(
+3
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,6 +23,7 @@ class AgentAuthContextResponse(BaseModel):
project_id: str
network: str
project_role: str
permissions: list[str]
token_expires_at: str | None = None
@@ -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,
)
+57 -53
View File
@@ -1,29 +1,30 @@
"""
审计日志 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)
@@ -31,26 +32,21 @@ async def get_audit_repository(
"/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,7 +56,6 @@ async def get_audit_logs(
skip=skip,
limit=limit,
)
return logs
@router.get(
@@ -69,20 +64,15 @@ async def get_audit_logs(
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("/session-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",
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
+297
View File
@@ -0,0 +1,297 @@
import json
from pathlib import Path
from tempfile import NamedTemporaryFile
from uuid import UUID, uuid4
from fastapi import (
APIRouter,
Body,
Depends,
File,
Header,
HTTPException,
Path as ApiPath,
Query,
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 ChangeSet, import_inp, 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/import",
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.post(
"/admin/projects/{project_id}/model/update",
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}
@router.post("/importinp/", deprecated=True)
async def legacy_import_inp(
request: Request,
network: str = Query(...),
x_project_id: UUID = Header(..., alias="X-Project-Id"),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
):
project = await _get_active_project(x_project_id, metadata_repo)
if network != project.code:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Project scope denied",
)
payload = await request.json()
inp_text = payload.get("inp") if isinstance(payload, dict) else None
if not isinstance(inp_text, str):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing INP content",
)
_validate_inp_bytes(inp_text.encode("utf-8"), "model.inp")
result = import_inp(network, ChangeSet({"inp": inp_text}))
await _audit_model_change(
request=request,
current_user=current_user,
metadata_repo=metadata_repo,
project_id=project.id,
action="import",
)
return result
@router.post("/uploadinp/", deprecated=True)
async def legacy_upload_inp(
request: Request,
content: bytes = Body(...),
name: str = Query(...),
x_project_id: UUID = Header(..., alias="X-Project-Id"),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> bool:
project = await _get_active_project(x_project_id, metadata_repo)
safe_name = Path(name).name
if safe_name != name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid INP file name",
)
_validate_inp_bytes(content, safe_name)
target_dir = Path("data")
target_dir.mkdir(parents=True, exist_ok=True)
(target_dir / safe_name).write_bytes(content)
await _audit_model_change(
request=request,
current_user=current_user,
metadata_repo=metadata_repo,
project_id=project.id,
action="upload",
)
return True
@router.post("/network_project/", deprecated=True)
async def legacy_network_project(
request: Request,
file: UploadFile = File(...),
x_project_id: UUID = Header(..., alias="X-Project-Id"),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
):
project = await _get_active_project(x_project_id, metadata_repo)
content, _ = 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 result
@router.post("/network_update/", deprecated=True)
async def legacy_network_update(
request: Request,
file: UploadFile = File(...),
x_project_id: UUID = Header(..., alias="X-Project-Id"),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> str:
project = await _get_active_project(x_project_id, metadata_repo)
content, _ = 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 json.dumps({"message": "管网更新成功"})
+11 -63
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,
@@ -89,7 +92,8 @@ async def have_project_endpoint(
@router.post("/createproject/", summary="创建新项目", description="创建一个新的供水管网项目。如果项目已存在,可能会覆盖或报错(取决于底层实现)。")
async def create_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)")
network: str = Query(..., description="管网名称(或数据库名称)"),
_=Depends(require_permission(ENVIRONMENT_MANAGE)),
):
"""
创建新项目
@@ -101,7 +105,8 @@ async def create_project_endpoint(
@router.post("/deleteproject/", summary="删除项目", description="永久删除指定的供水管网项目。此操作不可恢复。")
async def delete_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)")
network: str = Query(..., description="管网名称(或数据库名称)"),
_=Depends(require_permission(ENVIRONMENT_MANAGE)),
):
"""
删除项目
@@ -172,7 +177,8 @@ async def close_project_endpoint(
@router.post("/copyproject/", 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,24 +189,6 @@ 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 等信息。")
async def export_inp_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
@@ -331,26 +319,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="文件名"),
@@ -502,26 +470,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="文件名"),
+6 -11
View File
@@ -41,19 +41,14 @@ def _project_network(network: str, project_context: ProjectContext) -> str:
return project_context.project_code
def _can_modify_project(project_context: ProjectContext, current_user: Any) -> bool:
return bool(
project_context.project_role in {"owner", "admin", "member"}
or getattr(current_user, "role", None) == "admin"
or getattr(current_user, "is_superuser", False)
)
def _can_modify_project(project_context: ProjectContext) -> bool:
return project_context.project_role == "member"
def _require_project_write(
project_context: ProjectContext,
current_user: Any,
) -> None:
if not _can_modify_project(project_context, current_user):
if not _can_modify_project(project_context):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="当前项目角色为只读,不能修改监测点方案",
@@ -88,7 +83,7 @@ def _get_scheme_response(
return {
**scheme,
"can_edit": (
_can_modify_project(project_context, current_user)
_can_modify_project(project_context)
and can_edit_sensor_placement(current_user, scheme)
),
}
@@ -110,7 +105,7 @@ async def optimize_sensor_placement_scheme(
current_user=Depends(get_current_metadata_user),
) -> dict[str, Any]:
network = _project_network(payload.network, project_context)
_require_project_write(project_context, current_user)
_require_project_write(project_context)
optimizer = (
pressure_sensor_placement_sensitivity
if payload.method == "sensitivity"
@@ -173,7 +168,7 @@ async def overwrite_sensor_placement_scheme(
current_user=Depends(get_current_metadata_user),
) -> dict[str, Any]:
network = _project_network(network, project_context)
_require_project_write(project_context, current_user)
_require_project_write(project_context)
scheme = _get_scheme_response(
network,
scheme_id,
+3 -45
View File
@@ -1,10 +1,8 @@
from typing import Any, List, Optional
from datetime import datetime, timedelta
import json
import os
import shutil
import threading
from fastapi import APIRouter, Depends, 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
@@ -29,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,
@@ -282,7 +279,8 @@ async def valve_isolation_endpoint(
返回隔离方案,包括:
- must_close_valves: 必须关闭的阀门列表
- optional_valves: 可选关闭的阀门列表
- affected_nodes: 受影响的节点列表
- affected_nodes: 受影响的节点列表;不可隔离时为空列表
- affected_node_count: 受影响的节点总数
- isolatable: 是否可以有效隔离
"""
# result = {
@@ -549,46 +547,6 @@ 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)
+7 -2
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,
@@ -149,7 +150,11 @@ 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:
async def sync_with_server_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
operation: int = Query(..., description="目标操作ID"),
_=Depends(require_permission(SIMULATION_RUN)),
) -> ChangeSet:
"""
与服务器同步
+212 -92
View File
@@ -1,120 +1,240 @@
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from app.api.v1.endpoints import (
access,
admin_metadata,
agent_auth,
project,
simulation,
scada,
sensor_placement,
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,
prefix="/admin",
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, prefix="/audit", 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(sensor_placement.router, tags=["Sensor Placement"])
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,
prefix="/leakage",
tags=["Leakage"],
dependencies=[burst_run_access],
)
api_router.include_router(
burst_detection.router,
prefix="/burst-detection",
tags=["Burst Detection"],
dependencies=[burst_run_access],
)
api_router.include_router(
burst_location.router,
prefix="/burst-location",
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],
)