Skip to content

State Machine ​

The lightweight state machine has three responsibilities:

  1. Validate from_state → to_state
  2. Update the model state and any additional fields in the same save
  3. Call a logger with audit data

It does not create a separate TransitionLog table; use radar_log for audit records.

Source: app/core/state_machine.py.

Define ​

python
from app.utils import StateMachine

PRODUCT_FSM = StateMachine(
    transitions={
        "draft": ["active"],
        "active": ["archived"],
        "archived": [],
    }
)

transitions is an adjacency list: {current_state: [allowed_targets]}.

Invalid transitions raise TransitionError(code=Code.STATE_TRANSITION_INVALID, ...) by default. A business module can opt into its own code through the keyword-only constructor argument:

python
ORDER_FSM = StateMachine(
    transitions={"pending": ["paid"], "paid": ["shipped"], "shipped": []},
    error_code=Code.ORDER_INVALID_TRANSITION,
)

The business module must first add ORDER_INVALID_TRANSITION to the project-defined range in app/core/code.py; see Response codes.

Transition ​

python
from app.utils import Success, emit, get_current_user_id, radar_log

async def activate_product(product_id: int):
    product = await product_controller.get(id=product_id)
    await PRODUCT_FSM.transition(
        obj=product,
        to_state="active",
        state_field="status",
        actor_id=get_current_user_id(),
        log_fn=radar_log,
    )
    await emit("product.status_changed", product_id=product_id, to_state="active")
    return Success(msg="state updated", data=await product.to_dict())

Inside transition:

  1. Read getattr(obj, state_field), including Enum.value
  2. Validate allowed(from_state, to_state) and raise TransitionError with the configured code on failure
  3. Call obj.update_from_dict(...) and obj.save(update_fields=...)
  4. Call log_fn("state changed", data={...})

An invalid transition fails before update or logging, so it does not mutate the object or emit a success audit record.

Full signatures ​

python
StateMachine(
    transitions: dict[str, list[str]],
    *,
    error_code: str = Code.STATE_TRANSITION_INVALID,
)

async def transition(
    self,
    obj: Any,
    to_state: str,
    state_field: str = "status",
    actor_id: int | None = None,
    log_fn: Callable[..., None] | None = None,
    extra_updates: dict[str, Any] | None = None,
) -> None

Use extra_updates to persist related fields with the state:

python
await PRODUCT_FSM.transition(
    obj=product,
    to_state="archived",
    actor_id=get_current_user_id(),
    log_fn=radar_log,
    extra_updates={"archived_at": datetime.now(tz=timezone.utc)},
)

Inspect allowed targets ​

python
PRODUCT_FSM.allowed("draft", "active")       # → True
PRODUCT_FSM.allowed("draft", "archived")     # → False
PRODUCT_FSM.allowed_targets("draft")           # → ["active"]

The frontend can use this information to show the next action, but the backend must still enforce the transition.

Error handling ​

TransitionError extends BizError, so the global handler converts it to Fail(code, msg). Business services normally let it propagate.

  • Without a module-specific code: Code.STATE_TRANSITION_INVALID (2407)
  • With error_code: the configured module-specific code

Permission relationship ​

The state machine validates transition legality; it does not authorize the actor. Authorize at the route layer:

python
@router.post(
    "/products/{product_id}/transition",
    dependencies=[require_buttons("B_INVENTORY_PRODUCT_TRANSITION")],
)
async def transition_product(product_id: SqidPath, body: ProductTransition):
    return await product_service.transition(product_id, body.to_state)

Tests ​

python
async def test_draft_to_active_ok():
    product = await Product.create(status="draft", ...)
    await PRODUCT_FSM.transition(obj=product, to_state="active")
    assert product.status == "active"


async def test_draft_to_archived_blocked():
    product = await Product.create(status="draft", ...)
    with pytest.raises(TransitionError) as exc_info:
        await PRODUCT_FSM.transition(obj=product, to_state="archived")
    assert exc_info.value.code == Code.STATE_TRANSITION_INVALID

See also ​

基于 MIT 协议发布