Coverage for postrfp/model/fsm.py: 95%
95 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 datetime import datetime
2from typing import Optional, List, Set, TYPE_CHECKING
3from sqlalchemy import VARCHAR, ForeignKey, UniqueConstraint, Index, func, String
4from sqlalchemy.orm import (
5 Mapped,
6 mapped_column,
7 relationship,
8 validates,
9) # Import validates
10from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy
13from postrfp.model.helpers import random_string
14from postrfp.authorisation import perms
15from postrfp.model.meta import Base
16from postrfp.shared.types import PermString
17from postrfp.shared.constants import CEL_EXPRESSION_MAX_LENGTH
19if TYPE_CHECKING:
20 from postrfp.model import Organisation
23VALID_ACTIONS = perms.ALL_PERMISSIONS
25CODE_LENGTH = 12 # String length for Status Code
28class Workflow(Base):
29 """Stores metadata about a Finite State Machine definition."""
31 __tablename__ = "workflows"
33 # Indices and constraints
34 __table_args__ = (
35 UniqueConstraint("title", "entity_type", "organisation_id", "version"),
36 )
38 id: Mapped[int] = mapped_column(primary_key=True)
39 title: Mapped[str] = mapped_column(String(128), nullable=False)
40 description: Mapped[Optional[str]] = mapped_column(
41 String(1024), default=None, nullable=True
42 )
43 entity_type: Mapped[str] = mapped_column(String(64), nullable=False)
44 organisation_id: Mapped[str] = mapped_column(
45 ForeignKey(
46 "organisations.id",
47 ondelete="CASCADE",
48 ),
49 nullable=False,
50 )
51 version: Mapped[int] = mapped_column(default=1, nullable=False)
52 is_active: Mapped[bool] = mapped_column(default=True, nullable=False)
53 initial_status_code: Mapped[str] = mapped_column(
54 String(CODE_LENGTH), nullable=False
55 )
57 date_created: Mapped[datetime] = mapped_column(
58 server_default=func.utc_timestamp(), nullable=False
59 )
60 date_modified: Mapped[datetime] = mapped_column(
61 server_default=func.utc_timestamp(),
62 server_onupdate=func.utc_timestamp(),
63 nullable=False,
64 )
66 # Relationships
67 organisation: Mapped["Organisation"] = relationship(
68 "Organisation", foreign_keys=[organisation_id]
69 )
70 statuses: Mapped[List["Status"]] = relationship(
71 back_populates="workflow", cascade="all, delete-orphan"
72 )
73 # Add overlaps parameter to status_query
74 status_query = relationship("Status", lazy="dynamic", overlaps="statuses")
76 transitions: Mapped[List["Transition"]] = relationship(
77 back_populates="workflow", cascade="all, delete-orphan"
78 )
80 transitions_query = relationship(
81 "Transition", lazy="dynamic", overlaps="transitions"
82 )
84 def status_by_name(self, status_name: str) -> "Status":
85 return self.status_query.filter_by(name=status_name).scalar()
87 def get_initial_status(self) -> "Status":
88 initial_status = self.status_query.filter_by(
89 code=self.initial_status_code
90 ).scalar()
91 if initial_status is None:
92 raise ValueError(
93 f"Initial status with code '{self.initial_status_code}' not found in workflow '{self.title}'"
94 )
95 return initial_status
97 def __repr__(self):
98 return f"<Workflow #{self.id} '{self.title}'>"
100 def validate(self):
101 """
102 A basic check of the structure of this workflow
103 """
105 if len(self.statuses) != len({s.name for s in self.statuses}):
106 raise ValueError("Duplicate status name found")
108 status_codes = {s.code for s in self.statuses}
109 if len(self.statuses) != len(status_codes):
110 raise ValueError("Duplicate status code found")
112 if len(self.transitions) != len({t.name for t in self.transitions}):
113 raise ValueError("Duplicate transition name found")
115 # Find orphans
116 involved_status_codes = set()
117 for trans in self.transitions:
118 # Use status codes directly from related objects
119 involved_status_codes.add(trans.source_status.code)
120 involved_status_codes.add(trans.target_status.code)
122 all_status_codes = {s.code for s in self.statuses}
123 uninvolved_status_codes = all_status_codes - involved_status_codes
125 if len(uninvolved_status_codes) > 0:
126 raise ValueError(
127 f"Statuses not involved in any transition, code(s): {uninvolved_status_codes}"
128 )
131class Status(Base):
132 """Represents a status value for an entity. A "state" in a finite state machine."""
134 __tablename__ = "statuses"
136 __table_args__ = (
137 UniqueConstraint("workflow_id", "code"),
138 UniqueConstraint("workflow_id", "name"),
139 UniqueConstraint(
140 "id", "workflow_id"
141 ), # Required for FSMEntity foreign key constraint
142 )
144 id: Mapped[int] = mapped_column(primary_key=True)
145 workflow_id: Mapped[int] = mapped_column(
146 ForeignKey(
147 "workflows.id",
148 ondelete="CASCADE",
149 ),
150 nullable=False,
151 )
152 name: Mapped[str] = mapped_column(String(128), nullable=False)
153 code: Mapped[str] = mapped_column(
154 String(CODE_LENGTH), nullable=False, default=random_string
155 )
156 description: Mapped[Optional[str]] = mapped_column(String(512))
158 colour: Mapped[Optional[str]] = mapped_column(
159 String(length=7),
160 nullable=True,
161 comment="Hex color code (#RRGGBB)",
162 )
164 # Relationships
165 # Add overlaps parameter to workflow relationship
166 workflow: Mapped[Workflow] = relationship(
167 back_populates="statuses", overlaps="status_query"
168 )
170 actions: Mapped[List["StatusAction"]] = relationship(
171 # Do not add lazy="joined" or the useful Set-like behaviour
172 # is compromised by needed to call "unique()" on all queries
173 back_populates="status",
174 cascade="all, delete-orphan",
175 collection_class=set,
176 )
177 outgoing_transitions: Mapped[List["Transition"]] = relationship(
178 foreign_keys="Transition.source_status_id",
179 back_populates="source_status",
180 )
181 incoming_transitions: Mapped[List["Transition"]] = relationship(
182 foreign_keys="Transition.target_status_id",
183 back_populates="target_status",
184 )
186 status_actions: AssociationProxy[Set[PermString]] = association_proxy(
187 "actions",
188 "action",
189 creator=lambda action_name: StatusAction(action=action_name),
190 )
192 def has_action(self, action_name: PermString) -> bool:
193 return action_name in self.status_actions
195 def __repr__(self):
196 return f"<Status #{self.id} {self.name}>"
199class Transition(Base):
200 """Represents a transition between statuses."""
202 __tablename__ = "transitions"
204 __table_args__ = (
205 UniqueConstraint("workflow_id", "source_status_id", "name"),
206 Index("ix_transition_definition", "workflow_id"),
207 Index("ix_transition_source", "source_status_id"),
208 Index("ix_transition_target", "target_status_id"),
209 )
211 id: Mapped[int] = mapped_column(primary_key=True)
212 workflow_id: Mapped[int] = mapped_column(
213 ForeignKey(
214 "workflows.id",
215 ondelete="CASCADE",
216 ),
217 nullable=False,
218 )
219 guard_policy: Mapped[Optional[str]] = mapped_column(
220 VARCHAR(length=CEL_EXPRESSION_MAX_LENGTH), nullable=True
221 )
223 name: Mapped[str] = mapped_column(String(64), nullable=False)
224 source_status_id: Mapped[int] = mapped_column(
225 ForeignKey(
226 "statuses.id",
227 ondelete="CASCADE",
228 ),
229 nullable=False,
230 )
232 target_status_id: Mapped[int] = mapped_column(
233 ForeignKey(
234 "statuses.id",
235 ondelete="CASCADE",
236 ),
237 nullable=False,
238 )
240 # Relationships
241 workflow: Mapped[Workflow] = relationship(
242 back_populates="transitions", overlaps="transitions_query"
243 )
244 source_status: Mapped[Status] = relationship(
245 foreign_keys=[source_status_id], back_populates="outgoing_transitions"
246 )
247 target_status: Mapped[Status] = relationship(
248 foreign_keys=[target_status_id], back_populates="incoming_transitions"
249 )
251 def has_functions(self) -> bool:
252 """Does this Transition define any Javascript functions"""
253 return self.guard_policy is not None
255 def __repr__(self):
256 return f"<Transition #{self.id} {self.source_status.name} -> {self.target_status.name}>"
259class StatusAction(Base):
260 """Maps statuses to allowed actions/permissions."""
262 __tablename__ = "status_actions"
264 __table_args__ = (UniqueConstraint("status_id", "action"),)
266 id: Mapped[int] = mapped_column(primary_key=True)
267 status_id: Mapped[int] = mapped_column(
268 ForeignKey(
269 "statuses.id",
270 ondelete="CASCADE",
271 ),
272 nullable=False,
273 index=True,
274 )
275 action: Mapped[PermString] = mapped_column(String(64), nullable=False, index=True)
277 status: Mapped[Status] = relationship(back_populates="actions")
279 @validates("action")
280 def validate_action(self, key, action_value):
281 """Ensure the action value is a valid action string."""
282 if action_value not in VALID_ACTIONS:
283 raise ValueError(
284 f"Invalid action: '{action_value}'. Must be one of {VALID_ACTIONS}"
285 )
286 return action_value