Django friendly finite state machine support.
Project description
Django friendly finite state machine support
Django FSM-2 adds simple, declarative state management to Django models.
Introduction
FSM really helps to structure the code, and centralize the lifecycle of your Models.
Instead of adding a CharField field to a django model and manage its values by hand everywhere, FSMFields offer the ability to declare your transitions once with the decorator. These methods could contain side-effects, permissions, or logic to make the lifecycle management easier.
Nice introduction is available here: https://gist.github.com/Nagyman/9502133
[!IMPORTANT] Django FSM-2 is a maintained fork of Django FSM.
Big thanks to Mikhail Podgurskiy for starting this project and maintaining it for so many years.
Unfortunately, after 2 years without any releases, the project was brutally archived. Viewflow is presented as an alternative but the transition is not that easy.
If what you need is just a simple state machine, tailor-made for Django, Django FSM-2 is the successor of Django FSM, with dependencies updates, typing (planned)
Quick start
from django.db import models
from django_fsm import FSMField, FSMModelMixin, transition
class BlogPost(FSMModelMixin, models.Model):
state = FSMField(default='new')
@transition(field=state, source='new', target='published')
def publish(self, **kwargs):
pass
from django_fsm import can_proceed
post = BlogPost.objects.get(pk=1)
if can_proceed(post.publish):
post.publish()
post.save()
Installation
Install the package:
uv pip install django-fsm-2
Or install from git:
uv pip install -e git://github.com/django-commons/django-fsm-2.git#egg=django-fsm
Add django_fsm to your Django apps (required to graph transitions or use Admin integration):
INSTALLED_APPS = (
...,
'django_fsm',
...,
)
[!IMPORTANT] Migration from django-fsm
Django FSM-2 is a drop-in replacement. Update your dependency from
django-fsm to django-fsm-2 and keep your existing code.
uv pip install django-fsm-2
Usage
Core ideas
- Store a state in an
FSMField(orFSMIntegerField/FSMKeyField). - Declare transitions once with the
@transitiondecorator. - Transition methods can contain business logic and side effects.
- The in-memory state changes on success;
save()persists it.
Adding an FSM field
from django_fsm import FSMField, FSMModelMixin
class BlogPost(FSMModelMixin, models.Model):
state = FSMField(default='new')
Declaring a transition
from django_fsm import transition
@transition(field=state, source='new', target='published')
def publish(self, **kwargs):
"""
This function may contain side effects,
like updating caches, notifying users, etc.
The return value will be discarded.
"""
The field parameter accepts a string attribute name or a field instance.
If calling publish() succeeds without raising an exception, the state
changes in memory. You must call save() to persist it.
from django_fsm import can_proceed
def publish_view(request, post_id, **kwargs):
post = get_object_or_404(BlogPost, pk=post_id)
if not can_proceed(post.publish):
raise PermissionDenied
post.publish()
post.save()
return redirect('/')
Preconditions (conditions)
Use conditions to restrict transitions. Each function receives the
instance and must return truthy/falsey. The functions should not have
side effects.
def can_publish(instance):
# No publishing after 17 hours
return datetime.datetime.now().hour <= 17
class XXX(FSMModelMixin, models.Model):
@transition(
field=state,
source='new',
target='published',
conditions=[can_publish]
)
def publish(self, **kwargs):
pass
You can also use model methods:
class XXX(FSMModelMixin, models.Model):
def can_destroy(self):
return self.is_under_investigation()
@transition(
field=state,
source='*',
target='destroyed',
conditions=[can_destroy]
)
def destroy(self, **kwargs):
pass
Protected state fields
Use protected=True to prevent direct assignment. Only transitions may
change the state.
Because refresh_from_db assigns to the field, protected fields raise there
as well unless you use FSMModelMixin. Use FSMModelMixin by default to
allow refresh without enabling arbitrary writes elsewhere.
from django_fsm import FSMModelMixin
class BlogPost(FSMModelMixin, models.Model):
state = FSMField(default='new', protected=True)
model = BlogPost()
model.state = 'invalid' # Raises AttributeError
model.refresh_from_db() # Works
Source and target states
source accepts a list of states, a single state, or a django_fsm.State
implementation.
source='*'allows switching totargetfrom any state.source='+'allows switching totargetfrom any state excepttarget.
target can be a specific state or a django_fsm.State implementation.
from django_fsm import FSMField, transition, RETURN_VALUE, GET_STATE
@transition(
field=state,
source='*',
target=RETURN_VALUE('for_moderators', 'published'),
)
def publish(self, is_public=False, **kwargs):
return 'for_moderators' if is_public else 'published'
@transition(
field=state,
source='for_moderators',
target=GET_STATE(
lambda self, allowed: 'published' if allowed else 'rejected',
states=['published', 'rejected'],
),
)
def moderate(self, allowed, **kwargs):
pass
@transition(
field=state,
source='for_moderators',
target=GET_STATE(
lambda self, **kwargs: 'published' if kwargs.get('allowed', True) else 'rejected',
states=['published', 'rejected'],
),
)
def moderate(self, allowed=True, **kwargs):
pass
Custom transition metadata
Use custom to attach arbitrary data to a transition.
@transition(
field=state,
source='*',
target='onhold',
custom=dict(verbose='Hold for legal reasons'),
)
def legal_hold(self, **kwargs):
pass
Error target state
If a transition method raises an exception, you can specify an on_error
state.
@transition(
field=state,
source='new',
target='published',
on_error='failed'
)
def publish(self, **kwargs):
"""
Some exception could happen here
"""
Permissions
Attach permissions to transitions with the permission argument. It
accepts a permission string or a callable that receives (instance, user).
@transition(
field=state,
source='*',
target='published',
permission=lambda instance, user: not user.has_perm('myapp.can_make_mistakes'),
)
def publish(self, **kwargs):
pass
@transition(
field=state,
source='*',
target='removed',
permission='myapp.can_remove_post',
)
def remove(self, **kwargs):
pass
Check permission with has_transition_perm:
from django_fsm import has_transition_perm
def publish_view(request, post_id):
post = get_object_or_404(BlogPost, pk=post_id)
if not has_transition_perm(post.publish, request.user):
raise PermissionDenied
post.publish()
post.save()
return redirect('/')
Model helpers
Considering a model with a state field called "FIELD"
get_all_FIELD_transitionsenumerates all declared transitions.get_available_FIELD_transitionsreturns transitions available in the current state.get_available_user_FIELD_transitionsreturns transitions available in the current state for a given user.
Example: If your state field is called status
my_model_instance.get_all_status_transitions()
my_model_instance.get_available_status_transitions()
my_model_instance.get_available_user_status_transitions()
FSMKeyField (foreign key support)
Use FSMKeyField to store state values in a table and maintain FK
integrity.
class DbState(FSMModelMixin, models.Model):
id = models.CharField(primary_key=True)
label = models.CharField()
def __str__(self):
return self.label
class BlogPost(FSMModelMixin, models.Model):
state = FSMKeyField(DbState, default='new')
@transition(field=state, source='new', target='published')
def publish(self, **kwargs):
pass
In your fixtures/initial_data.json:
[
{
"pk": "new",
"model": "myapp.dbstate",
"fields": {
"label": "_NEW_"
}
},
{
"pk": "published",
"model": "myapp.dbstate",
"fields": {
"label": "_PUBLISHED_"
}
}
]
Note: source and target use the PK values of the DbState model as
names, even if the field is accessed without the _id postfix.
FSMIntegerField (enum-style states)
class BlogPostStateEnum(object):
NEW = 10
PUBLISHED = 20
HIDDEN = 30
class BlogPostWithIntegerField(FSMModelMixin, models.Model):
state = FSMIntegerField(default=BlogPostStateEnum.NEW)
@transition(
field=state,
source=BlogPostStateEnum.NEW,
target=BlogPostStateEnum.PUBLISHED,
)
def publish(self, **kwargs):
pass
Signals
django_fsm.signals.pre_transition and django_fsm.signals.post_transition
fire before and after an allowed transition. No signals fire for invalid
transitions.
Arguments sent with these signals:
senderThe model class.instanceThe actual instance being processed.nameTransition name.sourceSource model state.targetTarget model state.
Optimistic locking
Use ConcurrentTransitionMixin to avoid concurrent state changes. If the
state changed in the database, django_fsm.ConcurrentTransition is raised
on save().
from django_fsm import FSMField, ConcurrentTransitionMixin, FSMModelMixin
class BlogPost(ConcurrentTransitionMixin, FSMModelMixin, models.Model):
state = FSMField(default='new')
For guaranteed protection against race conditions caused by concurrently executed transitions, make sure:
- Your transitions do not have side effects except for database changes.
- You always call
save()within adjango.db.transaction.atomic()block.
Following these recommendations, ConcurrentTransitionMixin will cause a
rollback of all changes executed in an inconsistent state.
Admin Integration
NB: If you're migrating from django-fsm-admin (or any alternative), make sure it's not installed anymore to avoid installing the old django-fsm.
Update import path:
- from django_fsm_admin.mixins import FSMTransitionMixin
+ from django_fsm.admin import FSMAdminMixin
- In your admin.py file, use FSMAdminMixin to add behaviour to your ModelAdmin. FSMAdminMixin should be before ModelAdmin, the order is important.
from django_fsm.admin import FSMAdminMixin
@admin.register(AdminBlogPost)
class MyAdmin(FSMAdminMixin, admin.ModelAdmin):
# Declare the fsm fields you want to manage
fsm_fields = ['my_fsm_field']
...
- You can customize the buttons by adding
labelandhelp_textto thecustomattribute of the transition decorator
@transition(
field='state',
source=['startstate'],
target='finalstate',
custom={
"label": "My awesome transition", # this
"help_text": "Rename blog post", # and this
},
)
def do_something(self, **kwargs):
...
or by overriding some methods in FSMAdminMixin
@admin.register(AdminBlogPost)
class MyAdmin(FSMAdminMixin, admin.ModelAdmin):
...
def get_fsm_label(self, transition): # this method
if transition.name == "do_something":
return "My awesome transition"
return super().get_fsm_label(transition)
def get_help_text(self, transition): # and this method
if transition.name == "do_something":
return "Rename blog post"
return super().get_help_text(transition)
-
For forms in the admin transition flow, see the Custom Forms section below.
-
Hiding a transition is possible by adding
custom={"admin": False}to the transition decorator:
@transition(
field='state',
source=['startstate'],
target='finalstate',
custom={
"admin": False, # this
},
)
def do_something(self, **kwargs):
# will not add a button "Do Something" to your admin model interface
or from the admin:
@admin.register(AdminBlogPost)
class MyAdmin(FSMAdminMixin, admin.ModelAdmin):
...
def is_fsm_transition_visible(self, transition: fsm.Transition) -> bool:
if transition.name == "do_something":
return False
return super().is_fsm_transition_visible(transition)
NB: By adding FSM_ADMIN_FORCE_PERMIT = True to your configuration settings (or fsm_default_disallow_transition = False to your admin), the above restriction becomes the default.
Then one must explicitly allow that a transition method shows up in the admin interface using custom={"admin": True}
@admin.register(AdminBlogPost)
class MyAdmin(FSMAdminMixin, admin.ModelAdmin):
fsm_default_disallow_transition = False
...
Custom Forms
You can attach a custom form to a transition so the admin prompts for input
before the transition runs. Add a form entry to custom on the transition,
or define an admin-level mapping via fsm_forms. Both accept a forms.Form/
forms.ModelForm class or a dotted import path.
from django import forms
from django_fsm import FSMModelMixin, transition
class RenameForm(forms.Form):
new_title = forms.CharField(max_length=255)
# it's also possible to declare fsm log description
description = forms.CharField(max_length=255)
class BlogPost(FSMModelMixin, models.Model):
title = models.CharField(max_length=255)
state = FSMField(default="created")
@transition(
field=state,
source="*",
target="created",
custom={
"label": "Rename",
"help_text": "Rename blog post",
"form": "path.to.RenameForm",
},
)
def rename(self, new_title, **kwargs):
self.title = new_title
You can also define forms directly on your ModelAdmin without touching the
transition definition:
from django_fsm.admin import FSMAdminMixin
from .admin_forms import RenameForm
@admin.register(AdminBlogPost)
class MyAdmin(FSMAdminMixin, admin.ModelAdmin):
fsm_fields = ["state"]
fsm_forms = {
"rename": "path.to.RenameForm", # use import path
"rename": RenameForm, # or FormClass
}
Behavior details:
- When
formis set, the transition button redirects to a form view instead of executing immediately. - If both are defined,
fsm_formson the admin takes precedence overcustom["form"]on the transition. - On submit,
cleaned_datais passed to the transition method as keyword arguments and the object is saved. RenameFormreceives the current instance automatically.- You can override the transition form template by setting
fsm_transition_form_templateon yourModelAdmin(or override globallytemplates/django_fsm/fsm_admin_transition_form.html).
Drawing transitions
Render a graphical overview of your model transitions.
- Install graphviz support:
uv pip install django-fsm-2[graphviz]
or
uv pip install "graphviz>=0.4"
- Ensure
django_fsmis inINSTALLED_APPS:
INSTALLED_APPS = (
...,
'django_fsm',
...,
)
- Run the management command:
# Create a dot file
./manage.py graph_transitions > transitions.dot
# Create a PNG image file for a specific model
./manage.py graph_transitions -o blog_transitions.png myapp.Blog
# Exclude some transitions
./manage.py graph_transitions -e transition_1,transition_2 myapp.Blog
Extensions
Transition logging support could be achieved with help of django-fsm-log package : https://github.com/gizmag/django-fsm-log
Contributing
We welcome contributions. See CONTRIBUTING.md for detailed setup
instructions.
Quick Development Setup
# Clone and setup
git clone https://github.com/django-commons/django-fsm-2.git
cd django-fsm
uv sync
# Run tests
uv run pytest -v
# or
uv run tox
# Run linting
uv run ruff format .
uv run ruff check .
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_fsm_2-4.2.1.tar.gz.
File metadata
- Download URL: django_fsm_2-4.2.1.tar.gz
- Upload date:
- Size: 21.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e5a8b9bf9abc8546a6c8b6bb25c3c2b70d29d4bcc9dd998a9392d138aeb1b3ab
|
|
| MD5 |
b7e98875367379e3a5987470fd6ec9de
|
|
| BLAKE2b-256 |
588b62b769abae6ecf2e4fc02eb4503274e9e4bb236b3f1052d95c9fb1f7a59e
|
Provenance
The following attestation bundles were made for django_fsm_2-4.2.1.tar.gz:
Publisher:
release.yml on django-commons/django-fsm-2
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_fsm_2-4.2.1.tar.gz -
Subject digest:
e5a8b9bf9abc8546a6c8b6bb25c3c2b70d29d4bcc9dd998a9392d138aeb1b3ab - Sigstore transparency entry: 1065409437
- Sigstore integration time:
-
Permalink:
django-commons/django-fsm-2@10256aff840030e13a791cc1ff84b8190aaf2604 -
Branch / Tag:
refs/tags/4.2.1 - Owner: https://github.com/django-commons
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@10256aff840030e13a791cc1ff84b8190aaf2604 -
Trigger Event:
push
-
Statement type:
File details
Details for the file django_fsm_2-4.2.1-py3-none-any.whl.
File metadata
- Download URL: django_fsm_2-4.2.1-py3-none-any.whl
- Upload date:
- Size: 23.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7e9a2e37431a5e858f744157e7b680ab172901ae6e00013bed44242dc441a4ee
|
|
| MD5 |
2b71a250109a9da599f57d0f0fabb6af
|
|
| BLAKE2b-256 |
1fcc08cb68a51b2cbc9ae77c53abb22b2cc099291100bcbf67f4032d40639101
|
Provenance
The following attestation bundles were made for django_fsm_2-4.2.1-py3-none-any.whl:
Publisher:
release.yml on django-commons/django-fsm-2
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_fsm_2-4.2.1-py3-none-any.whl -
Subject digest:
7e9a2e37431a5e858f744157e7b680ab172901ae6e00013bed44242dc441a4ee - Sigstore transparency entry: 1065409443
- Sigstore integration time:
-
Permalink:
django-commons/django-fsm-2@10256aff840030e13a791cc1ff84b8190aaf2604 -
Branch / Tag:
refs/tags/4.2.1 - Owner: https://github.com/django-commons
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@10256aff840030e13a791cc1ff84b8190aaf2604 -
Trigger Event:
push
-
Statement type: