diff --git a/docs/advanced_topics/custom_tasks.rst b/docs/advanced_topics/custom_tasks.rst index 967955759b..d189f8e2b0 100644 --- a/docs/advanced_topics/custom_tasks.rst +++ b/docs/advanced_topics/custom_tasks.rst @@ -1,3 +1,267 @@ -============================ -How to add custom Task types -============================ +========================= +How to add new Task types +========================= + +The Workflow system introduced in Wagtail 2.9 allows users to create tasks, which represent stages of moderation. + +Wagtail provides one built in task type: ``GroupApprovalTask``, which allows any user in specific groups to approve or reject moderation. + +However, it is possible to add your task types in code. Instances of your custom task can then be created in the ``Tasks`` section of the Wagtail Admin. + +Task models +~~~~~~~~~~~ + +All custom tasks must be models inheriting from ``wagtailcore.Task``. In this set of examples, we'll set up a task which can be approved by only one specific user. + +.. code-block:: python + + # /models.py + + from wagtail.core.models import Task + + + class UserApprovalTask(Task): + pass + + +Subclassed Tasks follow the same approach as Pages: they are concrete models, with the specific subclass instance accessible by calling ``Task.specific()``. + +You can now add any custom fields. To make these editable in the admin, they must also be added as panels. + +For example: + +.. code-block:: python + + # /models.py + + from django.conf import settings + from django.db import models + from wagtail.admin.edit_handlers import FieldPanel + from wagtail.core.models import Task + + + class UserApprovalTask(Task): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=False) + + panels = Task.panels + [FieldPanel('user')] + + +Any fields that shouldn't be edited after task creation - for example, anything that would fundamentally change the meaning of the task in any history logs - +can be added to ``exlude_on_edit``. For example: + +.. code-block:: python + + # /models.py + + from django.conf import settings + from django.db import models + from wagtail.admin.edit_handlers import FieldPanel + from wagtail.core.models import Task + + + class UserApprovalTask(Task): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=False) + + panels = Task.panels + [FieldPanel('user')] + + # prevent editing of ``user`` after the task is created + exclude_on_edit = {'user'} + + +Custom TaskState models +~~~~~~~~~~~~~~~~~~~~~~~ + +You might also need to store custom state information for the task. Normally, this is done on an instance of ``TaskState``, which is created when a page starts +the task. However, this can also be subclassed equivalently to ``Task``: + +.. code-block:: python + + # /models.py + + from wagtail.core.models import TaskState + + + class UserApprovalTaskState(TaskState): + pass + +Your custom task must then be instructed to generate an instance of your custom task state on start instead of a plain ``TaskState`` instance: + +.. code-block:: python + + # /models.py + + from django.conf import settings + from django.db import models + from wagtail.admin.edit_handlers import FieldPanel + from wagtail.core.models import Task, TaskState + + + class UserApprovalTaskState(TaskState): + pass + + + class UserApprovalTask(Task): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=False) + + panels = Task.panels + [FieldPanel('user')] + + # prevent editing of ``user`` after the task is created + exclude_on_edit = {'user'} + + +Customising behaviour +~~~~~~~~~~~~~~~~~~~~~ + +Both ``Task`` and ``TaskState`` have a number of methods which can be overridden to implement custom behaviour. Here are some of the most useful: + +``Task.user_can_access_editor(page, user)``, ``Task.user_can_lock(page, user)``, ``Task.user_can_unlock(page, user)``: + +These methods determine if users usually without permissions can access the editor, lock, or unlock the page, by returning True or False. +Note that returning ``False`` will not prevent users who would normally be able to perform those actions. For example, for our ``UserApprovalTask``: + +.. code-block:: python + + def user_can_access_editor(self, page, user): + return user == self.user + +``Task.get_actions(page, user)``: + +This returns a list of ``(action_name, action_verbose_name)`` tuples, corresponding to the actions available for the task in the edit view menu. + +For example: + +.. code-block:: python + + def get_actions(self, page, user): + if user == self.user: + return [ + ('approve', "Approve"), + ('reject', "Reject"), + ('cancel', "Cancel"), + ] + else: + return [] + +``Task.on_action(task_state, user, action_name)``: + +This performs the actions specified in ``Task.get_actions(page, user)``: it is passed an action name, eg ``approve``, and the relevant task state. By default, +it calls ``approve`` and ``reject`` methods on the task state when the corresponding action names are passed through. + +For example, let's say we wanted to add an additional option: cancelling the entire workflow: + +.. code-block:: python + + def on_action(self, task_state, user, action_name): + if action_name == 'cancel': + return task_state.workflow_state.cancel(user=user) + else: + return super().on_action(task_state, user, workflow_state) + +``Task.get_task_states_user_can_moderate(user, **kwargs)``: + +This returns a QuerySet of ``TaskStates`` (or subclasses) the given user can moderate - this is currently used to select pages to display on the user's dashboard. + +For example: + +.. code-block:: python + + def get_task_states_user_can_moderate(self, user, **kwargs): + if user == self.user: + # get all task states linked to the (base class of) current task + return TaskState.objects.filter(status=TaskState.STATUS_IN_PROGRESS, task=self.task_ptr) + else: + return TaskState.objects.none() + + +Adding notifications +~~~~~~~~~~~~~~~~~~~~ + +Wagtail's notifications are sent by ``wagtail.admin.mail.Notifier`` subclasses: callables intended to be connected to a signal. + +By default, email notifications are sent upon workflow submission, approval and rejection, and upon submission to a group approval task. + +As an example, we'll add email notifications for when our new task is started. + +.. code-block:: python + + # /mail.py + + from wagtail.admin.mail import EmailNotifier + from wagtail.core.models import TaskState + + from .models import UserApprovalTaskState + + + class BaseUserApprovalTaskStateEmailNotifier(EmailNotifier): + """A base EmailNotifier to send updates for UserApprovalTask events""" + + def __init__(self): + # Allow UserApprovalTaskState and TaskState to send notifications + super().__init__((UserApprovalTaskState, TaskState)) + + def can_handle(self, instance, **kwargs): + if super().can_handle(instance, **kwargs) and isinstance(instance.task.specific, UserApprovalTask): + # Don't send notifications if a Task has been cancelled and then resumed - ie page was updated to a new revision + return not TaskState.objects.filter(workflow_state=instance.workflow_state, task=instance.task, status=TaskState.STATUS_CANCELLED).exists() + return False + + def get_context(self, task_state, **kwargs): + context = super().get_context(task_state, **kwargs) + context['page'] = task_state.workflow_state.page + context['task'] = task_state.task.specific + return context + + def get_recipient_users(self, task_state, **kwargs): + + # Send emails to the user assigned to the task + approving_user = task_state.task.specific.user + + recipients = {approving_user} + + return recipients + + def get_template_base_prefix(self, instance, **kwargs): + # Get the template base prefix for TaskState, so use the ``wagtailadmin/notifications/task_state_`` set of notification templates + return super().get_template_base_prefix(self, instance.task_state_ptr, **kwargs) + + + class UserApprovalTaskStateSubmissionEmailNotifier(BaseUserApprovalTaskStateEmailNotifier): + """An EmailNotifier to send updates for UserApprovalTask submission events""" + + notification = 'submitted' + + +Similarly, you could define notifier subclasses for approval and rejection notifications. + +Next, you need to instantiate the notifier, and connect it to the ``task_submitted`` signal. + +.. code-block:: python + + # /signal_handlers.py + + from wagtail.core.signals import task_submitted + from .mail import UserApprovalTaskStateSubmissionEmailNotifier + + + task_submission_email_notifier = UserApprovalTaskStateSubmissionEmailNotifier() + + def register_signal_handlers(): + task_submitted.connect(user_approval_task_submission_email_notifier, dispatch_uid='user_approval_task_submitted_email_notification') + +``register_signal_handlers()`` should then be run on loading the app: for example, by adding it to the ``ready()`` method in your ``AppConfig`` +(and making sure you set this config is set as ``default_app_config`` in ``/__init__.py``). + +.. code-block:: python + + # /apps.py + from django.apps import AppConfig + + + class MyAppConfig(AppConfig): + name = 'myappname' + label = 'myapplabel' + verbose_name = 'My verbose app name' + + def ready(self): + from .signal_handlers import register_signal_handlers + register_signal_handlers() \ No newline at end of file