Skip to main content
Yanked

This release has been yanked by its maintainers, and will be ignored by installers, except when explicitly specified.
Consider using release 0.6.0 instead.
Reason given by maintainers: To avoid additional migrations in v0.2.0 we've squashed the migrations there making it hard to upgrade from v0.1.0 to v0.2.0

django-state-machines

Status is not an enum scattered across your tables. It is data: a shared vocabulary, a versioned graph of allowed moves, and an append-only history of everything that happened — with the version pinned on each record, so publishing a new graph never migrates or invalidates a single existing row.

risk = Risk.objects.create(title="Data retention")  # -> status_key "draft", pinned to v1
risk.transition("risk.assess", user=request.user)  # -> "assessed", history row written
risk.available_actions(user=request.user)  # -> ["risk.mitigate", "risk.reject"]

Why

Most Django state-machine libraries put the graph in Python: transitions are decorated methods, and changing the rules means a deploy. That works right up to the point where the rules belong to the business rather than to the code — where compliance wants to add an approval step, where two tenants need different flows, and where an auditor asks which rules were in force when a record moved eighteen months ago.

This app answers those by making the graph a row rather than a decorator:

  • A vocabulary you can reference from anywhere. StatusDefinition defines the valid status values for an (entity_type, status_field) pair. Records store a status_key soft reference, so a status travels across databases and services without a join.
  • Versions that are immutable once published. A StateMachineVersion declares which statuses are states and which transitions are allowed. Every status-bearing row stores the version it was created under. Publishing v2 leaves every v1 record validating against v1, until you explicitly move it.
  • Guarded transitions. Each edge carries the action_key that triggers it, an optional guard, a required_permission, and a requires_approval flag.
  • One action catalog. The verb that drives a transition and the action you record in your audit log come from the same ActionType table, defined once rather than twice.
  • Side effects wired as data. Any app registers functions under a unique key; hook rows in the version say when they run — before or after a specific transition, any transition, entering a state, or leaving a state.

StateMachineVersion.lifecycle (draft | published | archived) is the one deliberate enum left in the design: a state machine cannot govern its own publication without infinite recursion.

Install

uv add vinta-django-state-machines
INSTALLED_APPS = [
    "django.contrib.contenttypes",
    "vinta_state_machines",
    ...,
]
python manage.py migrate

Declaring a status-bearing model

A governed status is two concrete fields: the soft reference to the catalog, and the pin to the version whose rules apply to this row.

from django.db import models
from vinta_state_machines.fields import (
    StateMachineMixin,
    StateMachineVersionField,
    StatusKeyField,
)


class Risk(StateMachineMixin, models.Model):
    title = models.CharField(max_length=200)
    amount = models.IntegerField(default=0)

    status_key = StatusKeyField(machine="risk.status")
    status_machine_version = StateMachineVersionField()

Both are ordinary fields, so migrations, select_related and type checking behave exactly as they would on a hand-written pair. StateMachineMixin is optional sugar; every method it adds is also a plain function in vinta_state_machines.engine that takes the instance, which is what you want for models you do not own.

Two statuses on one model? Declare the pair twice. The companion field name is derived from the status field (status_keystatus_machine_version), or named explicitly:

class Roadmap(StateMachineMixin, models.Model):
    status_key = StatusKeyField(machine="roadmap.status")
    status_machine_version = StateMachineVersionField()

    engagement_status_key = StatusKeyField(
        machine="roadmap.engagement_status",
        version_field="engagement_machine_version",
    )
    engagement_machine_version = StateMachineVersionField()

A system check flags a status field whose companion is missing, is not a foreign key, or points at the wrong model — before it can fail at runtime.

Defining a machine

The catalog is ordinary data, so you can build it in the admin, in a data migration, or from a JSON file. define_machine is the shortest path:

from vinta_state_machines.services import define_machine, publish_version

version = define_machine(
    {
        "key": "risk.status",
        "entity_type": "risk",
        "status_field": "status",
        "name": "Risk status",
        "version": "1",
        "states": [
            {"key": "draft", "name": "Draft", "is_initial": True, "x": 0, "y": 0},
            {"key": "assessed", "name": "Assessed", "x": 200, "y": 0},
            {"key": "mitigated", "name": "Mitigated", "is_terminal": True, "x": 400, "y": -80},
            {"key": "rejected", "name": "Rejected", "is_terminal": True, "x": 400, "y": 80},
        ],
        "transitions": [
            {"name": "create", "from": None, "to": "draft", "action": "risk.create"},
            {"name": "assess", "from": "draft", "to": "assessed", "action": "risk.assess"},
            {"name": "comment", "from": "assessed", "to": "assessed", "action": "risk.comment"},
            {
                "name": "mitigate",
                "from": "assessed",
                "to": "mitigated",
                "action": "risk.mitigate",
                "guard": "obj.amount <= 1000",
            },
            {
                "name": "reject",
                "from": "assessed",
                "to": "rejected",
                "action": "risk.reject",
                "required_permission": "risks.reject_risk",
                "requires_approval": True,
            },
        ],
    }
)

publish_version(version)  # validates the graph, freezes it, makes it the default

A from of None is the creation edge: the transition that takes a brand new record into an initial state. comment is a self transition — a legal move that leaves a state and arrives back at it, appending history without changing the status.

Each state carries x and y integers, so a version doubles as the layout of its own diagram. The canvas positions live with the graph and travel through clone, export and import along with everything else, which is what lets a visual editor round-trip a version without a second store to keep in sync.

The same JSON works from the command line:

python manage.py import_state_machine risk.json --publish
python manage.py export_state_machine risk.status --label 1 --output risk.json

Moving records

from vinta_state_machines.engine import available_transitions, can_transition, transition

A new record pins the machine's default_version and starts on its initial state, so Risk.objects.create(title=...) already lands somewhere valid.

transition(risk, "risk.assess", user=request.user, comment="Reviewed by security")

That single call, inside one transaction:

  1. resolves the pinned version and refuses to run under a draft or archived one,
  2. collects the edges that action could mean from the current status,
  3. takes the first whose required_permission and guard both hold, then checks requires_approval on it,
  4. runs the before side effects,
  5. writes the new status and pins the version if it was not pinned,
  6. appends a StatusTransition row,
  7. runs the after side effects.

Anything that fails rolls the whole thing back. Failures raise a subclass of StateMachineError — which is itself a ValidationError, so transition problems surface naturally through forms and serializers:

Exception Raised when
TransitionNotAllowed the version declares no such edge, or the state is terminal
GuardFailed the edge exists but its guard did not hold
PermissionDenied the actor lacks required_permission
ApprovalRequired the edge is flagged requires_approval and none was passed
NoStateMachineVersion nothing to resolve: no pin and no default version
InvalidVersionState the pinned version is not published
UnknownStatus the record's current status is not a state of its version

For rendering a UI, ask for the blocked edges too and show why each one is unavailable:

for option in available_transitions(risk, user=request.user, include_blocked=True):
    render(option.name, enabled=option.allowed, tooltip=option.reason)

Useful keyword arguments to transition():

Argument Effect
approval=... satisfies requires_approval; recorded in the history row's metadata
transition_name=... take exactly this edge instead of resolving by order
metadata={...} free-form data stored on the history row and passed to side effects
enforce_permissions=False for system-driven moves with no acting user
allow_unpublished=True exercise a draft while authoring it
save=False apply the change in memory and let the caller save
record_history=False skip the StatusTransition row

Named edges, self transitions and parallel edges

Every transition carries a name, unique among the edges leaving the same state. The name is what identifies an edge, because (from, to, action) no longer can:

  • a state may transition to itself, and
  • one pair of states may be joined by as many edges as the flow needs, including several that share an action.

When an action maps to several edges, the engine walks them in order and takes the first whose permission and guard both hold. That is how you say "approve, but which path depends on the amount" without inventing two verbs for one business action:

(
    {
        "name": "approve_large",
        "from": "review",
        "to": "approved",
        "action": "invoice.approve",
        "guard": "obj.total > 1000",
        "required_permission": "invoices.approve_large",
        "requires_approval": True,
        "order": 0,
    },
)
({"name": "approve", "from": "review", "to": "approved", "action": "invoice.approve", "order": 1},)

If no candidate is viable, the error comes from the first one, so the message still points at a specific edge rather than shrugging. To bypass resolution order and demand one exact edge:

transition(invoice, "invoice.approve", transition_name="approve_large", approval=sign_off)

available_transitions() lists every edge separately, each with its own .name, so a UI can render two buttons for the same action with different labels and tooltips. And because two history rows for one action would otherwise be indistinguishable, StatusTransition records the exact transition that ran, not just the action that named it.

A self transition is an ordinary move: guards, permissions, side effects and a history row all apply, the status simply ends up where it started. Terminal states remain terminal — looping back to yourself still counts as an outgoing edge, and validation says so.

Guards

A guard is either a safe expression or a registered function.

Expressions are evaluated against a restricted subset of Python — no imports, no private names, no method calls, no statements — with obj, user, action, from_status, to_status and metadata in scope:

obj.amount <= 1000 and obj.owner_id is not None

Guards never invoke arbitrary methods, because obj.delete reads exactly like an attribute. Opt a no-argument method in when you want it evaluated like a property:

from vinta_state_machines.guards import guard_callable


class Risk(models.Model):
    @guard_callable
    def is_large(self):
        return self.amount > 1000

For anything with real logic, register a named guard and reference it as "@key":

from vinta_state_machines.guards import register_guard


@register_guard("risk.within_budget")
def within_budget(obj, user, **context):
    return obj.amount <= obj.project.remaining_budget

Set STATE_MACHINES = {"ALLOW_GUARD_EXPRESSIONS": False} to require the named form everywhere. Either way, a broken guard is caught by validate_version at authoring time rather than the first time a record tries to move.

Side effects

Register a function under a unique key from any app — put them in side_effects.py and they are imported automatically:

# risks/side_effects.py
from vinta_state_machines.side_effects import AbortTransition, register_side_effect


@register_side_effect("risk.notify_owner")
def notify_owner(context):
    send_mail(
        to=context.instance.owner.email,
        subject=f"{context.instance} is now {context.to_status}",
        template=context.params["template"],
    )


@register_side_effect("risk.require_evidence")
def require_evidence(context):
    if not context.instance.evidence.exists():
        raise AbortTransition("Attach evidence before assessing this risk.")

Then wire them from the version, which is what makes them travel with the graph rather than with the code:

StateMachineHook.objects.create(
    state_machine_version=version,
    handler_key="risk.notify_owner",
    timing="after",  # before | after
    event="enter_state",  # transition | any_transition | enter_state | leave_state
    state=version.states.get(status__key="mitigated"),
    on_commit=True,  # wait for the surrounding transaction to commit
    params={"template": "risk_mitigated", "cc": ["compliance@example.com"]},
)

params is a JSON parameter stored on the relationship itself, so one registered function can be wired to several transitions and behave differently on each — and the parameters travel with the version through clone, publish, export and import. The handler reads it as context.params. In a definition dict it is just another key:

"hooks": [
    {
        "handler": "risk.notify_owner",
        "transition": "mitigate",
        "timing": "after",
        "on_commit": True,
        "params": {"template": "risk_mitigated"},
    },
]

Do not confuse it with metadata: params is authoring-time configuration attached to the binding, while metadata is per-move data supplied by whoever called transition(). A handler usually reads both.

event picks what the hook is bound to:

event Fires for Names
transition one specific edge transition, by name — plus from when the same name leaves more than one state
any_transition every edge of the version
enter_state every edge arriving at a state state
leave_state every edge departing a state state

Within one timing, hooks run leave → transition → enter, and order breaks ties on the same binding. The two timings mirror each other, so a before/after pair on the same binding always brackets the change:

before-leave → before-transition → before-enter
                                       ↓
                           status written, history appended
                                       ↓
 after-leave → after-transition → after-enter

Every handler takes one SideEffectContext:

Attribute
instance, field_name, status_field what is changing
from_status, to_status, action the move; from_status is None on creation
version, graph, transition the authorizing version and the edge
timing, event, hook which binding fired
actor the acting principal, or None for the system
params the JSON parameter stored on this binding, verbatim
metadata whatever the caller passed to transition(), for this move
record the history row — set for after handlers only

The engine writes only the status columns. A handler that also changes the record says so:

@register_side_effect("risk.stamp_closed_at")
def stamp_closed_at(context):
    context.instance.closed_at = timezone.now()
    context.touch("closed_at")

Everything runs inside the transition's transaction. A before handler raising AbortTransition vetoes the move cleanly; any other exception rolls it back. Use on_commit=True for after handlers that reach outside the database — emails, webhooks, queued jobs — so they never fire for a transaction that ends up rolling back.

validate_version refuses to publish a version whose hooks name a handler no installed app registers, so a typo is caught at publish time rather than at 3am.

Versioning

This is the part that pays for itself. Records pin, so publishing is safe:

from vinta_state_machines.services import archive_version, clone_version, publish_version

v2 = clone_version(machine.default_version, "2")  # deep copy into a fresh draft
v2.transitions.create(...)  # edit the draft freely
publish_version(v2)  # new records pin v2

Existing records keep validating against v1. Nothing was migrated; nothing was invalidated. When you do want a record to move forward, that is an explicit, auditable act:

from vinta_state_machines.services import rebase_record

rebase_record(risk, v2, map_status={"draft": "new"})

archive_version retires a version without breaking the records that pinned it — a pinned version is protected by the database, so it always outlives its rows.

validate_version checks a graph as a whole and separates what blocks publication from what merely deserves a look:

  • Errors — no states, no initial state, a transition or hook pointing into another version, an outgoing edge from a terminal state, a creation edge into a non-initial state, an unusable guard, a hook naming an unregistered handler.
  • Warnings — an unreachable state, a dead end that is not marked terminal.
python manage.py validate_state_machines --fail-on-warning
python manage.py validate_state_machines --list-side-effects

Editing a graph on a canvas

The admin's change form for a StateMachineVersion embeds vinta-state-machine-editor, a web component that draws the version as a pan/zoom canvas: states as cards you can drag and colour, transitions as edges you draw between them, and the side effects around both as ordered lists you fill from a dialog.

Nothing is needed to switch it on. The component ships pre-bundled inside this package, so there is no npm install and no build step — only django.contrib.staticfiles and a STATIC_URL, which any Django project already has.

The canvas edits drafts. A published or archived version renders read only, exactly as its form fields do, because records pin it and its graph can no longer change.

What each side calls things:

On the canvas In the database
a state's id the StatusDefinition key it binds
▶ Initial / ◉ Final on a card is_initial / is_terminal
an edge from the start dot a transition with from_state=None
an edge's trigger its ActionType, from the shared vocabulary
the four lists on a card enter_state / leave_state hooks, before and after
the two lists on an edge transition hooks, before and after
where things sit x / y on a state, label_offset_* on an edge

Ordering is positional throughout: dragging a card above another renumbers order, both for the edges leaving one state and for the side effects in one list.

Two things stay off the canvas by design. Hooks bound to any_transition belong to the version rather than to any one card, so the editor never sees them and never disturbs them — edit those in the inline below. And a state drawn on the canvas arrives with a generated id; the server gives it a real vocabulary key, slugified from its name, and hands the saved document back so the canvas picks the key up.

Handlers can describe themselves for the side-effect picker:

@register_side_effect(
    "risk.notify_owner",
    name="Notify the owner",
    description="Emails whoever owns the risk.",
    default_params={"template": "risk/notify.txt"},
)
def notify_owner(context): ...

Guard expressions are checked by the server as they are typed, through the same validate_guard that blocks publication, so a broken guard is caught while it is being written rather than at publish time.

To put the canvas somewhere other than the admin, the translation is a pair of plain functions:

from vinta_state_machines.editor import apply_editor_machine, to_editor_machine

document = to_editor_machine(version)  # -> the JSON the component reads
apply_editor_machine(version, document)  # <- reconcile a posted one, in one transaction

apply_editor_machine matches rows by id and updates them in place, so primary keys — and the history pointing at them — survive an edit. It raises EditorPayloadError, which carries every problem it found rather than only the first, and rolls the whole document back if any of them fire.

Per-tenant machines

A machine may be scoped to a tenant, so one organization runs a stricter approval flow than another while both share the same status vocabulary and the same reporting.

Nothing in the engine is tenant-aware: records pin a StateMachineVersion, and a scoped machine simply hands out a different one. Tenancy lives entirely in resolution.

STATE_MACHINES = {"SCOPE_RESOLVER": "myproject.tenancy.scope_for"}
# myproject/tenancy.py
def scope_for(instance, config):
    """Which tenant governs this record. None means the global machine."""
    return getattr(instance, "organization", None)

Resolution is a two-step fallback — the record's own tenant first, the global machine second — so a tenant only needs rows for the flows it actually customises:

define_machine({"key": "risk.status", "scope": "org:acme", ...})  # acme's own rules
define_machine({"key": "risk.status", ...})                       # everyone else

A machine with no scope is global, which is why a single-tenant project never has to know any of this exists.

Pointing the scope at your own model

StateMachineScope is swappable. Point it at your own tenant table and both scope foreign keys become real foreign keys to it, with real cascade:

# settings.py — a top-level setting, because Meta.swappable resolves against one
STATE_MACHINES_SCOPE_MODEL = "organizations.Organization"
class Organization(models.Model):
    slug = models.SlugField(unique=True)

    @property
    def scope_key(self) -> str:
        return f"org:{self.slug}"

    @classmethod
    def from_scope_key(cls, key):
        return cls.objects.filter(slug=key.removeprefix("org:")).first()

Those two members are the contract, and manage.py check enforces them (state_machines.E005 / E006). They exist because primary keys do not travel between databases but keys do — they are what lets export_state_machine --scope write a machine in staging and import_state_machine read it back in production.

Like every swappable model this is an install-time, one-way decision: make it before you have data, the way you would AUTH_USER_MODEL.

What the scope buys on the history table

StatusTransition carries the scope too, denormalised from the machine that authorized the move and indexed with created_at, so a tenant's audit trail is one indexed filter away rather than a scan:

StatusTransition.objects.filter(scope=acme, created_at__gte=start)

The two foreign keys deliberately differ on on_delete: CASCADE on StateMachine, PROTECT on StatusTransition. Configuration is disposable; an audit trail should outlive the tenant it describes, so deleting an organization forces an explicit archival step rather than quietly destroying its history.

History

Every committed transition appends one StatusTransition row, recording what moved, from where to where, who did it, which edge ran, and — crucially — which version authorized it:

from vinta_state_machines.models import StatusTransition

StatusTransition.objects.for_object(risk).with_related()
StatusTransition.objects.for_model(Risk).entering("mitigated")

Rows are append-only: editing one raises. The target is a generic foreign key, so one table covers every status-bearing model in the project.

Settings

All optional, all under one key:

STATE_MACHINES = {
    "AUTOPIN_DEFAULT_VERSION": True,  # pin and fill the initial status on create
    "STRICT": False,  # raise instead of skipping autopin when the
    # catalog is not there yet
    "RECORD_HISTORY": True,
    "CACHE_GRAPHS": True,  # keep parsed graphs in memory
    "ALLOW_GUARD_EXPRESSIONS": True,
    "MAX_GUARD_EXPRESSION_LENGTH": 1000,
    "TRANSITIONABLE_LIFECYCLES": ("published",),
    "PERMISSION_CHECKER": None,  # dotted path to checker(user, perm, instance)
    "SCOPE_RESOLVER": None,  # dotted path to resolver(instance, config);
    # None disables tenancy entirely
}

One setting lives outside that dict, because Meta.swappable can only resolve against a top-level name:

STATE_MACHINES_SCOPE_MODEL = "organizations.Organization"  # defaults to the built-in

Graphs are cached per version and invalidated whenever any row of that version changes. Because primary keys are recycled between tests, set "CACHE_GRAPHS": False in your test settings, or call vinta_state_machines.graph.clear_graph_cache() in a fixture.

Development

uv sync --all-groups
uv run pytest
uv run mypy
uv run ruff check .
uv run pre-commit install --install-hooks --hook-type commit-msg

The full support matrix runs under tox:

uv run tox

Python 3.10–3.14, Django 5.2 and later. Django 6.0 requires Python 3.12+, which is why the matrix pairs factors explicitly rather than taking the full product.

static/vinta_state_machines/state-machine-editor.js is the vendored ./bundled build of vinta-state-machine-editor (MIT, licence kept beside it). It is checked in rather than fetched so that installing this package needs no Node toolchain. To move to a new release:

npm pack vinta-state-machine-editor@<version>
tar -xzf vinta-state-machine-editor-<version>.tgz
cp package/dist/bundled.js \
  vinta_state_machines/static/vinta_state_machines/state-machine-editor.js

License

MIT. See LICENSE.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

vinta_django_state_machines-0.1.0.tar.gz (243.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

vinta_django_state_machines-0.1.0-py3-none-any.whl (218.1 kB view details)

Uploaded Python 3

File details

Details for the file vinta_django_state_machines-0.1.0.tar.gz.

File metadata

File hashes

Hashes for vinta_django_state_machines-0.1.0.tar.gz
Algorithm Hash digest
SHA256 a835b81167373fef921713408c4bce6e6a6740e1749ff416c35cc6ed4a68aadf
MD5 56979a97034a9acaee15a0de3a86f204
BLAKE2b-256 cfccc9270a127379aaea03770b4e4119df930be09d8bb498b4386db8660849d9

See more details on using hashes here.

Provenance

The following attestation bundles were made for vinta_django_state_machines-0.1.0.tar.gz:

Publisher: publish.yml on vintasoftware/vinta-django-state-machines

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file vinta_django_state_machines-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for vinta_django_state_machines-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 8276a2f4cf1fadb116b034d3f4f029ee560f258784db61b9f5f57d997d5ba083
MD5 121b8f302b3e7de7049e690d73cd1bdf
BLAKE2b-256 c74eaf033b7a6839aed5320b0418d5ec069af46f7a33cfc6b981e81f69a13670

See more details on using hashes here.

Provenance

The following attestation bundles were made for vinta_django_state_machines-0.1.0-py3-none-any.whl:

Publisher: publish.yml on vintasoftware/vinta-django-state-machines

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.6.0

2 files

0.5.0

2 files

0.4.0

2 files

0.3.0

2 files

0.2.0

2 files

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page