Implementing configurable work-flow patterns in Python Django

In my previous article, I discussed some of changes I've made to my WAM software to handle assessment and work-flow. I thought I'd have a look at this from the technical side for those interested in doing something similar, this is obviously extensible to general workflow management, where you might want to tweak the workflow later without diving into code.

My challenge was to consider how not to hard code a work-flow, but to have something that would be configurable, in my case in a SQL layer because I'm using Python and Django.

I had an idea about the work-flow I wanted, and it looked a bit like this (carefully sketched on my tablet). These nodes are particular states, so this isn't really a flow chart, as decisions aren't shown. What is shown is what states can progress to the next ones. But I wanted to be able to change the pattern of nodes in the future, or rather, I wanted users to be able to do this without altering the code. I also wanted to work out who could do what, and who should know about what.

Workflow Example

Workflow Example

Understanding States

The first thing I did was to create a State model class, and I guess in my head I was thinking of Markov Models.

class AssessmentState(models.Model):
    """Allows for configurable Assessment Resource Workflow

    name            The name of the state
    description     More details on the state
    actors          The user types who can create this state, CSV field
    notify          The user types to notify that the state has been created
    initial_state   Can this be an initial state?
    next_states     Permissible states to move to from this one
    priority        To allow the states to be sorted for presentation to the user

    actors and notify should be comma separated lists as in USER_TYPES below.
    """

    ANYONE = 'anyone'
    COORDINATOR = 'coordinator'
    MODERATOR = 'moderator'
    EXTERNAL = 'external'
    TEAM_MEMBER = 'team_member'
    ASSESSMENT_STAFF = 'assessment_staff'

    USER_TYPES = (
        (ANYONE, 'Any logged in user'),
        (COORDINATOR, 'Module coordinator'),
        (MODERATOR, 'Module moderator'),
        (TEAM_MEMBER, 'Module teaching team member'),
        (EXTERNAL, 'External Examiner'),
        (ASSESSMENT_STAFF, 'Members of AssessmentStaff'),
        ('exams_office', 'Exams Office')
    )

    name = models.CharField(max_length=200)
    description = models.TextField()
    actors = models.TextField()
    notify = models.TextField()
    initial_state = models.BooleanField(default = False)
    next_states = models.ManyToManyField('self', blank=True, symmetrical=False, related_name='children')
    priority = models.IntegerField()

As you can see, I created variables that told me the name of the state, and an opportunity for a more detailed description. I then wanted to be able to specify who could do certain things, and be notified. So, rather than a long series of Booleans, I want for a text field - the work-flow won't be edited very often, and when it is, it should be by someone who knows what they are doing. So it's just a Comma Separated text field. For instance.

coordinator,moderator

will indicate that the Module Coordinator and Moderator should be involved (this is an HE example, but the principle is quite extensible).

So the actors field will specify which kinds of people can invoke this state, and the notify field those who should get to hear about it.

I want to draw your attention to this bit:

next_states = models.ManyToManyField('self', blank=True, symmetrical=False, related_name='children')

What on earth does this do? It allows a Django model to have a Many to Many relationship with itself. In other words, for me to associate a number of states with this one. Please also note that presence of

symmetrical=False

This is most easily explained by comparison to the Facebook and Twitter friendship model. Both of these essentially link a User model in a many to many relationship with itself.

Facebook friends are symmetrical, once the link is established, it is two way. Twitter followers are not symmetrical.

I wanted to establish which successor states could be invoked from any given one. And this should not be symmetrical by default. You can see in my example graph above, I want it to be possible to move from state A to either B or C, but this is not entirely symmetric, it is possible to move from B to A, but it should not be possible to go from C to A. Without symmetric=False, each link will create an implied link back (all arrows in my state diagram would be bi-directional) which would be problematic. By establishing the relationship as asymmetric we can allow a reciprocal link (as is possible in Twitter, and our A and B example), but we don't enforce it, so that we prevent back tracking in work-flow where it should not be allowed (as in our A and C example).

Invoking States

I then created another model to keep track of which states were invoked.

class AssessmentStateSignOff(models.Model):
    """Marks a particular sign off of an AssessmentState for a module

    module              The module being signed off
    assessment_state    The AssessmentState being used
    signed_by           The user signing off (Could be Staff or Examiner)
    created             The datestamp for signoff
    notified            The datestamp for notificiations sent
    notes               Any comments
    """

    module = models.ForeignKey(Module, on_delete=models.CASCADE)
    assessment_state = models.ForeignKey(AssessmentState, on_delete=models.CASCADE)
    signed_by = models.ForeignKey(User, on_delete=models.CASCADE)
    created = models.DateTimeField(auto_now_add=True)
    notified = models.DateTimeField(null=True, blank=True)
    notes = models.TextField()

    def __str__(self):
        return str (self.assessment_state)

This model allows me to work out who (the signed_by field) invoked a particular AssessmentState, when, and with any particular notes.

I also added a field to record when a notification (notified) had been sent. On creation, I leave that field as null. One of the many glorious things about Django is that it's infrastructure for custom management commands allows you to easily build command line tools for doing cron tasks while your web front end runs without interruption. I found this rather awkward, but not impossible, in PHP, but in Django the whole thing is very organic, and you get access to all your models. If you have pushed plenty of your logic into the Model layer and not the View layer, this can really help.

In my new custom commend I can easily work out which signoffs have not been notified yet:

# Get all sign offs with no notification time
signoffs = AssessmentStateSignOff.objects.all().filter(notified=None).order_by("created")

I can then act upon those, send notifications, and if that's successful, set the notified field to the time at which I sent them.

Further Reading

In this article I have concentrated on the Model layer, with a few other observations, and in particular the relationship from a State model to itself.

All of the Forms and Views are available within my GitHub repository for the project. They aren't a work of art, but if you have any questions feel free to look there, or get in touch.

I hope that might be helpful to someone facing the same challenge, and do feel free to suggest how I could have solved the problem more elegantly.

Follow me!

Leave a Reply

Your email address will not be published. Required fields are marked *