398 lines
11 KiB
Python
398 lines
11 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
|
|
|
|
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", "completed", "cancelled"]
|
|
Priority = Literal["low", "normal", "high", "urgent"]
|
|
|
|
|
|
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
|
|
|
|
@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")
|
|
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
|
|
|
|
@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.scheduled_at is not None and self.scheduled_at.tzinfo is None:
|
|
raise ValueError("scheduled_at must include timezone")
|
|
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
|
|
|
|
|
|
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()
|