Create a gist now

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Workflows in Django

Workflows (States) in Django

I'm going to cover a simple, but effective, utility for managing state and transitions (aka workflow). We often need to store the state (status) of a model and it should only be in one state at a time.

Common Software Uses

  • Publishing (Draft->Approved->Published->Expired->Deleted)
  • Payments
  • Account Authorization (New->Active->Suspended->Deleted)
  • Membership (Trial->Paid->Cancelled)
  • Quality Assurance, Games
  • Anything with a series of steps

Definitely avoid ...

Booleans for states

  • is_new
  • is_active
  • is_published
  • is_draft
  • is_deleted
  • is_paid
  • is_member
  • is_*

Mutually exclusive states ... sort of finite, but the number of states increases with each bool:

  • 2 bools = 2^2 = 4 states
  • 3 bools = 2^3 = 8 states
  • etc (2^N)

Brittle and too many states to check.

Finite State Machine

  • finite list of states
  • one state at a time; the current state
  • transition state by triggering event or condition

The behavior of state machines can be observed in many devices in modern society which perform a predetermined sequence of actions depending on a sequence of events with which they are presented.

Simple approach ...

CharField with defined choices

state = CharField(
    default=1,
    choices=[(1, "draft"), (2, "approved"), (3, "published")]
)

Define methods to change state:

def publish(self):
    self.state = 3
    email_sombody(self)
    self.save()

def approve(self):
    self.state = 2
    self.save()

Better, but ...

  • not enforced
    • Can I go from draft to published, skipping approval?
    • What happens if I publish something that's already published?
  • repetitive
  • side-effects mix with transition code

Some Goals

  • Safe, verifiable transitions between states
  • Conditions for the transition
  • Clear side effects from state transitions
  • DRY

django-fsm

  • declarative transitions and conditions (via decorators)
  • specialized field to contain state

https://github.com/kmmbvnr/django-fsm

P.S. RoR has similar apps too

FSMField

  • Specialized CharField
  • Set protected=True
    • to prevent direct/accidental manipulation
    • forces use of transition methods
    • raises an AttributeError "Direct state modification is not allowed"

Example

state = FSMField(
    default=State.DRAFT,
    verbose_name='Publication State',
    choices=State.CHOICES,
    protected=True,
)

(alternatives FSMIntegerField, FSMKeyField)

Transition decorator

@transition(field=state, source=[State.APPROVED, State.EXPIRED],
    target=State.PUBLISHED,
    conditions=[can_display])
def publish(self):
    '''
    Publish the object.
    '''
    email_the_team()
    update_sitemap()
    busta_cache()

What does this get us?

  • defined source and target states (valid transitions)
  • a method to complete the transition and define side-effects
  • a list of conditions (aside from state), that must be met for the transition to occur

Extras

Graphing state transitions

./manage.py graph_transitions -o example-graph.png fsm_example.PublishableModel

Something a bit more complex:

django-fsm-admin

https://github.com/gadventures/django-fsm-admin

  • submit row
  • logging history
  • noting what's required to change state (messages)

django-fsm-log

https://github.com/gizmag/django-fsm-log

If you'd like your state transitions stored in something other than the admin history.

Alternatives?

Not much out there. django-fsm has the most activity.

Fin

Craig Nagy @nagyman G Adventures - Software Engineering, eComm Mgr.

@ericntd-legacy

This comment has been minimized.

Show comment
Hide comment
@ericntd-legacy

ericntd-legacy Feb 29, 2016

Hi Nagy,

Thanks for your sharing.
I don't understand how to use the handle the signals from django-fsm though.

My model is as follows:

from django.db import models
from django.dispatch import receiver
class Article(models.Model):
    state = FSMField(default=STATE_DRAFT, verbose_name='Publication State', choices=STATE_CHOICES, protected=False)
    @receiver(pre_transition)
    def on_pre_transition(self, sender, instance, name, source, target, **kwargs):
        print('pre_transition')

Then when I "published" an article using django-fsm-admin, I got an error:

TypeError at /admin/articles/article/32/
on_pre_transition() missing 1 required positional argument: 'self'

How do I use these callbacks?

Hi Nagy,

Thanks for your sharing.
I don't understand how to use the handle the signals from django-fsm though.

My model is as follows:

from django.db import models
from django.dispatch import receiver
class Article(models.Model):
    state = FSMField(default=STATE_DRAFT, verbose_name='Publication State', choices=STATE_CHOICES, protected=False)
    @receiver(pre_transition)
    def on_pre_transition(self, sender, instance, name, source, target, **kwargs):
        print('pre_transition')

Then when I "published" an article using django-fsm-admin, I got an error:

TypeError at /admin/articles/article/32/
on_pre_transition() missing 1 required positional argument: 'self'

How do I use these callbacks?

@jrief

This comment has been minimized.

Show comment
Hide comment
@jrief

jrief Mar 20, 2016

You should rewrite your pre/post-transition methods to:

    @staticmethod
    @receiver(pre_transition)
    def on_pre_transition(sender, instance=None, target=None, **kwargs):
        if not isinstance(instance, sender):
            return
        print('pre_transition')

and use instance as you would with self. Keep in mind, that instance can be an object of any type, hence the optional check if not isinstance(instance, sender)

jrief commented Mar 20, 2016

You should rewrite your pre/post-transition methods to:

    @staticmethod
    @receiver(pre_transition)
    def on_pre_transition(sender, instance=None, target=None, **kwargs):
        if not isinstance(instance, sender):
            return
        print('pre_transition')

and use instance as you would with self. Keep in mind, that instance can be an object of any type, hence the optional check if not isinstance(instance, sender)

@kishan3

This comment has been minimized.

Show comment
Hide comment
@kishan3

kishan3 May 12, 2016

in your example

state = FSMField(
default=State.DRAFT,
verbose_name='Publication State',
choices=State.CHOICES,
protected=True,
)

Is the State another model? What are the values for State.CHOICES
???

kishan3 commented May 12, 2016

in your example

state = FSMField(
default=State.DRAFT,
verbose_name='Publication State',
choices=State.CHOICES,
protected=True,
)

Is the State another model? What are the values for State.CHOICES
???

@hoshomoh

This comment has been minimized.

Show comment
Hide comment
@hoshomoh

hoshomoh Aug 1, 2016

How do you update the state of a model from an API not necessary the admin dashboard. In my case I am using django-rest-framework

hoshomoh commented Aug 1, 2016

How do you update the state of a model from an API not necessary the admin dashboard. In my case I am using django-rest-framework

@caruccio

This comment has been minimized.

Show comment
Hide comment
@caruccio

caruccio Aug 3, 2016

@hoshomoh Just call the transition function from your view.

caruccio commented Aug 3, 2016

@hoshomoh Just call the transition function from your view.

@caruccio

This comment has been minimized.

Show comment
Hide comment
@caruccio

caruccio Aug 3, 2016

It took me a while to understand that, in order to persist the state, one must call model.save() outside/after @transition function.

@transition(field=state, source='new', target='paid')
def charge(self):
    '''do whatever you need to get paid'''

def pay_invoice_view(request):
    invoice = models.Invoice.get_user_invoice(request.user)
    invoice.charge()
    invoice.save() ## must save here, not inside charge()

caruccio commented Aug 3, 2016

It took me a while to understand that, in order to persist the state, one must call model.save() outside/after @transition function.

@transition(field=state, source='new', target='paid')
def charge(self):
    '''do whatever you need to get paid'''

def pay_invoice_view(request):
    invoice = models.Invoice.get_user_invoice(request.user)
    invoice.charge()
    invoice.save() ## must save here, not inside charge()
@sharmaswati6

This comment has been minimized.

Show comment
Hide comment
@sharmaswati6

sharmaswati6 Feb 7, 2018

Can anybody share a complete sample app pushed to git pls?

Can anybody share a complete sample app pushed to git pls?

@fenilgandhi

This comment has been minimized.

Show comment
Hide comment
@fenilgandhi

fenilgandhi Jun 5, 2018

Thank you. been stuck on this for over a week until i discovered this.

Thank you. been stuck on this for over a week until i discovered this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment