Files
next-tjwater-server/app/api/v1/endpoints/scada_replay.py
T
jiang 26fa5d1909
Generic Container CI/CD / test-build-publish (push) Successful in 3m39s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 3m39s
feat(scada): add project monitoring replay
2026-11-04 14:27:21 +08:00

115 lines
5.2 KiB
Python

"""SCADA replay controls. Manual steps are processed by the command request."""
from uuid import UUID
from datetime import datetime
from fastapi import APIRouter, Depends, Query, HTTPException
from app.auth.metadata_dependencies import get_metadata_repository
from app.auth.permissions import SCADA_VIEW, SCADA_CLEAN, require_permission, permissions_for_context
from app.auth.project_dependencies import ProjectContext, get_project_pg_connection, get_project_timescale_connection
from app.domain.schemas.scada_replay import (
ScadaReplayCapabilities, ScadaReplayCreate, ScadaReplayCommand, ScadaReplaySnapshot,
ScadaReplayEventsPage, ScadaReplayReadings,
)
from app.infra.db.postgresql.scada_replays import ScadaReplayRepository
from app.domain.schemas.project_settings import ProjectSettings
from app.services.scada_replay import ScadaReplayService, snapshot
router = APIRouter(prefix="/scada-replays", tags=["SCADA Replay"])
async def repository(
ctx: ProjectContext = Depends(require_permission(SCADA_VIEW)),
connection=Depends(get_project_pg_connection),
):
return ScadaReplayRepository(connection, ctx.project_id)
async def project_settings(
ctx: ProjectContext = Depends(require_permission(SCADA_VIEW)),
metadata_repo=Depends(get_metadata_repository),
) -> ProjectSettings:
project = await metadata_repo.get_project_by_id(ctx.project_id)
if project is None:
raise HTTPException(404, "当前项目不存在")
return ProjectSettings.model_validate(project.settings or {})
@router.get("/capabilities", response_model=ScadaReplayCapabilities)
async def replay_capabilities(repo=Depends(repository), ctx=Depends(require_permission(SCADA_VIEW)),
settings=Depends(project_settings)):
available = await repo.available()
current = await repo.current() if available else None
monitoring = settings.scada_monitoring
monitoring_ready = bool(
monitoring
and len(monitoring.pressure_device_ids) >= 5
and monitoring.outlet_flow_device_ids
)
if monitoring_ready:
monitoring_reason = None
elif monitoring is None:
monitoring_reason = "项目尚未配置压力与出厂边界流量监测角色"
elif len(monitoring.pressure_device_ids) < 5:
monitoring_reason = "爆管侦测至少需要 5 个压力监测设备"
else:
monitoring_reason = "项目尚未配置出厂边界流量设备"
return ScadaReplayCapabilities(
available=available, can_write=SCADA_CLEAN in permissions_for_context(ctx),
reason=None if available else "需先部署 SCADA 回放存储结构",
monitoring_interval_seconds=settings.scada_monitoring_interval_seconds,
burst_monitoring_ready=monitoring_ready,
burst_monitoring_reason=monitoring_reason,
current=snapshot(current) if current else None,
)
@router.post("", response_model=ScadaReplaySnapshot, status_code=201)
async def create_replay(command: ScadaReplayCreate, repo=Depends(repository),
ctx=Depends(require_permission(SCADA_CLEAN)), settings=Depends(project_settings)):
return await ScadaReplayService(repo, ctx).create(
command,
monitoring_interval_seconds=settings.scada_monitoring_interval_seconds,
monitoring_config=settings.scada_monitoring,
)
@router.get("/{replay_id}", response_model=ScadaReplaySnapshot)
async def get_replay(replay_id: UUID, repo=Depends(repository)):
await ScadaReplayService(repo, None).ensure_storage()
return snapshot(await repo.get(replay_id))
@router.post("/{replay_id}/commands", response_model=ScadaReplaySnapshot)
async def replay_command(replay_id: UUID, command: ScadaReplayCommand, repo=Depends(repository),
ctx=Depends(require_permission(SCADA_CLEAN)),
timescale=Depends(get_project_timescale_connection)):
service = ScadaReplayService(repo, ctx, timescale)
result = await service.command(replay_id, command)
if command.action in {"step", "retry"} and result.step_pending:
return await service.process_pending_step(replay_id)
return result
@router.get("/{replay_id}/events", response_model=ScadaReplayEventsPage)
async def replay_events(replay_id: UUID, offset: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=200),
repo=Depends(repository)):
await ScadaReplayService(repo, None).ensure_storage()
await repo.get(replay_id)
items = await repo.events(replay_id, offset, limit + 1)
return ScadaReplayEventsPage(items=items[:limit], next_offset=offset + limit if len(items) > limit else None)
@router.get("/{replay_id}/observations", response_model=ScadaReplayReadings)
async def replay_observations(replay_id: UUID, through: datetime, repo=Depends(repository)):
await ScadaReplayService(repo, None).ensure_storage()
document = await repo.get(replay_id)
if (through.tzinfo is None or not document.get("delivered_until")
or through > datetime.fromisoformat(document["delivered_until"])
or through < datetime.fromisoformat(document["start_time"])):
raise HTTPException(422, "回看仅允许本轮已读取的时间区间")
return ScadaReplayReadings(
items=await repo.latest_outputs(replay_id, through),
through=through,
)