Django FSM-2 adds simple, declarative state management to Django models.
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)
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):
passfrom django_fsm import can_proceed
post = BlogPost.objects.get(pk=1)
if can_proceed(post.publish):
post.publish()
post.save()Install the package:
uv pip install django-fsm-2Or install from git:
uv pip install -e git://github.com/django-commons/django-fsm-2.git#egg=django-fsmAdd 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- 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.
from django_fsm import FSMField, FSMModelMixin
class BlogPost(FSMModelMixin, models.Model):
state = FSMField(default='new')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('/')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):
passYou 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):
passUse 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() # Workssource 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):
passUse 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):
passIf 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
"""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):
passCheck 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('/')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()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):
passIn 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.
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):
passdjango_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.
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.
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 interfaceor 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
...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_titleYou 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).
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.BlogTransition logging support could be achieved with help of django-fsm-log package : https://github.com/gizmag/django-fsm-log
We welcome contributions. See CONTRIBUTING.md for detailed setup
instructions.
# 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 .