Finite State Machine (state transitions) with django-fsm¶
In this guide we are going to:
Install
django-fsm-2packageDefine the blog Post worflow using the
transitiondecoratorTest the workflow using pytest
Generate statechart diagram
Pre-requisites¶
Requires the Create Django Models for the Blog API guide to be completed.
Guide code:¶
custom-django-user-model branch in the GitHub repository.
Introduction¶
Our blog posts should have state. State could be:
DRAFT - post can be seen and modified by the author
PUBLISHED - post can be seen by everyone
ARCHIVED - post can be seen only by the author
Only author can change the post state.
Post could be archived from any state. Post can be published only from draft. Post can be made draft from any state.
Here is what the state transitions look like. The diagram has been created using django-fsm package.
Install django-fsm¶
Add django-fsm-2 to the application dependencies (requirements.txt):
-r docs/requirements.txt
django
django-fsm-2
djangorestframework
pytest
pytest-cov
pytest-django
python-dotenv
Install the dependencies:
pip install -U -r requirements.txt
Modify Post Model¶
Let’s define our workflow using the django-fsm transition decorator:
# /src/posts/models.py
from django.conf import settings
from django.db import models
from django_fsm import FSMField, transition
# .........................
class PostState(models.TextChoices):
DRAFT = "draft"
PUBLISHED = "published"
ARCHIVED = "archived"
class Post(models.Model):
title = models.CharField(max_length=50)
body = models.TextField()
tags = models.ManyToManyField("Tag", related_name="posts", through="PostTag")
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
state = FSMField(default=PostState.DRAFT, choices=PostState.choices) # new
def can_publish(self, user):
return self.author == user
def can_archive(self, user):
return self.author == user
def can_draft(self, user):
return self.author == user
@transition(
field=state,
source=PostState.DRAFT,
target=PostState.PUBLISHED,
permission=can_publish,
)
def publish(self):
pass
@transition(
field=state, source="*", target=PostState.ARCHIVED, permission=can_archive
)
def archive(self):
pass
@transition(field=state, source="*", target=PostState.DRAFT, permission=can_draft)
def draft(self):
pass
def __str__(self):
return self.title
We also defined permission checking methods and attached them to the state mutator methods, using the permission parameter
of the transition decorator.
After we modify the model, we should always migrate our database:
python src/manage.py makemigrations
python src/manage.py migrate
Test the State Machine¶
To verify requirements are satisfied, write state machine tests:
# /src/tests/posts/test_models_state.py
import pytest
from django_fsm import (
TransitionNotAllowed,
get_available_FIELD_transitions,
get_available_user_FIELD_transitions,
has_transition_perm,
)
from posts.models import Post, PostState
pytestmark = [pytest.mark.django_db]
class TestPostState:
# DRAFT
def test_should_have_draft_state_after_created(self, user):
post = Post(author=user)
assert post.state == PostState.DRAFT
def test_should_permit_to_draft_from_archive_by_author(self, post, user):
assert has_transition_perm(post.draft, user)
def test_should_not_permit_to_draft_from_archive_by_not_author(self, post, user2):
assert not has_transition_perm(post.draft, user2)
def test_should_transition_to_draft_from_archived_after_draft(self, post):
post.state = PostState.ARCHIVED
post.save()
post.draft()
assert post.state == PostState.DRAFT
# PUBLISHED
def test_should_not_permit_publish_by_non_author(self, post, user2):
assert not has_transition_perm(post.publish, user2)
def test_should_not_allow_publish_from_archived_after_draft(self, post):
post.state = PostState.ARCHIVED
post.save()
with pytest.raises(TransitionNotAllowed):
post.publish()
def test_should_allow_to_publish_by_author(self, post, user):
assert has_transition_perm(post.publish, user)
def test_should_transition_to_published_from_draft_after_publish(self, post):
post.publish()
assert post.state == PostState.PUBLISHED
# ARCHIVED
def test_should_not_permit_archive_by_non_author(self, post, user2):
assert not has_transition_perm(post.archive, user2)
def test_should_permit_archive_by_author(self, post, user):
assert has_transition_perm(post.archive, user)
def test_should_transition_to_archived_from_draft_after_archive(self, post):
post.archive()
assert post.state == PostState.ARCHIVED
def test_should_transition_to_archived_from_published_after_archive(self, post):
post.state = PostState.PUBLISHED
post.archive()
assert post.state == PostState.ARCHIVED
# PARAMETRIZED
@pytest.mark.parametrize(
"state,expect_states,user_fixture",
(
(PostState.DRAFT, ["draft", "publish", "archive"], None),
(PostState.DRAFT, ["draft", "publish", "archive"], "user"),
(PostState.DRAFT, [], "user2"),
(PostState.PUBLISHED, ["draft", "archive"], None),
(PostState.PUBLISHED, ["draft", "archive"], "user"),
(PostState.PUBLISHED, [], "user2"),
(PostState.ARCHIVED, ["draft"], None),
(PostState.ARCHIVED, ["draft"], "user"),
(PostState.ARCHIVED, [], "user2"),
),
)
def test_should_return_available_state_transitions(
self, post, request, state, expect_states, user_fixture
):
post.state = state
state_field = Post._meta.get_field("state")
if user_fixture:
user = request.getfixturevalue(user_fixture)
available_state_names = [
t.name for t in get_available_user_FIELD_transitions(post, user, state_field)
]
else:
available_state_names = [
t.name for t in get_available_FIELD_transitions(post, state_field)
]
assert sorted(available_state_names) == sorted(expect_states)
Generate Statechart Diagram¶
First you need to install graphviz package. Update requirements.txt and install the dependencies:
-r docs/requirements.txt
django
django-fsm-2
djangorestframework
graphviz
pytest
pytest-cov
pytest-django
python-dotenv
pip install -U -r requirements.txt
You need to add also django-fsm to INSTALLED_APPS:
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# 3rd Party
"rest_framework",
"django_fsm", # new
# Local
"accounts.apps.AccountsConfig",
"posts.apps.PostsConfig",
]
You also need to install (depends on your operating system) graphviz executables (see graphviz downloads for more information):
sudo apt install graphviz
Generate the image:
python src/manage.py graph_transitions -o post_transitions.png posts.Post
If for some reason graphviz is not in the PATH:
PATH=$PATH:/path/to/graphviz/binary python src/manage.py graph_transitions -o post_transitions.png posts.Post
Here is an example of the diagram generated after following this guide:
Questions¶
When you look at the statechart diagram, you might find that there are some state transitions that are not tested by our tests. Could you find them? Write tests for them?
Think how you could improve the tests?