727d86614e
P0 (7): Auth-bypass removed, migrations fixed, plugin-upload disabled, RLS FORCE+WITH CHECK, plugin double-registration fixed, persistent volume, domain removed P1 (11): User/tenant model, Redis centralized, worker separated, transactional outbox, XSS fixed, DMS chunked streaming, permissions unified, password reset, metrics secured, config/docs fixed, cross-tenant FK P2 (4): Contact model normalized, cross-imports reduced 94%, commands+state machines for contacts/dms/mail/calendar, SPA path-traversal 8 new migrations, 99 unit tests, 13 commands, 8 contracts, 72 files changed
67 lines
2.2 KiB
Python
67 lines
2.2 KiB
Python
"""Generic finite state machine for domain entity status transitions.
|
|
|
|
Defines allowed state transitions for Contact and Workflow entities.
|
|
Usage in Commands:
|
|
|
|
from app.core.state_machine import contact_state_machine
|
|
contact_state_machine.transition(contact.status, "qualified")
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
class StateMachineError(Exception):
|
|
"""Raised when an invalid state transition is attempted."""
|
|
|
|
|
|
class StateMachine:
|
|
"""Finite state machine that validates and executes state transitions.
|
|
|
|
Attributes:
|
|
transitions: Mapping from a state to the list of states it can transition to.
|
|
"""
|
|
|
|
def __init__(self, transitions: dict[str, list[str]]) -> None:
|
|
self.transitions: dict[str, list[str]] = transitions
|
|
|
|
def can_transition(self, current: str, target: str) -> bool:
|
|
"""Return True if transitioning from *current* to *target* is allowed."""
|
|
allowed = self.transitions.get(current, [])
|
|
return target in allowed
|
|
|
|
def transition(self, current: str, target: str) -> str:
|
|
"""Validate and return the new state.
|
|
|
|
Raises:
|
|
StateMachineError: if the transition is not allowed.
|
|
"""
|
|
if not self.can_transition(current, target):
|
|
raise StateMachineError(
|
|
f"Invalid state transition: '{current}' -> '{target}'. "
|
|
f"Allowed targets from '{current}': {self.transitions.get(current, [])}"
|
|
)
|
|
return target
|
|
|
|
|
|
# ── Contact lifecycle: lead → qualified → customer → inactive ──
|
|
# Allows skipping 'qualified' and reactivation from inactive.
|
|
contact_state_machine = StateMachine(
|
|
transitions={
|
|
"lead": ["qualified", "customer", "inactive"],
|
|
"qualified": ["customer", "lead", "inactive"],
|
|
"customer": ["inactive"],
|
|
"inactive": ["lead"],
|
|
}
|
|
)
|
|
|
|
# ── Workflow lifecycle: draft → active → paused → completed → cancelled ──
|
|
workflow_state_machine = StateMachine(
|
|
transitions={
|
|
"draft": ["active", "cancelled"],
|
|
"active": ["paused", "completed", "cancelled"],
|
|
"paused": ["active", "completed", "cancelled"],
|
|
"completed": [],
|
|
"cancelled": [],
|
|
}
|
|
)
|