feat: add project-scoped operations
Generic Container CI/CD / test-build-publish (push) Failing after 6s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 6s

This commit is contained in:
2026-10-22 18:40:20 +08:00
parent d320972d57
commit 1d061f6099
73 changed files with 4384 additions and 228 deletions
+5 -1
View File
@@ -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
+99
View File
@@ -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")
+4 -1
View File
@@ -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
+33
View File
@@ -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',
+3
View File
@@ -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")
+9 -4
View File
@@ -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)
+29
View File
@@ -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
+31 -1
View File
@@ -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