How to make a finite-state machine in your Django application

Saad Ali
5 min readNov 6, 2022

--

In simple words, a finite-state machine is a computational model that helps to replicate any real-world scenario in which one object can have multiple states and can transition from one state to another. In FSM, an object can only have a single state at a given time.

We know a computer has basic three states of transition when computing something. We need to give input to our computer, it will process it and give us an output. Similarly in the worst-case scenario, the computer fails to process our input and throws an error. For example, dividing two numbers. If we give two numbers (other than zero) computer will process it and give us a result. But let’s say we give zero (0) as a denominator, and the computer gives an error.

Now let's implement a Finite state machine in your web application

As an employee, there are times when you have to take a leave from your job, for that you have to first apply for the leave then someone (maybe your project manager) has to review it before submitting it. After that, your leave request is either approved or rejected.

Let’s build this scenario in a Django application for that we first need to setup our basic Django application that has two apps: users & communications

Dependencies

pip install django
pip install djangorestframework
pip install django-fsm
pip install graphviz

Requirements:

(*) Add 'rest_framework' and 'django_fsm' in your INSTALLED_APPS list, in settings.py

Apps

python manage.py startapp users
python manage.py startapp communications

settings.py

INSTALLED_APPS [
'users.apps.UsersConfig',
'communications.apps.CommunicationsConfig', .....

users/models.py

from django.contrib.auth.models import AbstractUser


class User(AbstractUser):

def __str__(self):
return self.email

communications/models.py

from django.contrib.auth import get_user_model
from django.db import models
from django_fsm import FSMField, transition

from communications.choices import LeaveRequestStatus

User = get_user_model()


class LeaveRequest(models.Model):
text = models.TextField()
submitted_by = models.ForeignKey(User, on_delete=models.PROTECT, related_name="leave_requests")
status = FSMField(default=LeaveRequestStatus.DRAFT, choices=LeaveRequestStatus.choices)

@transition(field=status, source=[LeaveRequestStatus.DRAFT, LeaveRequestStatus.CHANGES_REQUESTED], target=LeaveRequestStatus.REVIEW)
def move_to_review(self):
return

@transition(field=status, source=LeaveRequestStatus.REVIEW, target=LeaveRequestStatus.CHANGES_REQUESTED)
def request_changes(self):
return

@transition(field=status, source=LeaveRequestStatus.REVIEW, target=LeaveRequestStatus.SUBMITTED)
def mark_submitted(self):
return

@transition(field=status, source=LeaveRequestStatus.SUBMITTED, target=LeaveRequestStatus.APPROVED)
def mark_approved(self):
return

@transition(field=status, source=LeaveRequestStatus.SUBMITTED, target=LeaveRequestStatus.DECLINED)
def mark_declined(self):
return

def __str__(self):
return f"{self.text} | Request by: {self.submitted_by}"

Create a choices.py file in your communication app and place the following code

from django.db import models


class LeaveRequestStatus(models.TextChoices):
DRAFT = "DRAFT", "Draft"
REVIEW = "REVIEW", "Review"
CHANGES_REQUESTED = "CHANGES_REQUESTED", "Changes requested"
SUBMITTED = "SUBMITTED", "Submitted"
APPROVED = "APPROVED", "Approved"
DECLINED = "DECLINED", "Declined"

After that create migrations for your models and apply them

python manage.py makemigrations
python manage.py migrate

You can observe, we have created all the possible scenarios for the leave request in the LeaveRequest model class. One thing to remember, we can only update the status of leave requests with the rules we have defined in our model (@transition methods)

Now let's create views for our communication app, we need to create a view for each of the state transitions.

communications/views.py

from django_fsm import TransitionNotAllowed
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView

from communications.models import LeaveRequest


class ReviewLeaveRequestAPIView(APIView):

def post(self, request, pk):
leave_request = LeaveRequest.objects.get(pk=pk)
try:
leave_request.move_to_review()
leave_request.save()
response = Response(status=status.HTTP_200_OK)
except TransitionNotAllowed:
response = Response(status=status.HTTP_400_BAD_REQUEST)
return response


class ChangesRequiredLeaveRequestAPIView(APIView):

def post(self, request, pk):
leave_request = LeaveRequest.objects.get(pk=pk)
try:
leave_request.request_changes()
leave_request.save()
response = Response(status=status.HTTP_200_OK)
except TransitionNotAllowed:
response = Response(status=status.HTTP_400_BAD_REQUEST)
return response


class SubmitLeaveRequestAPIView(APIView):

def post(self, request, pk):
leave_request = LeaveRequest.objects.get(pk=pk)
try:
leave_request.mark_submitted()
leave_request.save()
response = Response(status=status.HTTP_200_OK)
except TransitionNotAllowed:
response = Response(status=status.HTTP_400_BAD_REQUEST)
return response


class ApproveLeaveRequestAPIView(APIView):

def post(self, request, pk):
leave_request = LeaveRequest.objects.get(pk=pk)
try:
leave_request.mark_approved()
leave_request.save()
response = Response(status=status.HTTP_200_OK)
except TransitionNotAllowed:
response = Response(status=status.HTTP_400_BAD_REQUEST)
return response


class DeclineLeaveRequestAPIView(APIView):

def post(self, request, pk):
leave_request = LeaveRequest.objects.get(pk=pk)
try:
leave_request.mark_declined()
leave_request.save()
response = Response(status=status.HTTP_200_OK)
except TransitionNotAllowed:
response = Response(status=status.HTTP_400_BAD_REQUEST)
return response

communication/urls.py

from django.urls import path

from communications.views import ReviewLeaveRequestAPIView, ChangesRequiredLeaveRequestAPIView, SubmitLeaveRequestAPIView, ApproveLeaveRequestAPIView, DeclineLeaveRequestAPIView

urlpatterns = [
path("review/<int:pk>/", ReviewLeaveRequestAPIView.as_view(), name="review_leave_request"),
path("changes-requested/<int:pk>/", ChangesRequiredLeaveRequestAPIView.as_view(), name="changes_reequested_leave_request"),
path("submit/<int:pk>/", SubmitLeaveRequestAPIView.as_view(), name="submit_leave_request"),
path("approve/<int:pk>/", ApproveLeaveRequestAPIView.as_view(), name="approve_leave_request"),
path("decline/<int:pk>/", DeclineLeaveRequestAPIView.as_view(), name="decline_leave_request"),
]

Update your project URLs too (project-name/urls.py)

urlpatterns = [
path('admin/', admin.site.urls),
path("api/requests/", include("communications.urls")),
]

Additional step (optional)

Add an admin.py file in both of your apps (users and communications) so that you can create dummy users and requests to test your APIs easily

users/admin.py

from django.contrib import admin

from users.models import User


@admin.register(User)
class UserAdmin(admin.ModelAdmin):
list_display = (
"id",
"email",
"first_name",
"last_name",
"is_superuser",
"is_staff",
"is_active",
)
list_filter = (
"is_superuser",
"is_staff",
"is_active",
"last_login",
)
search_fields = ("=id", "email")

communications/admin.py

from django.contrib import admin

from communications.models import LeaveRequest


@admin.register(LeaveRequest)
class UserAdmin(admin.ModelAdmin):
list_display = (
"id",
"text",
"submitted_by",
"status",
)
list_filter = (
"status",
)
search_fields = ("=id", "status")

Now let’s test our APIs using Postman. I have created a dummy request with (DRAFT status) using Django admin. I am going to change its status to REVIEW by using review API.

response: 200 OK

After calling the same API, it throws an error because we can’t move to the REVIEW state from the REVIEW state.

response: 400 Bad Request

Lastly, we can also draw our finite-state machine using the Graphviz tool. Just run the below two commands to get the image form of your finite-state machine.

python manage.py graph_transitions > transitions.dot
dot -Tpng transitions.dot -o fsm.png

Hope you like this article, feel free to share your feedback in the chatbox.

--

--

Saad Ali
Saad Ali

Written by Saad Ali

Software Engineer. Love to work in Python, Django and Rails

No responses yet