Files
next-tjwater-server/samples/peripheral/schemas.py
T

439 lines
13 KiB
Python

from __future__ import annotations
from typing import Annotated, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictFloat, StrictInt, model_validator
from app.domain.schemas.shared import ObjectRef, TimeRange, WireDateTime
from app.domain.schemas.workbench_runtime import WorkbenchRuntimeWorkOrderSubtask
Text = Annotated[str, Field(min_length=1, max_length=4000)]
PositiveInt = Annotated[StrictInt, Field(ge=1)]
TaskStatus = Literal["pending", "processing", "pending_review", "completed"]
Scenario = Literal["normal", "missing_data", "unavailable", "response_lost", "duplicate_feedback", "conflicting_feedback", "late_state"]
class Model(BaseModel):
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
class SourceLink(Model):
system: Literal["next"]
task_id: UUID
object_type: Literal["validation", "artifact"]
object_id: UUID
revision: PositiveInt
class CreateTask(Model):
request_id: UUID
title: Annotated[str, Field(min_length=1, max_length=200)]
summary: Text
source: SourceLink
asset_ids: Annotated[list[Annotated[str, Field(min_length=1, max_length=128)]], Field(min_length=1, max_length=100)]
analysis_window: TimeRange
evidence_refs: Annotated[list[ObjectRef], Field(max_length=100)]
requested_check: Text
@model_validator(mode="after")
def unique_assets(self):
if len(set(self.asset_ids)) != len(self.asset_ids):
raise ValueError("asset_ids must be unique")
return self
class Transition(Model):
request_id: UUID
expected_revision: PositiveInt
target_status: TaskStatus
reason: Text
class AddFeedback(Model):
request_id: UUID
expected_revision: PositiveInt
feedback_id: UUID
occurred_at: WireDateTime
applicable_time: TimeRange
asset_ids: Annotated[list[Annotated[str, Field(min_length=1, max_length=128)]], Field(min_length=1, max_length=100)]
finding: Literal["instrument_fault", "no_instrument_fault", "uncertain"]
notes: Text
attachment_refs: Annotated[list[Annotated[str, Field(min_length=1, max_length=500)]], Field(max_length=20)]
@model_validator(mode="after")
def aware_observation(self):
if self.occurred_at.tzinfo is None:
raise ValueError("occurred_at must include timezone")
if len(set(self.asset_ids)) != len(self.asset_ids):
raise ValueError("asset_ids must be unique")
return self
class Feedback(Model):
feedback_id: UUID
task_id: UUID
recorded_by: str
recorded_at: WireDateTime
occurred_at: WireDateTime
applicable_time: TimeRange
asset_ids: list[str]
finding: Literal["instrument_fault", "no_instrument_fault", "uncertain"]
notes: str
attachment_refs: list[str]
origin: Literal["synthetic"]
class HistoryEntry(Model):
sequence: PositiveInt
from_status: TaskStatus | None
to_status: TaskStatus
actor: str
at: WireDateTime
reason: str
class Task(Model):
task_id: UUID
project_id: UUID
revision: PositiveInt
title: str
summary: str
source: SourceLink
asset_ids: list[str]
analysis_window: TimeRange
evidence_refs: list[ObjectRef]
requested_check: str
status: TaskStatus
origin: Literal["synthetic"]
created_at: WireDateTime
updated_at: WireDateTime
created_by: str
history: list[HistoryEntry]
feedback: list[Feedback]
class TaskPage(Model):
items: list[Task]
next_cursor: UUID | None
WorkOrderStatus = Literal[
"draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled"
]
Priority = Literal["low", "normal", "high", "urgent"]
WorkOrderType = Literal["general", "water_quality_complaint", "burst_report"]
WorkOrderSubtasks = Annotated[
list[WorkbenchRuntimeWorkOrderSubtask], Field(min_length=1, max_length=50)
]
def _validate_unique_subtasks(
subtasks: list[WorkbenchRuntimeWorkOrderSubtask] | None,
) -> None:
if subtasks is not None and len({item.id for item in subtasks}) != len(subtasks):
raise ValueError("work order subtask ids must be unique")
class CreateWorkOrder(Model):
request_id: UUID
title: Annotated[str, Field(min_length=1, max_length=200)]
summary: Text
status: WorkOrderStatus = "draft"
priority: Priority = "normal"
assignee: Annotated[str, Field(min_length=1, max_length=200)] | None = None
scheduled_at: WireDateTime | None = None
work_order_type: WorkOrderType = "general"
location: Annotated[str, Field(min_length=1, max_length=200)] | None = None
scenario_code: Annotated[str, Field(min_length=1, max_length=128)] | None = None
subtasks: WorkOrderSubtasks | None = None
@model_validator(mode="after")
def aware_schedule(self):
if self.scheduled_at is not None and self.scheduled_at.tzinfo is None:
raise ValueError("scheduled_at must include timezone")
_validate_unique_subtasks(self.subtasks)
return self
class UpdateWorkOrder(Model):
request_id: UUID
expected_revision: PositiveInt
title: Annotated[str, Field(min_length=1, max_length=200)] | None = None
summary: Text | None = None
status: WorkOrderStatus | None = None
priority: Priority | None = None
assignee: Annotated[str, Field(min_length=1, max_length=200)] | None = None
clear_assignee: StrictBool = False
scheduled_at: WireDateTime | None = None
clear_scheduled_at: StrictBool = False
location: Annotated[str, Field(min_length=1, max_length=200)] | None = None
clear_location: StrictBool = False
scenario_code: Annotated[str, Field(min_length=1, max_length=128)] | None = None
clear_scenario_code: StrictBool = False
subtasks: WorkOrderSubtasks | None = None
clear_subtasks: StrictBool = False
@model_validator(mode="after")
def valid_update(self):
changed = set(self.model_fields_set) - {"request_id", "expected_revision"}
if not changed:
raise ValueError("at least one work order field must be updated")
if self.clear_assignee and self.assignee is not None:
raise ValueError("clear_assignee conflicts with assignee")
if self.clear_scheduled_at and self.scheduled_at is not None:
raise ValueError("clear_scheduled_at conflicts with scheduled_at")
if self.clear_location and self.location is not None:
raise ValueError("clear_location conflicts with location")
if self.clear_scenario_code and self.scenario_code is not None:
raise ValueError("clear_scenario_code conflicts with scenario_code")
if self.clear_subtasks and self.subtasks is not None:
raise ValueError("clear_subtasks conflicts with subtasks")
if self.scheduled_at is not None and self.scheduled_at.tzinfo is None:
raise ValueError("scheduled_at must include timezone")
_validate_unique_subtasks(self.subtasks)
return self
class DeleteResource(Model):
request_id: UUID
expected_revision: PositiveInt
class WorkOrder(Model):
work_order_id: UUID
project_id: UUID
revision: PositiveInt
title: str
summary: str
status: WorkOrderStatus
priority: Priority
assignee: str | None
scheduled_at: WireDateTime | None
origin: Literal["synthetic"]
created_at: WireDateTime
updated_at: WireDateTime
created_by: str
updated_by: str
work_order_type: WorkOrderType = "general"
location: str | None = None
scenario_code: str | None = None
subtasks: WorkOrderSubtasks | None = None
@model_validator(mode="after")
def unique_subtasks(self):
_validate_unique_subtasks(self.subtasks)
return self
class WorkOrderPage(Model):
items: list[WorkOrder]
next_cursor: UUID | None
class CreateTestDataRecord(Model):
request_id: UUID
label: Annotated[str, Field(min_length=1, max_length=200)]
point_id: Annotated[str, Field(min_length=1, max_length=128)]
observed_at: WireDateTime
value: StrictFloat | StrictInt | None
unit: Annotated[str, Field(min_length=1, max_length=32)]
quality: Literal["valid", "unknown", "missing"] = "valid"
notes: Annotated[str, Field(max_length=4000)] = ""
@model_validator(mode="after")
def aware_observation(self):
if self.observed_at.tzinfo is None:
raise ValueError("observed_at must include timezone")
return self
class UpdateTestDataRecord(Model):
request_id: UUID
expected_revision: PositiveInt
label: Annotated[str, Field(min_length=1, max_length=200)] | None = None
point_id: Annotated[str, Field(min_length=1, max_length=128)] | None = None
observed_at: WireDateTime | None = None
value: StrictFloat | StrictInt | None = None
clear_value: StrictBool = False
unit: Annotated[str, Field(min_length=1, max_length=32)] | None = None
quality: Literal["valid", "unknown", "missing"] | None = None
notes: Annotated[str, Field(max_length=4000)] | None = None
@model_validator(mode="after")
def valid_update(self):
changed = set(self.model_fields_set) - {"request_id", "expected_revision"}
if not changed:
raise ValueError("at least one test data field must be updated")
if self.clear_value and self.value is not None:
raise ValueError("clear_value conflicts with value")
if self.observed_at is not None and self.observed_at.tzinfo is None:
raise ValueError("observed_at must include timezone")
return self
class TestDataRecord(Model):
record_id: UUID
project_id: UUID
revision: PositiveInt
label: str
point_id: str
observed_at: WireDateTime
value: float | int | None
unit: str
quality: Literal["valid", "unknown", "missing"]
notes: str
origin: Literal["synthetic"]
created_at: WireDateTime
updated_at: WireDateTime
created_by: str
updated_by: str
class TestDataRecordPage(Model):
items: list[TestDataRecord]
next_cursor: UUID | None
class DeletedResource(Model):
resource_type: Literal["work_order", "test_data_record"]
resource_id: UUID
revision: Annotated[StrictInt, Field(ge=2)]
deleted: Literal[True]
class Receipt(Model):
request_id: UUID
task_id: UUID | None
result: Task | ScenarioState | WorkOrder | TestDataRecord | DeletedResource
class ScenarioCommand(Model):
request_id: UUID
scenario: Scenario
class ScenarioState(Model):
scenario: Scenario
revision: PositiveInt
ResourceAvailability = Literal["available", "not_configured"]
class ManagedResourceCapabilities(Model):
work_orders: ResourceAvailability
test_data: ResourceAvailability
class Context(Model):
protocol_version: Literal["tjwater-peripheral-sample@1"]
origin: Literal["synthetic"]
project_id: UUID
user_id: UUID
can_write: StrictBool
scenarios: list[Scenario]
state_transitions: dict[str, list[str]]
managed_resources: ManagedResourceCapabilities
class Point(Model):
point_id: str
label: str
unit: str
status: str
origin: Literal["historical_replay", "synthetic"]
class PointPage(Model):
items: list[Point]
source_id: str
origin: Literal["historical_replay", "synthetic"]
next_cursor: str | None
class Observation(Model):
record_id: str
point_id: str
observed_at: WireDateTime
value: float | None
quality: Literal["valid", "unknown", "missing"]
unit: str
class Observations(Model):
origin: Literal["historical_replay", "synthetic"]
source_id: str
source_revision: str
items: list[Observation]
truncated: StrictBool
class Alert(Model):
alert_id: str
point_id: str
occurred_at: WireDateTime
message: str
origin: Literal["historical_replay", "synthetic"]
class AlertPage(Model):
items: list[Alert]
source_id: str
origin: Literal["historical_replay", "synthetic"]
availability: Literal["available", "not_configured"]
reason: str | None
class SampleError(Model):
code: str
message: str
request_id: UUID | None
effect: Literal["none", "unknown"]
class DemoDeviceState(Model):
record_id: UUID
asset_id: str
asset_type: Literal["pressure_sensor", "pump", "valve"]
observed_at: WireDateTime
state: Literal["online", "offline", "running", "stopped", "open", "closed"]
class DemoAlert(Model):
alert_id: UUID
asset_id: str
occurred_at: WireDateTime
cleared_at: WireDateTime | None
code: Literal["communication_lost", "low_level"]
message: str
class DemoOperation(Model):
record_id: UUID
asset_id: str
occurred_at: WireDateTime
operation: Literal["pump_start", "pump_stop", "valve_open", "valve_close"]
actor: Literal["synthetic-generator"]
note: str
class DemoContent(Model):
generator_version: Literal["water-demo@2"]
seed: str
origin: Literal["synthetic"]
window: TimeRange
points: list[Point]
observations: list[Observation]
device_states: list[DemoDeviceState]
alerts: list[DemoAlert]
operations: list[DemoOperation]
class DemoDataset(DemoContent):
project_id: UUID
source_id: str
source_revision: str
Receipt.model_rebuild()