feat(server): integrate MCP queries and investigation messaging
Add project-bound OAuth MCP queries and controlled analysis with scoped evidence, limits, timeouts and a nearby-model spatial query using EPSG:4326 inputs. Default nearby searches to 200 meters and five results. Integrate investigation messaging/presentation, health/readiness and typed history; remove duplicate web search and fix online simulation and compressed baseline writes. Include project migrations 023-025; keep local credentials and demo seeding excluded. Validation: 319 focused pytest checks; real PostGIS temporary-table regression; read-only Jinqian bridge radius comparison; cross-language MCP discovery/calls.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
"""Actor-private messaging APIs; the Agent verifies its session ownership."""
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from psycopg import AsyncConnection
|
||||
|
||||
from app.auth.permissions import OPERATIONAL_TASK_VIEW, OPERATIONAL_TASK_WRITE, require_permission
|
||||
from app.auth.project_dependencies import ProjectContext, get_project_pg_connection
|
||||
from app.domain.domain_errors import DomainError
|
||||
from app.domain.schemas.agent_messages import (
|
||||
AgentMessage, AgentMessageCreate, AgentMessageDelivery, AgentMessagePage, AgentMessageReply,
|
||||
)
|
||||
from app.infra.db.postgresql.agent_messages import AgentMessageRepository
|
||||
from app.services.agent_messages import AgentMessageService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/agent-messages")
|
||||
|
||||
|
||||
def _service(ctx: ProjectContext, conn: AsyncConnection) -> AgentMessageService:
|
||||
return AgentMessageService(AgentMessageRepository(conn, ctx.project_id), ctx)
|
||||
|
||||
|
||||
def _raise_domain(exc: DomainError):
|
||||
raise HTTPException(status_code=exc.status, detail=exc.payload()) from exc
|
||||
|
||||
|
||||
@router.post("", response_model=AgentMessage, status_code=201)
|
||||
async def create_agent_message(
|
||||
command: AgentMessageCreate,
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await _service(ctx, conn).create(command)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.get("", response_model=AgentMessagePage)
|
||||
async def list_agent_messages(
|
||||
session_id: str = Query(min_length=1, max_length=500),
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
cursor: str | None = None,
|
||||
pending_only: bool = False,
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await _service(ctx, conn).list_session(session_id, limit, cursor, pending_only)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.get("/pending", response_model=AgentMessagePage)
|
||||
async def list_pending_agent_messages(
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
cursor: str | None = None,
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await _service(ctx, conn).pending(limit, cursor)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.get("/{message_id}", response_model=AgentMessage)
|
||||
async def get_agent_message(
|
||||
message_id: UUID,
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await _service(ctx, conn).get(message_id)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.post("/{message_id}/reply", response_model=AgentMessage, status_code=201)
|
||||
async def reply_agent_message(
|
||||
message_id: UUID, command: AgentMessageReply,
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await _service(ctx, conn).reply(message_id, command)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.post("/{message_id}/delivery", response_model=AgentMessage)
|
||||
async def record_agent_message_delivery(
|
||||
message_id: UUID, command: AgentMessageDelivery,
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await _service(ctx, conn).delivery(message_id, command)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Host-only MCP bindings: declared in REST contracts even when MCP is disabled."""
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request, Response
|
||||
from psycopg.errors import UndefinedTable, InvalidSchemaName
|
||||
from app.mcp.models import BindInput, BindResponse
|
||||
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
|
||||
|
||||
router = APIRouter(tags=["MCP Query Context"])
|
||||
|
||||
|
||||
def runtime_for(request: Request):
|
||||
runtime = getattr(request.app.state, "mcp_runtime", None)
|
||||
if runtime is None:
|
||||
raise HTTPException(503, "Product MCP is disabled")
|
||||
return runtime
|
||||
|
||||
|
||||
@router.post("/mcp-query-contexts", response_model=BindResponse)
|
||||
async def create_context(
|
||||
command: BindInput,
|
||||
request: Request,
|
||||
x_project_id: str = Header(alias="X-Project-Id"),
|
||||
) -> BindResponse:
|
||||
runtime = runtime_for(request)
|
||||
principal = await runtime.identity(request)
|
||||
from app.mcp.contexts import bind_context
|
||||
|
||||
try:
|
||||
async with runtime.scope_factory(principal, x_project_id) as scope:
|
||||
binding = await bind_context(scope, principal, command, runtime.settings)
|
||||
except (UndefinedTable, InvalidSchemaName):
|
||||
raise HTTPException(
|
||||
503, "MCP project storage has not been provisioned"
|
||||
) from None
|
||||
return BindResponse(
|
||||
context_handle=binding.context_handle,
|
||||
expires_at=binding.expires_at,
|
||||
task_id=binding.task_id,
|
||||
run_id=binding.run_id,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/mcp-query-contexts/{context_handle}", status_code=204)
|
||||
async def delete_context(
|
||||
context_handle: str,
|
||||
request: Request,
|
||||
x_project_id: str = Header(alias="X-Project-Id"),
|
||||
) -> Response:
|
||||
runtime = runtime_for(request)
|
||||
principal = await runtime.identity(request)
|
||||
try:
|
||||
async with runtime.scope_factory(principal, x_project_id) as scope:
|
||||
await McpQueryRepository(scope.connection, scope.project.project_id).revoke(
|
||||
context_handle, principal.subject, principal.client_id
|
||||
)
|
||||
except (UndefinedTable, InvalidSchemaName):
|
||||
raise HTTPException(
|
||||
503, "MCP project storage has not been provisioned"
|
||||
) from None
|
||||
return Response(status_code=204)
|
||||
@@ -24,6 +24,7 @@ from app.auth.project_dependencies import (
|
||||
get_project_pg_connection,
|
||||
)
|
||||
from app.domain.domain_errors import DomainError
|
||||
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
|
||||
from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot
|
||||
from app.domain.schemas.operational_task import (
|
||||
ActionDecisionCommand,
|
||||
@@ -70,6 +71,9 @@ from app.domain.schemas.operational_task import (
|
||||
TaskStatus,
|
||||
TaskDataQueryResult,
|
||||
TaskType,
|
||||
TaskPresentationEventCommand,
|
||||
TaskPresentationEventResult,
|
||||
TaskPresentationCursor,
|
||||
)
|
||||
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||||
from app.infra.integrations.operational_actions import (
|
||||
@@ -92,10 +96,9 @@ def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskServi
|
||||
|
||||
|
||||
def _task_permissions(ctx: ProjectContext) -> set[str]:
|
||||
permissions = set(permissions_for_context(ctx))
|
||||
if ctx.system_role == "service":
|
||||
permissions.difference_update({SCADA_VIEW, SIMULATION_VIEW})
|
||||
return permissions
|
||||
# Investigations use preconfigured project access, including service accounts.
|
||||
# Do not force an already-authorized reader through legacy per-task delegation.
|
||||
return set(permissions_for_context(ctx))
|
||||
|
||||
|
||||
def _raise_domain(exc: DomainError):
|
||||
@@ -176,6 +179,8 @@ async def list_operational_task_runs(
|
||||
run_status: Literal[
|
||||
"queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"
|
||||
] | None = Query(default=None, alias="status"),
|
||||
task_type: TaskType | None = None,
|
||||
task_created_from: WorkbenchDateTime | None = None,
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
cursor: str | None = None,
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||
@@ -193,6 +198,8 @@ async def list_operational_task_runs(
|
||||
try:
|
||||
items, next_cursor = await _service(ctx, conn).list_runs(
|
||||
status=run_status,
|
||||
task_type=task_type,
|
||||
task_created_from=task_created_from,
|
||||
limit=limit,
|
||||
cursor_created_at=cursor_created_at,
|
||||
cursor_run_id=cursor_run_id,
|
||||
@@ -202,6 +209,32 @@ async def list_operational_task_runs(
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.get("/operational-tasks/{task_id}/runs", response_model=OperationalTaskRunPage)
|
||||
async def list_task_run_history(
|
||||
task_id: UUID,
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return OperationalTaskRunPage(items=await _service(ctx, conn).task_runs(task_id, limit, offset))
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.get("/operational-tasks/{task_id}/runs/{run_id}", response_model=OperationalTaskRun)
|
||||
async def get_task_run(
|
||||
task_id: UUID, run_id: UUID,
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await _service(ctx, conn).task_run(task_id, run_id)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/operational-tasks/schedules",
|
||||
response_model=OperationalTaskSchedule,
|
||||
@@ -272,7 +305,10 @@ async def recover_operational_task_leases(
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await _service(ctx, conn).recover_expired_leases(command.limit)
|
||||
return await _service(ctx, conn).recover_expired_leases(
|
||||
command.limit, task_type=command.task_type,
|
||||
task_created_from=command.task_created_from,
|
||||
)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
@@ -473,6 +509,31 @@ async def publish_operational_task_artifacts(
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.get("/operational-tasks/{task_id}/presentation-cursor", response_model=TaskPresentationCursor)
|
||||
async def get_operational_task_presentation_cursor(
|
||||
task_id: UUID,
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await _service(ctx, conn).presentation_cursor(task_id)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.post("/operational-tasks/{task_id}/presentation-events", response_model=TaskPresentationEventResult)
|
||||
async def publish_operational_task_presentation_event(
|
||||
task_id: UUID,
|
||||
command: TaskPresentationEventCommand,
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await _service(ctx, conn).publish_presentation_event(task_id, command)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/operational-tasks/{task_id}/data-query", response_model=TaskDataQueryResult
|
||||
)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.domain.schemas.place_search import PlaceSearchRequest, PlaceSearchResponse
|
||||
from app.services.place_search import PlaceSearchError, search_tianditu_places
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/place-search-requests",
|
||||
response_model=PlaceSearchResponse,
|
||||
summary="Search Fengxian places",
|
||||
description="调用天地图地名搜索,固定上海市奉贤区范围,只返回前 10 个可定位候选,不提供分页。",
|
||||
)
|
||||
async def tianditu_place_search(request: PlaceSearchRequest) -> PlaceSearchResponse:
|
||||
try:
|
||||
return await search_tianditu_places(request)
|
||||
except PlaceSearchError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
@@ -1,5 +1,5 @@
|
||||
from typing import Any, List, Literal, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from app.auth.keycloak_dependencies import get_current_keycloak_username
|
||||
@@ -18,7 +18,6 @@ from app.services.simulation_scenarios import (
|
||||
from app.services.valve_isolation import analyze_valve_isolation
|
||||
from app.domain.time import (
|
||||
parse_aware_time,
|
||||
parse_clock_duration_seconds,
|
||||
parse_utc_time,
|
||||
)
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
@@ -50,27 +49,9 @@ class PressureRegulation(BaseModel):
|
||||
def run_simulation_manually_by_date(
|
||||
network_name: str, start_time: datetime, duration: int
|
||||
) -> None:
|
||||
end_datetime = start_time + timedelta(minutes=duration)
|
||||
time_properties = simulation.get_time(network_name)
|
||||
hydraulic_step_seconds = parse_clock_duration_seconds(
|
||||
time_properties["HYDRAULIC TIMESTEP"],
|
||||
field_name="HYDRAULIC TIMESTEP",
|
||||
simulation.run_online_simulation(
|
||||
name=network_name, start_time=start_time, duration_seconds=duration * 60,
|
||||
)
|
||||
if hydraulic_step_seconds <= 0:
|
||||
raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.")
|
||||
hydraulic_step = timedelta(seconds=hydraulic_step_seconds)
|
||||
scada_mappings = simulation.query_corresponding_element_id_and_query_id(
|
||||
network_name
|
||||
)
|
||||
current_time = start_time
|
||||
while current_time < end_datetime:
|
||||
simulation.run_simulation(
|
||||
name=network_name,
|
||||
simulation_type="realtime",
|
||||
modify_pattern_start_time=current_time.isoformat(timespec="seconds"),
|
||||
scada_mappings=scada_mappings,
|
||||
)
|
||||
current_time += hydraulic_step
|
||||
|
||||
|
||||
# 必须用这个PlainTextResponse,不然每个key都有引号
|
||||
|
||||
@@ -11,9 +11,10 @@ from app.services.timeseries_history import TimeseriesHistoryService
|
||||
from .dependencies import get_timescale_connection, get_postgres_connection
|
||||
|
||||
router = APIRouter()
|
||||
history_router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
@history_router.post(
|
||||
"/timeseries/views/element-history/query",
|
||||
summary="批量查询管网元素历史数据",
|
||||
response_model=ElementHistoryResponse,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.services.web_search import (
|
||||
BochaSearchAPIError,
|
||||
BochaSearchConfigError,
|
||||
WebSearchRequest,
|
||||
search_bocha_web,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/web-searches",
|
||||
summary="Web Search",
|
||||
description="调用 Bocha Web Search API 获取实时网页搜索结果",
|
||||
)
|
||||
async def web_search(request: WebSearchRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return await search_bocha_web(request)
|
||||
except BochaSearchConfigError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except BochaSearchAPIError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
@@ -129,72 +129,8 @@ async def workbench_runtime(
|
||||
ctx: ProjectContext = Depends(get_project_context),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
analysis_runs: list[dict] | None = None
|
||||
latest_results: list[dict] | None = None
|
||||
latest_analysis_map_payload: dict | None = None
|
||||
analysis_availability = "not_configured"
|
||||
alert_storage_configured = False
|
||||
alerts: list[dict] | None = None
|
||||
schedule_availability = "not_configured"
|
||||
alert_availability = "not_configured"
|
||||
try:
|
||||
async with _projection_transaction(conn):
|
||||
analysis_runs = await AnalysisRepository.list_recent_runs(conn, limit=100)
|
||||
if analysis_runs:
|
||||
latest_results = await AnalysisRepository.list_recent_results(
|
||||
conn, analysis_runs[0]["run_id"], limit=100
|
||||
)
|
||||
analysis_availability = "ready" if analysis_runs else "not_configured"
|
||||
except Exception:
|
||||
logger.warning("Workbench analysis projection is unavailable", exc_info=True)
|
||||
analysis_runs = None
|
||||
latest_results = None
|
||||
analysis_availability = "degraded"
|
||||
if analysis_runs and latest_results:
|
||||
try:
|
||||
async with _projection_transaction(conn):
|
||||
latest_analysis_map_payload = await _sensor_location_map_payload(
|
||||
conn, latest_results
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Workbench analysis spatial references are unavailable",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
async with _projection_transaction(conn):
|
||||
operations_availability = await WorkbenchOperationsRepository.availability(conn)
|
||||
alert_storage_configured = operations_availability.alerts
|
||||
except Exception:
|
||||
logger.warning("Workbench operations storage probe is unavailable", exc_info=True)
|
||||
schedule_availability = "degraded"
|
||||
alert_availability = "degraded"
|
||||
if alert_storage_configured:
|
||||
try:
|
||||
async with _projection_transaction(conn):
|
||||
alert_documents = await WorkbenchOperationsRepository.list_alerts(
|
||||
conn, str(ctx.project_id)
|
||||
)
|
||||
alerts, invalid_alerts = _validate_documents(
|
||||
alert_documents, WorkbenchPersistedAlert, "alert"
|
||||
)
|
||||
alert_availability = "degraded" if invalid_alerts else "ready"
|
||||
except Exception:
|
||||
alerts = None
|
||||
alert_availability = "degraded"
|
||||
logger.warning("Workbench alert projection is unavailable", exc_info=True)
|
||||
return await build_workbench_runtime_snapshot(
|
||||
analysis_runs=analysis_runs,
|
||||
latest_analysis_results=latest_results,
|
||||
latest_analysis_map_payload=latest_analysis_map_payload,
|
||||
alert_storage_configured=alert_storage_configured,
|
||||
persisted_alerts=alerts,
|
||||
scenario_availability=analysis_availability,
|
||||
artifact_availability=analysis_availability,
|
||||
schedule_availability=schedule_availability,
|
||||
alert_availability=alert_availability,
|
||||
projection_degraded=False,
|
||||
)
|
||||
from app.services.workbench_runtime import read_workbench_runtime
|
||||
return await read_workbench_runtime(ctx, conn)
|
||||
|
||||
|
||||
async def _sensor_location_map_payload(
|
||||
|
||||
+13
-5
@@ -4,6 +4,7 @@ from app.api.v1.endpoints import (
|
||||
access,
|
||||
admin_metadata,
|
||||
agent_auth,
|
||||
agent_messages,
|
||||
audit,
|
||||
burst_detection,
|
||||
burst_location,
|
||||
@@ -12,12 +13,12 @@ from app.api.v1.endpoints import (
|
||||
meta,
|
||||
model_import,
|
||||
operational_tasks,
|
||||
place_search,
|
||||
project,
|
||||
project_data,
|
||||
scada,
|
||||
sensor_placement,
|
||||
simulation,
|
||||
web_search,
|
||||
workbench_runtime,
|
||||
workbench_events,
|
||||
)
|
||||
@@ -103,6 +104,7 @@ api_router.include_router(
|
||||
)
|
||||
api_router.include_router(access.router, tags=["Access Control"])
|
||||
api_router.include_router(agent_auth.router, tags=["Agent Auth"])
|
||||
api_router.include_router(agent_messages.router, tags=["Agent Messages"])
|
||||
api_router.include_router(
|
||||
admin_metadata.router,
|
||||
tags=["Metadata Admin"],
|
||||
@@ -159,13 +161,13 @@ api_router.include_router(
|
||||
dependencies=[optimization_run_access],
|
||||
)
|
||||
api_router.include_router(
|
||||
web_search.router,
|
||||
tags=["Web Search"],
|
||||
geocoding.router,
|
||||
tags=["Geocoding"],
|
||||
dependencies=[webgis_view_access],
|
||||
)
|
||||
api_router.include_router(
|
||||
geocoding.router,
|
||||
tags=["Geocoding"],
|
||||
place_search.router,
|
||||
tags=["Place Search"],
|
||||
dependencies=[webgis_view_access],
|
||||
)
|
||||
api_router.include_router(
|
||||
@@ -195,6 +197,12 @@ for endpoint_router, tag in (
|
||||
dependencies=[simulation_access],
|
||||
)
|
||||
|
||||
api_router.include_router(
|
||||
ts_composite.history_router,
|
||||
tags=["TimescaleDB - Composite"],
|
||||
dependencies=[Depends(require_permission(SCADA_VIEW))],
|
||||
)
|
||||
|
||||
for endpoint_router, tag in (
|
||||
(ts_scada.router, "TimescaleDB - SCADA"),
|
||||
(ts_composite.router, "TimescaleDB - Composite"),
|
||||
|
||||
Reference in New Issue
Block a user