LB (Ben Johnston)
Posted on September 22, 2021
Goal: Create a simple way for Wagtail's CMS admin users to manage custom Workflow Tasks with checklists.
Why: Wagtail's Workflow feature is incredibly powerful and can be leveraged to help guide our users through their Page publishing process easier.
Journey
Note: Feel free to skip if you just want to see the code.
One of my favourite books is The Checklist Manifesto: How to Get Things Right and another I am reading right now is Everything in Its Place: The Power of Mise-En-Place to Organise Your Life, Work, and Mind. The core message that is in common with these is the simple practice of writing a checklist or a plan before you start and where possible, manage those checklists for future work of the same kind.
Now, checklists can become a burden to those who are forced to mindlessly tick 700 boxes they never read for the thousandth time this week, so as in all things there is a balance.
However, I do honestly believe that simple checklists for recurring processes can help both new people to a process and those who are veterans, remember the critical aspects of what they do day to day.
One visceral memory I have of checklists is when my son, Leo, was born. My wife had to go into an emergency C-section and while I won't go into any more details, I do vividly remember up on the wall were two huge checklists (just like described in the Checklist Manifesto) with four or five key steps regarding the surgery process.
This memory is obviously key to me for more than just checklists, I got to see my amazing son for the first time. I often think back to that moment and wonder how many times a day, or that week those in the room would have done the same operation but how many lives a simple checklist on the wall would have saved.
It also puts into perspective the code I write and the processes we design for our team. Most likely what we do is not something that could risk the lives of others but nonetheless, we all have a part to play in helping our teams be excellent and remember the little but non-trivial things they do in their job.
So that leads us to Wagtail's Workflow system, which was introduced in Wagtail 2.10. This system replaced the previous moderation workflow and was a sponsored feature (a special thanks to those who contribute to Open Source / Free Software) and may have flown under the radar for many of those who use Wagtail.
However, this Workflow feature has been built from the ground up to be very extensible and even borrows a lot of the approaches from Wagtail's core Page
model itself where a mix of custom code and CMS Admin editing can be combined to make something incredibly flexible and powerful.
What We Are Building
In this tutorial, you may have guessed, we will be putting together a way for Workflow Tasks to be created with a checklist and then that checklist is presented to those when they approve that specific step in the workflow.
This means that Wagtail Admin users could create a Workflow Checklist Task that is specifically for approval of types of pages or a generic one for all pages.
Tutorial
Step 0 - Getting Started
- It is assumed at this point you have Wagtail running locally and have a basic understanding of the Workflow system from a user's perspective.
- If not, it would be best to start with the Wagtail Getting Started guide.
- It is also assumed you have a basic understanding of Django's Model and Form systems, although if not maybe this Tutorial will be a good way to get some deeper understanding.
- Versions:
- Django 3.2
- Wagtail 2.14
Step 1 - Create the ChecklistApprovalTask
Model
- Firstly, we need to think about what this
Task
model is and what we want to let the user fill out. - The Wagtail
Workflow
area has a few key models, the main being aWorkflow
and aTask
. - When creating a
Task
in the Wagtail admin, the user is presented with what kind ofTask
to use (similar to when creating a newPage
), this UI only shows if there are more than one kind ofTask
models available (Wagtail will search through your models and find all those that are subclasses of the coreTask
model). - So, the
Task
model we are creating contains the fields that the user enters for multipleTask
s of that 'kind', which are then mapped to one or more user createdWorkflow
s. - For our base
Task
model in that case, we do not actually need to define sets of checklists but rather a way for users to enter a set of checklist items and the simplest way to do this would be with a multi-lineTextField
where each line becomes a checklist item. - Along with that, we will also add a field to determine whether this
Task
will require ALL checklist items to be ticked when submitting, this way the checklists can be a 'suggestion' or a 'requirement' on a perTask
instance basis. - It is important to remember that the
Task
instance can be changed at any time, so the checklist the user views today could be different tomorrow and as such we will keep this implementation simpler by not tracking that the checklist was submitted and which items were ticked but that could be implemented as an enhancement down the road. - The following code is loosely based on the Wagtail docs How to add new Task Types section, however, to save a bunch of reimplementation, instead of building all the user logic on our own we will just extend the existing (built-in)
GroupApprovalTask
. - The reason for extending
GroupApprovalTask
is that ourChecklistApprovalTask
is very similar, we want to assign a user group that can approve/reject as aTask
but we just want to allow the approve step to show extra content in the approval screen. - Once you implement the code below you should be able to create a new Workflow and add one of the new
ChecklistApprovalTask
instances. For the rest of this tutorial it would be good to have one ready to go to test with as we build out the features.
Code models.py
- Create a new model
ChecklistApprovalTask
that extendsGroupApprovalTask
. - This new model will contain two fields;
checklist
aTextField
which will be used to generate the checklist items (per line), and ais_checklist_required
BooleanField
which will be ticked to force each checklist item to be ticked. - Similar to
Page
panels
, we can use theadmin_form_fields
class attribute to define a List of fields that will be shown when a user creates/edits thisTask
. - The last part of our model is to leverage the
get_description
class method and the meta verbose names to provide a user-facing description & name of thisTask
type, plus we want to override the one that comes with theGroupApprovalTask
class. - Once you have built your model, run
django-admin makemigrations
and thendjango-admin migrate
to apply your new model.
from django.db import models
from wagtail.core.models import GroupApprovalTask
class ChecklistApprovalTask(GroupApprovalTask):
"""
Custom task type where all the features of the GroupApprovalTask will exist but
with the ability to define a custom checklist that may be required to be checked for
Approval of this step. Checklist field will be a multi-line field, each line being
one checklist item.
"""
# Reminder: Already has 'groups' field as we are extending `GroupApprovalTask`
checklist = models.TextField(
"Checklist",
help_text="Each line will become a checklist item shown on the Approve step.",
)
is_checklist_required = models.BooleanField(
"Required",
help_text="If required, all items in the checklist must be ticked to approve.",
blank=True,
)
admin_form_fields = GroupApprovalTask.admin_form_fields + [
"checklist",
"is_checklist_required",
]
@classmethod
def get_description(cls):
return (
"Members of the chosen User Groups can approve this task with a checklist."
)
class Meta:
verbose_name = "Checklist approval task"
verbose_name_plural = "Checklist approval tasks"
Before you continue: Check that when you create a new Task you can now see that there are two options available; 'Group approval task' and 'Checklist approval task'.
Step 2 - Revise the Actions to Always Show the Form Modal
- The built-in
GroupApprovalTask
, when in a Workflow, will give the user two options; 'Approve and Publish' and 'Approve with Comment and Publish', the difference is that the one with the comment will open a form modal when clicked where the user can fill out a comment. - What we want to do for our custom
Task
is to ensure that the approval step can only be completed with a form modal variant, and in this form we will show the checklist. - Each
Task
has a methodget_actions
which will return a list of(action_name, action_verbose_name, action_requires_additional_data_from_modal)
tuples. - We will now revise this method to leverage the existing check if the user is in the right group but only return one option with the form modal being required, we will also ensure there is a reject action allowed.
Code models.py
- Within the
CrosscheckApprovalTask
built above, create a new methodget_actions
, this should copy the user check from the GroupApprovalTask implementation but only return two actions.
class CrosscheckApprovalTask(GroupApprovalTask):
# ... checklist etc, from above step
def get_actions(self, page, user):
"""
Customise the actions returned to have a reject and only one approve.
The approve will have a third value as True which indicates a form is
required.
"""
if self.groups.filter(id__in=user.groups.all()).exists() or user.is_superuser:
REJECT = ("reject", "Request changes", True)
APPROVE = ("approve", "Approve", True)
return [REJECT, APPROVE]
return []
# ... @classmethod etc
Before you continue: Check that when you put an existing Page into the workflow that contains this new task type, when approving the change it will show only one option 'Approve and Publish' and this should open a form modal. No need to approve just yet though.
Step 3 - Build a Basic Checklist Form
- We now need to create a custom form that leverages the existing form that
GroupApprovalTask
makes, this form needs to have aMultipleChoiceField
where each of thechoices
is a line in ourTask
model'schecklist
field. - We also want to make the form field dynamic based on the
Task
model'sis_checklist_required
saved value. - To override the
Task
form we can add aget_form_for_action
method and when the action is'approve'
we can provide a custom Form. - The things we need to answer is what form to extend (we want to ensure we get the comment field still), how do we ensure that the checklist values do not try to be 'saved' to the
TaskState
(the model that reflects each state for aTask
as it is processed). - If we take a look at the implementation of
get_form_for_action
on theGroupApprovalTask
we can see that it returns aTaskStateCommentForm
which extends a DjangoForm
with one field,comments
.
Code models.py
- To build a dynamic Class in Python, we can declare a new class in a function or we can also use the
type
built-in function, passing in three args. A name, base classes tuple and a dict where each key will be used to generate dynamic attributes (fields) and methods (e.g. the clean method). - In this dynamic class we will extend whatever the super's
get_form_for_action
returns, this way we do not need to think about what this is in the code, but know that it is theTaskStateCommentForm
above. - We will also need to add a
clean
method that will remove any checklist values that are submitted (as we do not want to save these). - We will need to add a field,
checklist
, which we will pull out to a new class methodget_checklist_field
which can return aforms.MultipleChoiceField
that has dynamic values forrequired
and thechoices
based on theTask
instance. Note: The default widget used for this field isSelectMultiple
which is a bit cluncky, but we will enhance that in the next step. - We will also want to ensure that our
checklist
field shows before thecomment
field, for that we can dynamically add afield_order
attribute.
from django import forms
from django.db import models
from django.utils.translation import gettext_lazy as _
# ... other imports
class ChecklistApprovalTask(GroupApprovalTask):
# ... checklist etc, from above step
def get_checklist_field(self):
"""
Prepare a form field that is a list of checklist boxes for each line in the
checklist on this Task instance.
"""
required = self.is_checklist_required
field = dict(label=_("Checklist"), required=required)
field["choices"] = [
(index, label) for index, label in enumerate(self.checklist.splitlines())
]
return forms.MultipleChoiceField(**field)
def get_form_for_action(self, action):
"""
If the action is 'approve', return a new class (using type) that has access
to the checklist items as a field based on this Task's instance.
"""
form_class = super().get_form_for_action(action)
if action == "approve":
def clean(form):
"""
When this form's clean method is processed (on a POST), ensure we do not pass
the 'checklist' data any further as no handling of this data is built.
"""
cleaned_data = super(form_class, form).clean()
if "checklist" in cleaned_data:
del cleaned_data["checklist"]
return cleaned_data
return type(
str("ChecklistApprovalTaskForm"),
(form_class,),
dict(
checklist=self.get_checklist_field(),
clean=clean,
field_order=["checklist", "comment"],
),
)
return form_class
# ... @classmethod etc
Before you continue: Check that when you click the Approve step on a Page with this Task, you now see a list of checklist items and it is required (or not, based on the data saved on the original Task type).
Step 4 - Enhance the Checklist Form
- Now, we will modify the
help_text
,validators
and thewidget
of our field generated in the methodget_checklist_field
. - The goals here are to ensure that, when the checklist is required, we show and actually validate against all items being ticked.
- I find all of this pretty amazing, how powerful it is to build up blocks of built-in functions and logic into what we want without really writing much code ourselves, just calling the right methods/functions/classes.
Code models.py
-
help_text
needs to be a dynamic value based on the required value we set up in the previous step. - If the checklist is required, we will leverage the built-in
MinLengthValidator
, while this is usually used to validate string length it can be used just the same for validating the length of the list of values provided to the field (in our case it will be a list of indices). We will also pass in a custommessage
kwarg to this validator so it makes sense to the user. - For the
widget
we will use the built-inCheckboxSelectMultiple
, but note in the docs that even if we setrequired
on the field the checkbox will not actually put required on the inputs HTML attributes so we need to pass in an extraattrs
to the widget to handle this. - Note: the only parts added below are the
min_length_validator
,help_text
,validators
andwidget
lines.
from django import forms
from django.core.validators import MinLengthValidator
from django.db import models
from django.utils.translation import gettext_lazy as _, ngettext_lazy
# ... other imports
class ChecklistApprovalTask(GroupApprovalTask):
# ... checklist etc, from above step
def get_checklist_field(self):
"""
Prepare a form field that is a list of checklist boxes for each line in the
checklist on this Task instance.
"""
required = self.is_checklist_required
field = dict(label=_("Checklist"), required=required)
field["choices"] = [
(index, label) for index, label in enumerate(self.checklist.splitlines())
]
min_length_validator = MinLengthValidator(
len(field["choices"]),
message=ngettext_lazy(
"Only %(show_value)d item has been checked (all %(limit_value)d are required).",
"Only %(show_value)d items have been checked (all %(limit_value)d are required).",
"show_value",
),
)
field["help_text"] = (
_("Please check all items.") if required else _("Please review all items.")
)
field["validators"] = [min_length_validator] if required else []
field["widget"] = forms.CheckboxSelectMultiple(
# required attr needed as not rendered by default (even if field required)
# https://docs.djangoproject.com/en/3.2/ref/forms/widgets/#django.forms.CheckboxSelectMultiple
attrs={"required": "required"} if required else {}
)
return forms.MultipleChoiceField(**field)
Before you continue: Check that the Approve modal form now contains actual checkboxes for each checklist item and that validation works as expected.
Final Implementation
- You should now have a fully functional new Task type where your CMS admin users can create for specific workflows that leverage existing user group approval logic along with custom checklists on a per Task instance.
- You can view the steps on the tutorial/workflow-checklist branch or the final code in the models.py file.
- I think it is really important to now require all the checklist items by default, but some will insist that this is required no doubt, but checklists work better as reminders and not rigid rules in this context.
- Please comment in response if you think something is missing here or have some other useful content relating to Wagtail Workflows that might be helpful.
- Thanks to my brother Sam for proofing and to Danielle MacInnes for the cover photo.
Future Improvements Ideas
- Adding a
description
field to theTask
so that users can put content above the checklist that explains part of a process. - Storing the checklist values (or maybe how many were checked), if that is useful for reporting, remember that the Page history will contain which users Approved the step and that may be enough as it is.
- Other Form content that applies to an Approval (or some other step like Rejection) and needs to be dynamic, maybe even requiring a comment when the workflow is Rejected.
- If you end up building one of these, even as a Github Gist, be sure to put a link to it in the comments.
Posted on September 22, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.