feat: add project-scoped operations
This commit is contained in:
@@ -4,6 +4,8 @@ from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from app.domain.schemas.project_map_config import ProjectMapConfig, ProjectMapConfigUpdate
|
||||
|
||||
|
||||
BusinessRole = Literal["admin", "service", "user"]
|
||||
ProjectRole = Literal["member", "viewer"]
|
||||
@@ -75,10 +77,11 @@ class AdminProjectCreateRequest(BaseModel):
|
||||
description: str | None = None
|
||||
gs_workspace: str = Field(..., min_length=1, max_length=100)
|
||||
map_extent: dict | None = None
|
||||
map_config: ProjectMapConfig = Field(default_factory=ProjectMapConfig)
|
||||
status: ProjectStatus = "active"
|
||||
|
||||
|
||||
class AdminProjectUpdateRequest(BaseModel):
|
||||
class AdminProjectUpdateRequest(ProjectMapConfigUpdate):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
code: str | None = Field(default=None, min_length=1, max_length=50)
|
||||
description: str | None = None
|
||||
@@ -94,6 +97,7 @@ class AdminProjectResponse(BaseModel):
|
||||
description: str | None = None
|
||||
gs_workspace: str
|
||||
map_extent: dict | None = None
|
||||
map_config: ProjectMapConfig = Field(default_factory=ProjectMapConfig)
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Validated, local presentation blocks. Component props come from the frontend catalog."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from jsonschema import Draft7Validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
_catalog = json.loads(
|
||||
(Path(__file__).resolve().parents[3] / "contracts/answer-ui-catalog.json").read_text()
|
||||
)
|
||||
_validators = {name: Draft7Validator(schema) for name, schema in _catalog["props"].items()}
|
||||
Key = Annotated[str, Field(pattern=r"^[a-zA-Z0-9_-]{1,80}$")]
|
||||
|
||||
|
||||
class UiModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AnswerUiElement(UiModel):
|
||||
type: str
|
||||
props: dict[str, Any]
|
||||
children: list[Key] = Field(max_length=48)
|
||||
|
||||
|
||||
class AnswerUiSpec(UiModel):
|
||||
root: Key
|
||||
elements: dict[Key, AnswerUiElement]
|
||||
|
||||
|
||||
class AnswerUiBlock(UiModel):
|
||||
version: Literal["answer-ui@1"]
|
||||
block_id: Key
|
||||
fallback_text: Annotated[str, Field(min_length=1, max_length=4000)]
|
||||
spec: AnswerUiSpec
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_components(self):
|
||||
spec = self.spec
|
||||
if not 1 <= len(spec.elements) <= 48:
|
||||
raise ValueError("UI requires 1 to 48 elements")
|
||||
# Match the existing JavaScript serialized character limit, including UTF-16.
|
||||
serialized = json.dumps(self.model_dump(), ensure_ascii=False, separators=(",", ":"))
|
||||
if len(serialized.encode("utf-16-le")) // 2 > 100000:
|
||||
raise ValueError("UI block is too large")
|
||||
for element in spec.elements.values():
|
||||
validator = _validators.get(element.type)
|
||||
if validator is None or not validator.is_valid(element.props):
|
||||
raise ValueError("Unknown UI component or invalid properties")
|
||||
if element.children and element.type not in {"Stack", "Section"}:
|
||||
raise ValueError("Leaf components cannot contain children")
|
||||
props = element.props
|
||||
if element.type == "Chart":
|
||||
if any(len(series["data"]) != len(props["x_data"]) for series in props["series"]):
|
||||
raise ValueError("Chart series must match the axis")
|
||||
if any(not math.isfinite(value) for series in props["series"] for value in series["data"]):
|
||||
raise ValueError("Chart values must be finite")
|
||||
if element.type == "Table" and any(len(row) != len(props["columns"]) for row in props["rows"]):
|
||||
raise ValueError("Table row width must match its columns")
|
||||
if element.type == "FollowUpForm":
|
||||
names = [field["name"] for field in props["fields"]]
|
||||
if len(names) != len(set(names)):
|
||||
raise ValueError("Form field names must be unique")
|
||||
visited: set[str] = set()
|
||||
|
||||
def visit(key: str, depth: int):
|
||||
if depth > 12 or key in visited or key not in spec.elements:
|
||||
raise ValueError("UI must be a bounded tree without duplicate or missing references")
|
||||
visited.add(key)
|
||||
for child in spec.elements[key].children:
|
||||
visit(child, depth + 1)
|
||||
|
||||
visit(spec.root, 0)
|
||||
if len(visited) != len(spec.elements):
|
||||
raise ValueError("UI contains unreachable elements")
|
||||
return self
|
||||
|
||||
|
||||
class ReportUiBlock(UiModel):
|
||||
block: AnswerUiBlock
|
||||
evidence_refs: list[Annotated[str, Field(min_length=1, max_length=128)]] = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
def validate_report_ui_blocks(blocks: list[ReportUiBlock], evidence_refs: list[str]) -> None:
|
||||
ids = [item.block.block_id for item in blocks]
|
||||
if len(ids) != len(set(ids)):
|
||||
raise ValueError("Report UI block ids must be unique")
|
||||
serialized = json.dumps([item.model_dump() for item in blocks], ensure_ascii=False, separators=(",", ":"))
|
||||
if len(serialized.encode("utf-8")) > 400000:
|
||||
raise ValueError("Report UI exceeds 400 KB")
|
||||
for item in blocks:
|
||||
if not set(item.evidence_refs) <= set(evidence_refs):
|
||||
raise ValueError("UI evidence must be included in report evidence")
|
||||
for element in item.block.spec.elements.values():
|
||||
if element.type == "Evidence" and element.props.get("evidence_id") not in item.evidence_refs:
|
||||
raise ValueError("UI evidence component must reference its block evidence")
|
||||
@@ -1,7 +1,9 @@
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.domain.schemas.project_map_config import ProjectMapConfig
|
||||
|
||||
|
||||
class ProjectMetaResponse(BaseModel):
|
||||
@@ -11,6 +13,7 @@ class ProjectMetaResponse(BaseModel):
|
||||
description: Optional[str] = None
|
||||
gs_workspace: str
|
||||
map_extent: Optional[dict] = None
|
||||
map_config: ProjectMapConfig = Field(default_factory=ProjectMapConfig)
|
||||
status: str
|
||||
project_role: str
|
||||
|
||||
|
||||
@@ -16,6 +16,39 @@ TaskDataSource = Literal[
|
||||
|
||||
|
||||
McpSnapshotSource = Literal[
|
||||
'get_mutation_status',
|
||||
'query_nearby_elements',
|
||||
'list_regions',
|
||||
'get_region',
|
||||
'get_region_nodes',
|
||||
'get_node_connections',
|
||||
'query_network_extent',
|
||||
'list_major_nodes',
|
||||
'get_model_configuration',
|
||||
'list_model_configurations',
|
||||
'search_project_places',
|
||||
'reverse_geocode',
|
||||
'get_project_info',
|
||||
'export_project_model',
|
||||
'get_sensor_placement_candidate',
|
||||
'export_sensor_placement',
|
||||
'list_task_schedules',
|
||||
'get_task_run_history',
|
||||
'calculate_demand_allocation',
|
||||
'run_burst_simulation',
|
||||
'edit_model_element',
|
||||
'edit_model_configuration',
|
||||
'clean_scada_readings',
|
||||
'write_scada_readings',
|
||||
'delete_scada_readings',
|
||||
'save_simulation_scenario',
|
||||
'create_sensor_placement',
|
||||
'update_sensor_placement',
|
||||
'create_operational_task',
|
||||
'create_task_schedule',
|
||||
'update_task_schedule',
|
||||
'retry_operational_task',
|
||||
'cancel_task_run',
|
||||
'run_simulation',
|
||||
'run_contaminant_simulation',
|
||||
'analyze_valve_closure',
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import Field, JsonValue, model_validator
|
||||
from app.domain.schemas.answer_ui import ReportUiBlock, validate_report_ui_blocks
|
||||
|
||||
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel
|
||||
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
|
||||
@@ -253,9 +254,11 @@ class InvestigationReport(StrictModel):
|
||||
stop_reason: NonEmptyStr | None = None
|
||||
data_gaps: list[InvestigationDataGap] | None = None
|
||||
diagram: InvestigationDiagram | None = None
|
||||
ui_blocks: list["ReportUiBlock"] = Field(default_factory=list, max_length=8)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_result(self):
|
||||
validate_report_ui_blocks(self.ui_blocks, self.evidence_refs)
|
||||
if self.outcome is None:
|
||||
if self.stage is not None or self.stop_reason is not None or self.data_gaps is not None:
|
||||
raise ValueError("structured report fields must be supplied together")
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
MAX_PLACE_SEARCH_RESULTS = 10
|
||||
@@ -11,6 +9,13 @@ class PlaceSearchRequest(BaseModel):
|
||||
keyword: str = Field(..., min_length=1, max_length=120)
|
||||
|
||||
|
||||
class ReverseGeocodingRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
longitude: float = Field(ge=-180, le=180, allow_inf_nan=False)
|
||||
latitude: float = Field(ge=-90, le=90, allow_inf_nan=False)
|
||||
|
||||
|
||||
class PlaceSearchItem(BaseModel):
|
||||
id: str = Field(..., min_length=1)
|
||||
name: str = Field(..., min_length=1)
|
||||
@@ -18,11 +23,11 @@ class PlaceSearchItem(BaseModel):
|
||||
longitude: float = Field(..., ge=-180, le=180, allow_inf_nan=False)
|
||||
latitude: float = Field(..., ge=-90, le=90, allow_inf_nan=False)
|
||||
category: str | None = None
|
||||
district: Literal["奉贤区"] = "奉贤区"
|
||||
district: str = Field(min_length=1)
|
||||
|
||||
|
||||
class PlaceSearchResponse(BaseModel):
|
||||
items: list[PlaceSearchItem] = Field(..., max_length=MAX_PLACE_SEARCH_RESULTS)
|
||||
total: int = Field(..., ge=0, le=MAX_PLACE_SEARCH_RESULTS)
|
||||
truncated: bool = False
|
||||
region: Literal["奉贤区"] = "奉贤区"
|
||||
region: str = Field(min_length=1)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class PlaceSearchConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||
|
||||
enabled: bool = False
|
||||
provider: Literal["tianditu"] = "tianditu"
|
||||
admin_code: str = Field(pattern=r"^(?:156)?[0-9]{6}$")
|
||||
region: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class ProjectMapConfig(BaseModel):
|
||||
# Preserve existing map options such as legend when editing search settings.
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
place_search: PlaceSearchConfig | None = None
|
||||
|
||||
|
||||
class ProjectMapConfigUpdate(BaseModel):
|
||||
map_config: ProjectMapConfig | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def reject_null_map_config(self):
|
||||
if "map_config" in self.model_fields_set and self.map_config is None:
|
||||
raise ValueError("map_config must be an object")
|
||||
return self
|
||||
@@ -1,4 +1,6 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ScadaDeviceResponse(BaseModel):
|
||||
@@ -17,3 +19,31 @@ class ScadaDeviceResponse(BaseModel):
|
||||
y: float | None = None
|
||||
longitude: float | None = None
|
||||
latitude: float | None = None
|
||||
|
||||
|
||||
class ScadaLatestReadingResponse(BaseModel):
|
||||
"""Latest persisted observation for one project SCADA device."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
||||
|
||||
device_id: str
|
||||
device_type: str
|
||||
measurement_unit: str
|
||||
transmission_mode: str
|
||||
transmission_frequency: str
|
||||
observed_at: datetime | None = None
|
||||
monitored_value: float | None = None
|
||||
cleaned_value: float | None = None
|
||||
|
||||
|
||||
class ScadaLatestReadingsResponse(BaseModel):
|
||||
"""Bounded device page with each device's latest persisted observation."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
||||
|
||||
items: list[ScadaLatestReadingResponse]
|
||||
total: int = Field(ge=0)
|
||||
limit: int = Field(ge=1, le=500)
|
||||
offset: int = Field(ge=0)
|
||||
observed_count: int = Field(ge=0)
|
||||
latest_observed_at: datetime | None = None
|
||||
|
||||
Reference in New Issue
Block a user