Coverage for postrfp/fsm/schemas.py: 99%
97 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-22 21:34 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-22 21:34 +0000
1from typing import List, Optional, Literal, Set, Annotated
2from pydantic import (
3 BaseModel,
4 Field,
5 ConfigDict,
6 model_validator,
7 computed_field,
8 StringConstraints,
9)
11from postrfp.shared.types import PermString
12from postrfp.shared.serial.common import Pagination
15class DbModel(BaseModel):
16 """Base model for database schemas."""
18 model_config = ConfigDict(
19 from_attributes=True,
20 populate_by_name=True,
21 use_enum_values=True,
22 arbitrary_types_allowed=True,
23 )
26class IdSchema(BaseModel):
27 """Schema for returning an ID."""
29 id: int = Field(..., description="Unique identifier")
32# Base schemas for simple requests/responses
33class StatusSchema(DbModel):
34 """Schema for state data compatible with editor."""
36 id: Optional[int] = None
37 name: str = Field(
38 ..., max_length=64, description="Human-readable name of the state"
39 )
40 code: str = Field(max_length=64, description="System identifier for the state")
41 colour: Optional[
42 Annotated[str, StringConstraints(max_length=7, pattern=r"^#[0-9A-Fa-f]{6}$")]
43 ] = Field(None, description="Hex color code (#RRGGBB)", examples=["#1E90FF"])
45 status_actions: list[PermString] = []
46 description: Optional[str] = Field(max_length=512, default=None)
49class TransitionSchema(DbModel):
50 """Schema for transition data compatible with editor."""
52 id: Optional[int] = None
53 name: str = Field(..., max_length=64)
55 source_status: StatusSchema
56 target_status: StatusSchema
58 guard_policy: Optional[str] = Field(
59 default=None, description="CEL expression for guard condition"
60 )
62 @computed_field # type: ignore[misc]
63 @property
64 def source(self) -> str:
65 return self.source_status.code
67 @computed_field # type: ignore[misc]
68 @property
69 def target(self) -> str:
70 return self.target_status.code
73class WorkflowSchema(DbModel):
74 """Schema for definition compatible with editor."""
76 id: Optional[int] = Field(None, description="ID of the FSM definition")
77 title: str = Field(..., max_length=128)
78 description: str | None = Field(None, max_length=1024)
79 entity_type: str = Field(..., max_length=64)
80 initial_status_code: Optional[str] = Field(
81 default="Not Set", max_length=64, description="Code of the initial state"
82 )
83 current_state: Optional[str] = Field(
84 None, description="Current state code (if applicable)"
85 )
87 context_spec: Optional[str] = Field(
88 None, description="JSON schema for context validation"
89 )
90 version: int = 1
91 is_active: bool = True
93 organisation_id: str = Field(
94 ..., description="ID of the organisation that owns this "
95 )
96 statuses: List[StatusSchema] = Field(default_factory=list, min_length=1)
97 transitions: List[TransitionSchema] = []
99 @model_validator(mode="after")
100 def validate_workflow_integrity(self) -> "WorkflowSchema":
101 """
102 Validate the workflow structure:
103 - Status names must be unique
104 - Status codes must be unique
105 - Transition names must be unique
106 - Each status must be involved in at least one transition (no orphans)
107 - If transitions exist, statuses must exist too
108 - All transition source and target codes must refer to valid statuses
109 """
110 if self.transitions and not self.statuses:
111 raise ValueError("Workflow contains transitions but no statuses")
113 if not self.statuses:
114 return self
116 status_names = [status.name for status in self.statuses]
117 if len(status_names) != len(set(status_names)):
118 raise ValueError("Duplicate status name found")
120 status_codes = [status.code for status in self.statuses]
121 if len(status_codes) != len(set(status_codes)):
122 raise ValueError("Duplicate status code found")
124 if not self.transitions:
125 return self
127 transition_names = [transition.name for transition in self.transitions]
128 if len(transition_names) != len(set(transition_names)):
129 raise ValueError("Duplicate transition name found")
131 valid_status_codes = set(status.code for status in self.statuses)
133 for transition in self.transitions:
134 if transition.source not in valid_status_codes:
135 raise ValueError(
136 f"Transition '{transition.name}' references invalid source status code: '{transition.source}'"
137 )
138 if transition.target not in valid_status_codes:
139 raise ValueError(
140 f"Transition '{transition.name}' references invalid target status code: '{transition.target}'"
141 )
143 involved_status_codes: Set[str] = set()
145 for transition in self.transitions:
146 involved_status_codes.add(transition.source)
147 involved_status_codes.add(transition.target)
149 all_status_codes = valid_status_codes
150 uninvolved_status_codes = all_status_codes - involved_status_codes
151 if uninvolved_status_codes:
152 raise ValueError(
153 f"Statuses not involved in any transition, code(s): {uninvolved_status_codes}"
154 )
156 return self
159class WorkflowSummary(DbModel):
160 """Schema for a summary of a workflow definition."""
162 id: int = Field(..., description="ID of the FSM definition")
163 title: str = Field(..., max_length=128)
164 entity_type: str = Field(..., max_length=64)
165 version: int = 1
166 is_active: bool = True
169class WorkflowList(DbModel):
170 """Schema for a list of transitions."""
172 data: List[WorkflowSummary]
173 pagination: Pagination
176class TransitionRequest(BaseModel):
177 entity_id: int
178 entity_type: Literal["Project", "Issue"]
179 transition_name: str
182class TransitionResult(BaseModel):
183 """
184 Result returned from running an Entity transition
185 """
187 transition_permitted: bool
188 message: Optional[str] = Field(
189 None, description="Message returned from the Angst server"
190 )
191 job_ref: Optional[str] = Field(
192 None, description="Job ID of any HTTP calls invoked on the Angst server"
193 )
196class EntityTypeSchema(DbModel):
197 """Schema for FSM entity types."""
199 name: str = Field(..., description="Entity class name")
200 table_name: str = Field(..., description="SQLAlchemy table name")
201 context_schema: dict = Field(..., description="JSON schema for context validation")
204class EntityTypeList(DbModel):
205 """Schema for a list of FSM entity types."""
207 data: List[EntityTypeSchema]