Django integration
A real statechart on a Django model: StatechartField, transaction + row-lock sends, signals, an audit log in the same transaction, permission guards, admin buttons, management commands and a django-fsm migration.
Django is where most Python state machines live, and they are usually flat: one status column, a handful of @transition methods, no nesting, no timers. This extra puts a real statechart on a model, with hierarchy, parallel regions, after, invoke and actors. order.send("PAY") runs in transaction.atomic() with a row lock. The audit row commits in the same transaction as the state change. Permissions are guards in the chart, so the admin, DRF and Channels all ask the same question: “may this user do it?”. There is also a mechanical migration from django-fsm-2.
REST ([drf]) and WebSocket ([channels]) surfaces are on the DRF & Channels page.
Install
pip install "xstate-statemachine[django]"
INSTALLED_APPS = [..., "django.contrib.contenttypes", "xstate_statemachine.contrib.django"]
Requires Django >=4.2. Run python manage.py migrate to create the app’s tables (xsm_django_*: deadlines, audit log, outbox, idempotency, DjangoStore). Tested versions are in the compatibility table.
For a complete, runnable project – parallel legal/finance review, PermissionGuard with two roles, admin transition buttons with an audit inline, a DRF viewset with schema, a Channels status page, an after escalation through the deadlines table and a test suite – see the django_approvals example.
Quick start
import django
from django.conf import settings
settings.configure(
INSTALLED_APPS=[
"django.contrib.contenttypes",
"django.contrib.auth",
"xstate_statemachine.contrib.django",
],
DATABASES={
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
},
},
DEFAULT_AUTO_FIELD="django.db.models.BigAutoField",
)
django.setup()
from django.core.management import call_command
from django.db import connection, models
from xstate_statemachine import MachineLogic
from xstate_statemachine.contrib.django import (
StatechartField,
StatechartModelMixin,
)
def record(i, ctx, e, a):
ctx["paid"] = e.payload["amount"]
class Order(StatechartModelMixin, models.Model):
statechart_machine = {
"id": "order",
"initial": "cart",
"context": {
"paid": 0,
},
"states": {
"cart": {
"on": {
"CHECKOUT": "review",
},
},
"review": {
"type": "parallel",
"onDone": "approved",
"states": {
"legal": {
"initial": "pending",
"states": {
"pending": {
"on": {
"LEGAL_OK": "ok",
},
},
"ok": {
"type": "final",
},
},
},
"finance": {
"initial": "pending",
"states": {
"pending": {
"on": {
"FINANCE_OK": "ok",
},
},
"ok": {
"type": "final",
},
},
},
},
},
"approved": {
"after": {
"86400000": "expired",
},
"on": {
"PAY": {
"target": "paid",
"actions": "record",
},
},
},
"expired": {},
"paid": {
"type": "final",
},
},
}
statechart_logic = MachineLogic(
actions={
"record": record,
}
)
statechart = (
StatechartField()
) # + statechart_state / _state_ids / _version ...
class Meta:
app_label = "shop"
call_command("migrate", verbosity=0)
with connection.schema_editor() as editor: # in a project: makemigrations
editor.create_model(Order)
order = Order.objects.create() # initial snapshot built on insert
order.send("CHECKOUT") # atomic() + select_for_update()
assert Order.objects.in_state("order.review").get() == order
order.send("LEGAL_OK")
order.send("FINANCE_OK")
assert order.state == "order.approved" and order.can("PAY")
assert order.send("PAY", amount=42).changed
assert Order.objects.filter(statechart__state="order.paid").count() == 1
assert order.machine.context["paid"] == 42
assert [
h.event for h in order.history
] == [ # the audit trail, same transaction
"CHECKOUT",
"LEGAL_OK",
"done.state.order.review.legal", # a region finished (engine event)
"FINANCE_OK",
"done.state.order.review.finance",
"done.state.order.review", # ... and so did the parallel state
"PAY",
]
Reference
StatechartField
StatechartField(*, max_snapshot_bytes=1 MiB, state_max_length=512, denormalize=True)
A JSONField holding the snapshot dict. contribute_to_class adds four ordinary sibling fields, so makemigrations works normally (and a second run finds nothing):
| Column | Type | Meaning |
|---|---|---|
<name>_state |
CharField, indexed |
sorted, comma-joined active leaf ids (order.review.finance.pending,order.review.legal.pending) |
<name>_state_ids |
JSONField |
the leaf ids as a list |
<name>_version |
PositiveIntegerField |
bumped on every write; the optimistic fence |
<name>_machine_version |
CharField |
the chart’s version when last written |
Lookups: filter(statechart__state="order.paid") and statechart__state__in=[...] compile to the indexed column. The snapshot is size-checked on write and read (X0.4, SnapshotTooLargeError). The column layout matches the SQLAlchemy extra, so one schema serves both ORMs.
StatechartModelMixin
| Class attribute | Default | Meaning |
|---|---|---|
statechart_machine |
— | JSON path (relative to the model module or BASE_DIR), config dict, MachineNode, or a dotted callable "shop.charts:order" |
statechart_logic |
None |
MachineLogic, a dotted callable returning one, or a dotted module bound by name (LogicLoader) |
statechart_lock |
"pessimistic" |
default lock mode for send() |
statechart_audit |
None |
write TransitionLog rows (on when the app and contenttypes are installed) |
statechart_plugins |
() |
extra plugins per send: a list, or (row) -> list |
statechart_clock |
None |
Clock handed to the per-send SyncInterpreter (a SimulatedClock in tests makes after timers deterministic); None means the real clock |
statechart_migrator / statechart_on_version_mismatch |
None |
forwarded to from_snapshot (#263) |
statechart_initialize |
True |
build the initial snapshot on insert (entry actions run once, at creation) |
statechart_forget_log |
"redact" |
X0.5 choice for the audit trail on forget_statechart() |
Methods and properties:
send(event_type, *, lock=None, actor=None, reason=None, plugins=(), using=None, wait=True, payload=None, **kwargs) -> Receipt. Event data goes inpayload=(a mapping); web surfaces must pass client data that way and never as**body, so a client cannot setlock/using/plugins/actor. Python callers may still pass keyword arguments. Apply the event and write the row.lock="pessimistic"locks the row first (select_for_update(); on SQLite a no-opUPDATEtakes the write lock, because SQLite has no row locks)."optimistic"writesUPDATE ... WHERE <field>_version = expectedand raisesConflictErrorwhen it loses."none"means last writer wins.actortravels asactor_idin the payload and is visible toPermissionGuard.reasongoes to the audit row.- Lock errors and deleted rows. Lock errors are recognised by SQLSTATE (
55P03lock timeout,40P01deadlock,40001serialization failure) or, for drivers without one, an anchored phrase (database is locked,Lock wait timeout exceeded) – a table namedlocked_ordersis never a lock error. On PostgresLockTimeoutError.timeoutreports the session’slock_timeout(0 means wait forever: setSET lock_timeout = '2s'per connection for pessimistic sends from web code; a deadlock still aborts one side and is retryable). A row deleted whilesend()ran raisesModel.DoesNotExist, never aConflictErrorthatsend_with_retrywould retry.lock="none"is last-writer-wins for the snapshot, but<field>_versionstill only moves forward. QuerySet.update(statechart=...)/bulk_updatebypass the mixin: the denormalised columns are NOT recomputed. Runmanage.py xsm_refresh_columns app.Model(orrefresh_statechart_columnsin a data migration) afterwards.asend(...). For async views. The whole locked section runs in onesync_to_async(thread_sensitive=True)call. There is no native asyncselect_for_update, so never split the ORM calls across awaits.can(event, *, actor=None, **payload),available_events,available_events_for(actor). Guards run. Nothing is written.machine(an unstarted interpreter restored from the snapshot),state,state_ids,matches(state_id),history(TransitionLogrows byseq).forget_statechart(). X0.5: clears the snapshot and its deadline rows, and redacts (or deletes) the audit rows. Returns counts.save(). AnUPDATEnever rewrites the statechart columns unless you name them inupdate_fields. Onlysend()moves the state, so saving a stale instance for another field cannot roll the state back.Model.objects.in_state(*ids). Rows where any id is active, as a leaf or as an ancestor (in_state("order.review")matchesorder.review.legal.pending). It matches whole id segments with bound parameters (noLIKE, no SQL built from the id).
send_with_retry(row, event, *, retries=10, backoff=None, lock="optimistic", **kw) reloads the statechart columns and re-applies the event on ConflictError (the optimistic fence was lost) or LockTimeoutError (a pessimistic writer waited out the database’s busy timeout – the driver’s OperationalError is mapped, never leaked). When the retries run out, the last error is raised with attempts set. Under retry, actions may run once per attempt (X0.3).
Durable timers and xsm_deadlines
Every armed after timer is a row in xsm_django_statechartdeadline (source = the model’s table, key = pk). The row is written in the send’s transaction and cleared when the state is exited. python manage.py xsm_deadlines [app.Model ...] [--forever] [--interval 1] [--limit 1000] [--now EPOCH] runs DueTimerScanner over that index: one pass by default (cron), or a loop with --forever that finishes its current pass and exits 0 on SIGINT / SIGTERM. A matured timer fires in an interpreter restored from the row, and the result is written back under the <field>_version fence, so a request racing the scanner cannot lose an update. DjangoModelStore(Model) is that StateStore view if you drive the scanner yourself.
DjangoStore
DjangoStore(namespace="default", *, using="default", lock_ttl_s=60, codec=None, max_snapshot_bytes=1 MiB) is the A2 StateStore on the ORM. It passes the same contract suite as SQLiteStore. lock() is a lease row that is portable across databases. Calls inside a transaction.atomic() (or store.transaction()) join it. due_keys() feeds DueTimerScanner from the index.
The app’s own tables
Installing xstate_statemachine.contrib.django adds six models, all in xstate_statemachine.contrib.django.models (default table prefix xsm_django_). You never write their rows by hand, but you will meet them in migrations, the admin and your database:
| Model | Written by | What a row is |
|---|---|---|
StatechartSnapshot |
DjangoStore |
One persisted instance: source (the owning table / namespace), key, the snapshot blob, its optimistic version, the chart’s machine_version label and updated_at |
StatechartDeadline |
DjangoStore |
One durable after timer of a key (state_id, entry_seq, due_at_wall, delay_ms, event_type); the index DueTimerScanner / xsm_deadlines read |
StatechartLock |
DjangoStore.lock() |
A pessimistic-lock lease (owner, expires_at); reclaimed once it passes, so a crashed holder never wedges a key |
TransitionLog |
the mixin’s audit step | One processed event, written in the same transaction as the transition (see Audit) |
OutboxMessage |
DjangoOutboxStore |
A transactional-outbox envelope committed with the transition that produced it; the relay marks it sent |
IdempotencyRecord |
DjangoInbox |
One (scope, key) of the idempotency inbox with the stored receipt, so a replayed Idempotency-Key is answered without touching the machine |
Migrations
The app ships its migrations (migrate xsm_django zero reverses them). Your own model’s sibling columns are ordinary fields in your migrations; a field named workflow gets workflow_state, workflow_state_ids, … and the index on workflow_state.
Renaming a StatechartField: makemigrations asks once per column (“Was doc.workflow renamed to doc.flow?”, then workflow_state → flow_state, …). Answer yes to all five. Answering no to a sibling drops that column and adds an empty one, which loses the denormalised state until you refresh it (below).
refresh_statechart_columns(Model, "statechart", *, using="default", batch=1000, migrate=None, dry_run=False) recomputes the sibling columns after a chart change, a bulk import or a raw SQL edit. It reads rows in primary-key batches (bounded memory), skips rows that are already right, and returns how many it changed. With migrate=, it rewrites each snapshot first (on a deep copy) and bumps its version. In a migration:
operations = [refresh_statechart_columns_op("shop", "Order", migrate=rename_state)]
From the shell: python manage.py xsm_refresh_columns shop.Order [--batch 1000] [--dry-run].
Signals
from xstate_statemachine.contrib.django.signals import pre_transition, post_transition, statechart_error (all three are also re-exported from xstate_statemachine.contrib.django). Added in 0.11.0.
| Signal | Arguments | Notes |
|---|---|---|
pre_transition |
instance, event, from_states, actor, using |
Raise TransitionVetoed(reason) to answer Receipt(denied=True). Nothing changes and no audit row is written. Any other exception propagates and rolls back the send. |
post_transition |
instance, event, receipt, from_states, to_states, actions, actor, using |
Fires once per send, inside the transaction, after the row and its audit rows are written. A receiver that raises rolls back the state change and its audit and outbox rows. |
statechart_error |
instance, kind, error |
kind is "action", "service" or "chain_budget" |
post_transition.connect(fn, on_commit=True) defers fn to transaction.on_commit(using=...). on_commit receivers follow Django’s weak= rule (a bound method of a temporary object stops firing once the object is collected; pass weak=False to pin it) and every disconnect() spelling – the function, a bound method, sender=, dispatch_uid= – removes them. Because it never runs for a rolled-back send, it is the only safe place for external side effects (email, HTTP, a broker). An on_commit receiver that raises does so after the commit. The state change is kept, and the error reaches whatever runs the commit (Django’s own on_commit semantics). post_transition.disconnect(fn) removes it however it was connected. Before the 0.11.0 fix, disconnect(fn) after an on_commit=True connect silently did nothing.
Audit
TransitionLog stores content_type/object_id, seq, event, a redacted payload, from_states, to_states, actions, disposition, an actor FK, reason, correlation_id, machine_version and created. DjangoAuditPlugin buffers records while the machine runs. The mixin writes them after the fenced UPDATE and before post_transition, so a failed insert fails the send instead of being swallowed by the engine’s plugin containment. seq has no gaps within a row. statechart_audit = False turns auditing off for a model.
disposition |
Meaning |
|---|---|
transition |
the state changed |
denied |
a guard refused (e.g. a PermissionGuard) |
unhandled |
no transition for the event in this state |
error |
an action or service raised (also under actionErrorPolicy: "continue") |
duplicate |
an idempotency key that was already processed |
deferred |
the event was deferred |
A pre_transition veto is not recorded, because it happens before the machine runs. If you need it on the record, write it from the receiver.
Erasure (X0.5). instance.forget_statechart() erases the snapshot and the deadlines and redacts the audit rows: payload, actor, reason and correlation_id are blanked. seq, event, disposition and the states stay, so the chain still has no gaps. forget_log(instance, using=..., mode="delete") (in contrib.django.audit) removes the rows instead.
In the admin the rows are append-only. TransitionLogAdmin is registered automatically (XSM_ADMIN_TRANSITIONLOG = False opts out). It lists the rows with filters for disposition, event, model and actor, and shows the first 120 characters of the payload. Nobody can add, change or delete a row there, superusers included. Erasure goes through forget_statechart, never the delete button.
Engine-generated rows (done.state.*, after.*) carry actor=None; a send against a finished machine is recorded as unhandled with its actor, reason and the InterpreterStoppedError – the attempt is on the record. Redaction matches keys by substring (auth also redacts meta.auth). forget_statechart() touches the snapshot, deadlines and audit rows only: outbox envelopes are already-integrated messages and inbox entries are scoped and expire by TTL – call DjangoInbox().forget(scope) for full erasure.
Permissions
PermissionGuard(*perms, object_level=True, fallback_global=True)checksuser.has_perm(perm, obj=row)against the saved row being sent to, then the global permission (unlessfallback_global=False). Object-level backends (django-guardian, rules) work unchanged. With no actor the guard isFalse.RoleGuard(*groups, allow_superuser=True)means membership of any of the named groups. A group that does not exist is simplyFalse.AnyOf(...)/AllOf(...)nest to any depth.has_event_permission(user, instance, event, *, require_enabled=True)isTruewhencan(event)passes as user and some candidate transition’s permission guards pass. Underorguards or alternative transitions, either role is enough, the same as forsend(). Anonymous, inactive andNoneusers getFalse. An auth backend that raises gives a loggedFalse, never a 500. The admin, DRF and Channels all call this function.permitted_events(user, instance)lists the declared events it allows, across every region of a parallel chart.has_permresults are cached on the user object, so the only extra queries are theRoleGuardgroup lookups.StatechartPermissionis a framework-neutral policy base to subclass.
has_event_permission(..., require_enabled=False) |
has_event_permission(...) |
Means | HTTP |
|---|---|---|---|
False |
False |
forbidden: the user has no role for it | 403 |
True |
False |
allowed, but not possible right now (a business guard, or the wrong state) | 409 |
True |
True |
go | 200 |
Outbox and idempotency
DjangoOutboxStore() implements the EDA OutboxStore (#293). Wire it with statechart_plugins = lambda row: [OutboxPlugin(DjangoOutboxStore(using=row._state.db or "default"))] – the store must live on the row’s database alias (a mismatch is refused, it could not join the send’s transaction). The mixin writes its rows after the fenced UPDATE, outside the plugin containment (like persisted()). A failing outbox INSERT makes send() raise and rolls the transition back, so an approval can never commit without its integration event. A rolled-back transition takes its outbox row with it.
OutboxRelay(store, broker).relay_once_sync() publishes pending rows and marks them sent only after publish() returns. Delivery is at-least-once: a crash between the two re-sends the same envelope id, so consumers must dedup on it. DjangoInbox() is the idempotency InboxStore and is wired the same way. Its claim and mark join the send’s transaction, and a replayed key gives a duplicate receipt and audit row.
The whole section, run end to end:
import django
from django.conf import settings
settings.configure(
INSTALLED_APPS=[
"django.contrib.contenttypes",
"django.contrib.auth",
"xstate_statemachine.contrib.django",
],
DATABASES={
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
},
},
DEFAULT_AUTO_FIELD="django.db.models.BigAutoField",
)
django.setup()
from django.contrib.auth.models import Group, User
from django.core.management import call_command
from django.db import connection, models, transaction
from xstate_statemachine import MachineLogic
from xstate_statemachine.contrib.django import (
DjangoOutboxStore,
PermissionGuard,
RoleGuard,
StatechartField,
StatechartModelMixin,
TransitionVetoed,
has_event_permission,
permitted_events,
post_transition,
pre_transition,
)
from xstate_statemachine.eda import OutboxPlugin, OutboxRelay
class Invoice(StatechartModelMixin, models.Model):
statechart_machine = {
"id": "invoice",
"initial": "draft",
"context": {
"lines": 0,
},
"states": {
"draft": {
"on": {
"ADD_LINE": {
"actions": "addLine",
},
"APPROVE": {
"target": "approved", # "approver" OR "cfo"
"guard": {
"type": "and",
"children": [
{
"type": "or",
"children": ["canApprove", "isCfo"],
},
"hasLines",
],
},
},
},
},
"approved": {
"tags": ["publish"],
"type": "final",
},
},
}
statechart_logic = MachineLogic(
actions={
"addLine": lambda i, ctx, e, a: ctx.update(lines=ctx["lines"] + 1),
},
guards={
"canApprove": PermissionGuard("auth.change_user"), # any real perm
"isCfo": RoleGuard("cfo"),
"hasLines": lambda ctx, e: ctx["lines"] > 0,
},
)
statechart_plugins = staticmethod(
lambda row: [OutboxPlugin(DjangoOutboxStore())]
)
statechart = StatechartField()
class Meta:
app_label = "billing"
call_command("migrate", verbosity=0)
with connection.schema_editor() as editor:
editor.create_model(Invoice)
cfo = User.objects.create_user("cfo")
cfo.groups.add(Group.objects.create(name="cfo"))
intern = User.objects.create_user("intern")
inv = Invoice.objects.create()
# 🔐 "forbidden" vs "not possible right now"
assert has_event_permission(
cfo, inv, "APPROVE", require_enabled=False
) # may try
assert not has_event_permission(cfo, inv, "APPROVE") # no lines yet
assert not has_event_permission(intern, inv, "APPROVE", require_enabled=False)
assert inv.send("APPROVE", actor=intern).denied
# 📣 a pre_transition veto: denied, nothing written
def frozen(sender, **kw):
if kw["event"].type == "APPROVE":
raise TransitionVetoed("period closed")
pre_transition.connect(frozen)
inv.send("ADD_LINE", actor=cfo)
assert permitted_events(cfo, inv) == ["ADD_LINE", "APPROVE"]
assert inv.send("APPROVE", actor=cfo).denied and inv.state == "invoice.draft"
pre_transition.disconnect(frozen)
# 📣 on_commit receivers run after COMMIT only (and disconnect(fn) works)
sent = []
def notify(sender, instance, to_states, **kw):
sent.append(to_states)
post_transition.connect(notify, on_commit=True)
with transaction.atomic():
assert inv.send("APPROVE", actor=cfo, reason="ok").changed
assert sent == [] # not yet committed
post_transition.disconnect(notify)
assert sent == [("invoice.approved",)]
# 🧾 the audit trail: every attempt, in order, with the actor
assert [(h.event, h.disposition, h.actor_id) for h in inv.history] == [
("APPROVE", "denied", intern.pk), # a guard refusal: recorded
("ADD_LINE", "transition", cfo.pk), # (the veto wrote nothing)
("APPROVE", "transition", cfo.pk),
]
# 📤 the outbox row committed WITH the approval; the relay publishes it
published = []
class Broker:
def publish(self, topic, envelope):
published.append(envelope.id)
assert OutboxRelay(DjangoOutboxStore(), Broker()).relay_once_sync() == 1
assert OutboxRelay(DjangoOutboxStore(), Broker()).relay_once_sync() == 0
assert len(published) == 1
# 🧹 forget: redact (default) keeps the gapless chain
inv.forget_statechart()
assert [h.seq for h in inv.history] == [1, 2, 3]
assert all(h.actor_id is None and h.payload == {} for h in inv.history)
Admin
@admin.register(Order)
class OrderAdmin(StatechartAdminMixin, admin.ModelAdmin):
xsm_confirm_events = ("CANCEL",)
The change form shows one button per event this user may send now, computed with has_event_permission. Each button is its own CSRF-protected POST form, rendered after the admin’s form. Buttons need no JavaScript. Events tagged meta.confirm (or listed in xsm_confirm_events) go through a confirmation page with a reason field. The reason and actor=request.user land in the audit row. The page also gets a TransitionLogInline (read-only, newest first, the latest max_rows = 50; visible to anyone who may view the row), a state badge with a link to a Mermaid diagram view (active state highlighted, needs view permission), a StateListFilter, and one bulk action per event that reports n changed / m denied. Labels come from meta.title, then description, then the event id. UI strings use gettext_lazy.
A change form looks like this, rendered as text. The history inline sits inside the admin’s form, and the buttons come after it, because forms cannot nest:
Change order [History]
Title: [ Order 17 ]
State: Legal OK, Finance pending (diagram)
── Transition history ──
Seq Event Disposition From states To states Actor
2 LEGAL_OK transition review.legal.pending review.legal.ok alice
1 CHECKOUT transition cart review.legal.pending … alice
[ Save ] [ Save and continue editing ]
── Statechart: order.review.finance.pending ──
[ Finance OK ] [ Reset… ]
Knobs on StatechartAdminMixin: xsm_confirm_events (a tuple of event ids that always get the confirmation page), xsm_bulk_actions = False (no per-event changelist actions), and xsm_history_inline = False (no TransitionLogInline). Set TransitionLogInline.max_rows = None on a subclass to show the whole history. The diagram view is admin/<app>/<model>/<pk>/statechart/ (URL name admin:<app>_<model>_xsm_diagram) and needs the model’s view permission. The confirm and POST endpoint is <pk>/xsm-transition/ (admin:<app>_<model>_xsm_transition). TransitionLogAdmin (the read-only audit changelist) is registered automatically. Set XSM_ADMIN_TRANSITIONLOG = False in settings to register your own.
Permissions and limits. The diagram view, the confirmation page and the transition endpoint need the model’s view permission. A user without it gets 403 for every pk, existing or not, so pks cannot be probed. Only a user who may view gets 404 for a missing pk. Sending still needs change permission plus has_event_permission on the POST. Bulk actions are offered only to users with change permission. They stream the selection (.iterator(chunk_size=500)) and report n changed / m denied. If any row hit LockTimeoutError or ConflictError, the message is error level and ends / n failed (retry them): those rows were not refused, they lost a race. The reason is cut to 2000 characters. The diagram highlights only state ids the chart knows, and only ids that are safe in Mermaid, so an edited state column cannot inject Mermaid directives. The diagram page loads no remote JavaScript by default: the xsm_mermaid_script block is empty. Put your Mermaid loader there, and add its origin to your CSP.
Templates (override in your project): admin/xsm/change_form.html (blocks xsm_badge, xsm_transitions), admin/xsm/confirm_transition.html (block xsm_confirm_form), admin/xsm/diagram.html (block xsm_mermaid_script loads Mermaid if you want it rendered; add its origin to your CSP).
Management commands
| Command | Same output as |
|---|---|
xsm_inspect app.Model [pk] [--json] [--no-events] [--plain] [--database ALIAS] |
xsm inspect <chart.json> (plus the row’s state with pk) |
xsm_diagram app.Model [-f mermaid\|plantuml\|ascii] [-o out] [--plain] |
xsm diagram |
xsm_docs app.Model [-o dir] [--plain] |
xsm docs |
xsm_simulate app.Model [-e A,B,+500] [--clock C] [--guards-false G] [--json] [--plain] |
xsm simulate (stub logic, no database) |
xsm_deadlines [app.Model ...] [--forever] [--interval S] [--limit N] [--now EPOCH] [--database ALIAS] |
the durable-timer scanner |
xsm_refresh_columns app.Model [--batch N] [--dry-run] [--database ALIAS] |
refresh_statechart_columns |
xsm_snapshots app.Model [--stale] [--json] [--limit N] [--database ALIAS] |
rows and their machine_version |
xsm_migrate_fsm app.Model [--field F] [--statechart-field F] [--dry-run] [--write-chart P] [--map OLD=NEW] [--batch N] [--machine-id ID] [--database ALIAS] |
see below |
The output of the first four matches xsm byte for byte, and the tests pin that. --no-color is Django’s own flag and is honoured; NO_COLOR and TERM=dumb are honoured as by xsm. xsm_simulate without -e runs no events and never waits for input. An unknown label, a model with two StatechartFields and no statechart_field_name, a missing or malformed pk, an unknown --database alias, an unwritable -o, or an unmigrated database is a CommandError (exit status 1, carrying the CLI’s own message – exit status 1: cannot write ...: FileNotFoundError: ...), not a traceback. A bad flag or -f value is exit status 2 (argparse). Charts given as a dict or a callable are written to a temporary JSON file first.
Coming from django-fsm-2
| django-fsm-2 | here |
|---|---|
state = FSMField(default="new") |
statechart = StatechartField() + statechart_machine = "machines/order.json" |
@transition(field=state, source="new", target="paid") |
"new": {"on": {"PAY": "paid"}} in the chart |
conditions=[is_paid] |
a named guard: "guard": "isPaid" |
permission="shop.pay" |
PermissionGuard("shop.pay") (object-level too) |
has_transition_perm(order.pay, user) |
has_event_permission(user, order, "PAY") |
can_proceed(order.pay) |
order.can("PAY") |
get_available_user_state_transitions(user) |
permitted_events(user, order) |
order.pay(); order.save() |
order.send("PAY", actor=user) (one transaction) |
ConcurrentTransitionMixin |
lock="optimistic" + send_with_retry (or the default row lock) |
pre_transition / post_transition |
the same names, inside the transaction; on_commit=True for side effects |
django-fsm-log (StateLog, @fsm_log_by, @fsm_log_description) |
TransitionLog, written in the same transaction; send(actor=user, reason=...); refusals are recorded too |
FSMAdminMixin admin buttons (fsm_admin is deprecated) |
StatechartAdminMixin |
python manage.py xsm_migrate_fsm shop.Order --field state --dry-run extracts the chart from your @transition decorators. The same command without --dry-run fills the snapshots, batched and resumable, skips and reports per value any column value the chart does not know (--map OLD=NEW folds renamed values in), and FSMDualWriteMixin keeps both columns in sync for one release, in both directions: send() writes the old column, and an old @transition + save() re-adopts the snapshot. --dry-run prints only the chart JSON on stdout; the recipe goes to stderr. xsm gt --from-django-fsm app.Model does step 1 without manage.py. The four steps are on vs django-fsm. from_state_ids(machine, ids, context) in xstate_statemachine.persistence is the generic “adopt an existing record” primitive underneath, and it works for plain dicts and SQLAlchemy too.
Guarantees
What this does: a model
send()is atomic. The snapshot, sibling columns, deadline rows, audit rows, outbox rows and idempotency mark commit together or not at all. By default concurrent sends on one row serialise (16 threads × 100 sends = exactly 1600, pinned on SQLite, and on Postgres withDATABASE_URL). The optimistic mode never loses an update: it raisesConflictError. Apre_transitionveto changes nothing. Apost_transitionfailure rolls back. A failing audit or outbox write fails the send, so an approval never commits without its audit row or its integration event. The audit admin is append-only. Snapshots are size-capped on write and read (X0.4).forget_statechart()erases the snapshot and its deadlines and redacts the audit trail (X0.5).What this does not do: it does not make actions exactly-once. Under
send_with_retry, an action may run once per attempt (X0.3), so put external effects in services, the outbox, orpost_transition(on_commit=True). The outbox relay is at-least-once, not exactly-once: consumers dedup on the envelope id. It does not arm in-process timers:afterfires whenxsm_deadlines(or the nextsend()) runs. It does not provide a native async row lock (useasend). It does not authenticate snapshots edited directly in the database.See the programme-wide Guarantees and Security pages (#303).
Threat model
Who can call this: any code holding a model instance can
send()events. Guards, not the ORM, decide what a user may do, so put aPermissionGuard/RoleGuardon every event that needs one and passactor=request.user. In the admin, anyone with the model’s change permission sees the buttons thathas_event_permissionallows. Each press is re-checked on POST; a POST from a user without the change permission is a 403.What it exposes: the admin transition view is a POST form with Django’s CSRF token (the admin amendment to X0.7). A GET renders the confirmation form and changes nothing. A POST without the token is 403 (
CsrfViewMiddlewarepluscsrf_protecton the view). The diagram view needs the view permission. The audit log stores the actor, the reason and a redacted payload (password,token,secret… masked). Management commands run with the caller’s shell rights.You must configure: a permission guard (or your own guard) on every event a user must not send, and
actor=on every user-drivensend(). KeepCsrfViewMiddlewareenabled. Runxsm_deadlinesif charts useafter. For external side effects, usepost_transition.connect(..., on_commit=True)or the outbox.
Compatibility
| Django | Python | Tested in CI |
|---|---|---|
| 4.2 (oldest, Python 3.9) – latest | 3.9 and 3.13 cells | ✅ |
See the generated compatibility table. SQLite runs everywhere. The same suite runs on Postgres when DATABASE_URL=postgres://... is set.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
MissingExtraError: ... pip install "xstate-statemachine[django]" |
extra not installed | run the command |
no such table: xsm_django_... |
app not migrated | add "xstate_statemachine.contrib.django" to INSTALLED_APPS and migrate |
ConflictError from send(lock="optimistic") |
another writer won the version fence | use send_with_retry, or the default pessimistic lock |
LockTimeoutError from send() (SQLite database is locked, Postgres lock timeout, MySQL Lock wait timeout) |
a writer waited out the database’s busy/lock timeout | retryable – send_with_retry retries it; keep sends short; raise OPTIONS["timeout"]; use Postgres for real concurrency |
SynchronousOnlyOperation in an async view |
the ORM was called on the event loop | await order.asend(...) |
SnapshotDriftError / MachineVersionMismatchError after a chart change |
stored snapshots predate the chart | register a SnapshotMigrator step (statechart_migrator), then xsm_snapshots --stale and refresh_statechart_columns |
DoesNotExist from send() |
the row was deleted while the send ran | nothing to retry; the caller decides |
Columns out of step with the snapshot after QuerySet.update() / bulk_update() |
the ORM write bypassed the mixin | manage.py xsm_refresh_columns app.Model |
| Admin shows no buttons | the user lacks change permission, or every guard refuses | check permitted_events(user, obj) |
| Admin diagram / confirm page is 403, even for an existing pk | the user lacks the model’s view permission (missing and existing pks answer the same, by design) |
grant view_<model> |
| No “Send …” bulk actions in the changelist | the user lacks change permission, or xsm_bulk_actions = False |
grant change_<model> |
Bulk action message ends n failed (retry them) |
those rows hit LockTimeoutError / ConflictError / StoreUnavailableError (a concurrent writer or an outage), not a denial; any other library error – a missing implementation, an unknown event – is counted as denied and logged at WARNING, and a row deleted mid-action is denied too |
run the action again on the same selection |
| Admin diagram shows text, not a picture | no Mermaid script loads by default (CSP-safe) | override block xsm_mermaid_script with your Mermaid loader and allow its origin in your CSP |
xsm_inspect app.Model <pk> says bad pk / not found |
a malformed or missing pk, or the row is on another alias | pass --database ALIAS |
CommandError: unknown --database |
the alias is not in DATABASES |
use a configured alias |
CommandError: ... needs exactly one StatechartField |
the model has several fields | set statechart_field_name on the model |
An on_commit=True receiver never runs in a test |
a TestCase never commits |
use django_capture_on_commit_callbacks(execute=True) / captureOnCommitCallbacks |
send() raises IntegrityError / DatabaseError from the outbox or audit table |
the marker write failed, and by design the transition rolled back with it | migrate xsm_django, fix the table, retry the send |
| The same integration event arrives twice | the relay crashed after publish() but before mark_sent (at-least-once) |
dedup on the envelope id (DjangoInbox on the consumer) |
A permission check is always False, with a logged traceback |
the auth backend raised (for example, LDAP is down) | fix the backend; the denial is deliberate |
XStateMachineError: ... store ... on another database from send() |
an outbox / inbox store in statechart_plugins built for a different alias than the row |
build it with using=row._state.db |
| A vetoed attempt is missing from the history | a pre_transition veto runs before the machine and writes nothing |
log it from the receiver |