Skip to content

7. The states

A loan is out, then returned, or lost. That is a state machine, and you do not write it by hand.

Declaring the lifecycle

models/workflow.py:

"""``library_loan`` lifecycle: out, then returned or lost."""
from __future__ import annotations
from typing import TYPE_CHECKING
from kewos.views import register_status_colors
from kewos.workflow import StateMachine, has_state_machine, register_state_machine
if TYPE_CHECKING:
from kewos.orm.registry import Registry
_STATE_COLORS = {"out": "info", "returned": "success", "lost": "destructive"}
def _build() -> StateMachine:
machine = StateMachine(initial="out", states=("out", "returned", "lost"))
machine.add_transition(trigger="check_in", source="out", dest="returned")
machine.add_transition(trigger="declare_lost", source="out", dest="lost")
return machine
def register_loan_workflow(_registry: Registry) -> None:
"""Bind the loan state machine and its status colours (idempotent)."""
if not has_state_machine("library_loan"):
register_state_machine("library_loan", _build())
register_status_colors("library_loan", "state", _STATE_COLORS)

Then call it from models/__init__.py, after the models.

What it produces

The loan form: the state track, and the two transition buttons

Three things appeared, without a line of interface:

  • The state track, with the current step to the fore and the next ones waiting.
  • The transition buttons, “Check in” and “Declare lost”, which only appear when the transition is possible from the current state. From returned, there are none.
  • The semantic colours: an outstanding loan is neutral, a returned one green, a lost one red. They come from the theme’s tokens, never a hard-coded colour, and they show up in the list and the kanban too.

The mistake I made writing this

My first attempt showed workflow.check_in on the button: an unresolved translation key, served as-is to the user. I had written the key under action.check_in.

The right shape is workflow.<trigger>.

{
"workflow.check_in": "Check in",
"workflow.declare_lost": "Declare lost"
}

No error is raised in that case: a missing key is shown raw. That is exactly the kind of defect that passes a code review and shows up in a demo.

Guarding a transition

A transition can refuse to run.

machine.add_transition(
trigger="check_in",
source="out",
dest="returned",
guard=lambda instance: instance.taken_on is not None,
)

The guard returns false, the transition is refused, the interface shows the error. You cannot work around it by calling the API: the transition goes through the same path.

Asking for information at transition time

form= declares the fields the user must fill in before the transition goes.

machine.add_transition(
trigger="declare_lost",
source="out",
dest="lost",
form=("lost_note",),
)

The engine shows a dialog with those fields, and the effect can then rely on them. That is how you guarantee a loan declared lost always carries an explanation.

Acting on the way through

effect= receives the session and runs inside the transition’s transaction. Whatever is written there is rolled back with it if something fails later.

def _mark_returned(session, _classes, instance) -> None:
"""Stamp the return date, atomically with the transition."""
instance.returned_on = date.today()
machine.add_transition(
trigger="check_in", source="out", dest="returned", effect=_mark_returned
)

That is the place to trace, to emit an event, to update a counter. It is not the place to call an external service: that would hold the transaction open across a network call. Emit an event, and let a background job deal with it.

A final state

A model can declare that beyond a certain state, nothing changes any more:

registry.register_model(
"library_loan",
module="library",
fields=[...],
immutable_after_state=("returned", "lost"),
)

A correction is then a counter-write, not an edit. Same rule as an accounting entry, and for the same reason: the history has to stay defensible.

Next

Artificial intelligence.