31
Adding Tasks with a Checklist to Wagtail Workflows
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.
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.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.
- Django 3.2
- Wagtail 2.14
Task
model is and what we want to let the user fill out.Workflow
area has a few key models, the main being a Workflow
and a Task
.Task
in the Wagtail admin, the user is presented with what kind of Task
to use (similar to when creating a new Page
), this UI only shows if there are more than one kind of Task
models available (Wagtail will search through your models and find all those that are subclasses of the core Task
model).Task
model we are creating contains the fields that the user enters for multiple Task
s of that 'kind', which are then mapped to one or more user created Workflow
s.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-line TextField
where each line becomes a checklist item.Task
will require ALL checklist items to be ticked when submitting, this way the checklists can be a 'suggestion' or a 'requirement' on a per Task
instance basis.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.GroupApprovalTask
.GroupApprovalTask
is that our ChecklistApprovalTask
is very similar, we want to assign a user group that can approve/reject as a Task
but we just want to allow the approve step to show extra content in the approval screen.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.ChecklistApprovalTask
that extends GroupApprovalTask
.checklist
a TextField
which will be used to generate the checklist items (per line), and a is_checklist_required
BooleanField
which will be ticked to force each checklist item to be ticked.Page
panels
, we can use the admin_form_fields
class attribute to define a List of fields that will be shown when a user creates/edits this Task
.get_description
class method and the meta verbose names to provide a user-facing description & name of this Task
type, plus we want to override the one that comes with the GroupApprovalTask
class.django-admin makemigrations
and then django-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'.
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.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.Task
has a method get_actions
which will return a list of (action_name, action_verbose_name, action_requires_additional_data_from_modal)
tuples.CrosscheckApprovalTask
built above, create a new method get_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.

GroupApprovalTask
makes, this form needs to have a MultipleChoiceField
where each of the choices
is a line in our Task
model's checklist
field.Task
model's is_checklist_required
saved value.Task
form we can add a get_form_for_action
method and when the action is 'approve'
we can provide a custom Form.TaskState
(the model that reflects each state for a Task
as it is processed).get_form_for_action
on the GroupApprovalTask
we can see that it returns a TaskStateCommentForm
which extends a Django Form
with one field, comments
.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).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 the TaskStateCommentForm
above.clean
method that will remove any checklist values that are submitted (as we do not want to save these).checklist
, which we will pull out to a new class method get_checklist_field
which can return a forms.MultipleChoiceField
that has dynamic values for required
and the choices
based on the Task
instance. Note: The default widget used for this field is SelectMultiple
which is a bit cluncky, but we will enhance that in the next step.checklist
field shows before the comment
field, for that we can dynamically add a field_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).

help_text
, validators
and the widget
of our field generated in the method get_checklist_field
.help_text
needs to be a dynamic value based on the required value we set up in the previous step.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 custom message
kwarg to this validator so it makes sense to the user.widget
we will use the built-in CheckboxSelectMultiple
, but note in the docs that even if we set required
on the field the checkbox will not actually put required on the inputs HTML attributes so we need to pass in an extra attrs
to the widget to handle this.min_length_validator
, help_text
, validators
and widget
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.

description
field to the Task
so that users can put content above the checklist that explains part of a process.31