custom_tasks.rst 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. =====================
  2. Adding new Task types
  3. =====================
  4. The Workflow system allows users to create tasks, which represent stages of moderation.
  5. Wagtail provides one built in task type: ``GroupApprovalTask``, which allows any user in specific groups to approve or reject moderation.
  6. However, it is possible to add your own task types in code. Instances of your custom task can then be created in the ``Tasks`` section of the Wagtail Admin.
  7. Task models
  8. ~~~~~~~~~~~
  9. 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.
  10. .. code-block:: python
  11. # <project>/models.py
  12. from wagtail.core.models import Task
  13. class UserApprovalTask(Task):
  14. pass
  15. Subclassed Tasks follow the same approach as Pages: they are concrete models, with the specific subclass instance accessible by calling ``Task.specific()``.
  16. You can now add any custom fields. To make these editable in the admin, add the names of the fields into the ``admin_form_fields`` attribute:
  17. For example:
  18. .. code-block:: python
  19. # <project>/models.py
  20. from django.conf import settings
  21. from django.db import models
  22. from wagtail.core.models import Task
  23. class UserApprovalTask(Task):
  24. user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=False)
  25. admin_form_fields = Task.admin_form_fields + ['user']
  26. 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 -
  27. can be added to ``admin_form_readonly_on_edit_fields``. For example:
  28. .. code-block:: python
  29. # <project>/models.py
  30. from django.conf import settings
  31. from django.db import models
  32. from wagtail.core.models import Task
  33. class UserApprovalTask(Task):
  34. user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=False)
  35. admin_form_fields = Task.admin_form_fields + ['user']
  36. # prevent editing of ``user`` after the task is created
  37. # by default, this attribute contains the 'name' field to prevent tasks from being renamed
  38. admin_form_readonly_on_edit_fields = Task.admin_form_readonly_on_edit_fields + ['user']
  39. Wagtail will choose a default form widget to use based on the field type. But you can override the form widget using the ``admin_form_widgets`` attribute:
  40. .. code-block:: python
  41. # <project>/models.py
  42. from django.conf import settings
  43. from django.db import models
  44. from wagtail.core.models import Task
  45. from .widgets import CustomUserChooserWidget
  46. class UserApprovalTask(Task):
  47. user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=False)
  48. admin_form_fields = Task.admin_form_fields + ['user']
  49. admin_form_widgets = {
  50. 'user': CustomUserChooserWidget,
  51. }
  52. Custom TaskState models
  53. ~~~~~~~~~~~~~~~~~~~~~~~
  54. You might also need to store custom state information for the task: for example, a rating left by an approving user.
  55. Normally, this is done on an instance of ``TaskState``, which is created when a page starts the task. However, this can
  56. also be subclassed equivalently to ``Task``:
  57. .. code-block:: python
  58. # <project>/models.py
  59. from wagtail.core.models import TaskState
  60. class UserApprovalTaskState(TaskState):
  61. pass
  62. Your custom task must then be instructed to generate an instance of your custom task state on start instead of a plain ``TaskState`` instance:
  63. .. code-block:: python
  64. # <project>/models.py
  65. from django.conf import settings
  66. from django.db import models
  67. from wagtail.core.models import Task, TaskState
  68. class UserApprovalTaskState(TaskState):
  69. pass
  70. class UserApprovalTask(Task):
  71. user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=False)
  72. admin_form_fields = Task.admin_form_fields + ['user']
  73. task_state_class = UserApprovalTaskState
  74. Customising behaviour
  75. ~~~~~~~~~~~~~~~~~~~~~
  76. Both ``Task`` and ``TaskState`` have a number of methods which can be overridden to implement custom behaviour. Here are some of the most useful:
  77. ``Task.user_can_access_editor(page, user)``, ``Task.user_can_lock(page, user)``, ``Task.user_can_unlock(page, user)``:
  78. These methods determine if users usually without permissions can access the editor, lock, or unlock the page, by returning True or False.
  79. Note that returning ``False`` will not prevent users who would normally be able to perform those actions. For example, for our ``UserApprovalTask``:
  80. .. code-block:: python
  81. def user_can_access_editor(self, page, user):
  82. return user == self.user
  83. ``Task.page_locked_for_user(page, user)``:
  84. This returns ``True`` if the page should be locked and uneditable by the user. It is
  85. used by `GroupApprovalTask` to lock the page to any users not in the approval group.
  86. .. code-block:: python
  87. def page_locked_for_user(self, page, user):
  88. return user != self.user
  89. ``Task.get_actions(page, user)``:
  90. This returns a list of ``(action_name, action_verbose_name, action_requires_additional_data_from_modal)`` tuples, corresponding to the actions available for the task in the edit view menu.
  91. ``action_requires_additional_data_from_modal`` should be a boolean, returning ``True`` if choosing the action should open a modal for
  92. additional data input - for example, entering a comment.
  93. For example:
  94. .. code-block:: python
  95. def get_actions(self, page, user):
  96. if user == self.user:
  97. return [
  98. ('approve', "Approve", False),
  99. ('reject', "Reject", False),
  100. ('cancel', "Cancel", False),
  101. ]
  102. else:
  103. return []
  104. ``Task.get_form_for_action(action)``:
  105. Returns a form to be used for additional data input for the given action modal. By default,
  106. returns ``TaskStateCommentForm``, with a single comment field. The form data returned in
  107. ``form.cleaned_data`` must be fully serializable as JSON.
  108. ``Task.get_template_for_action(action)``:
  109. Returns the name of a custom template to be used in rendering the data entry modal for that action.
  110. ``Task.on_action(task_state, user, action_name, **kwargs)``:
  111. 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,
  112. it calls ``approve`` and ``reject`` methods on the task state when the corresponding action names are passed through. Any additional data entered in a modal
  113. (see ``get_form_for_action`` and ``get_actions``) is supplied as kwargs.
  114. For example, let's say we wanted to add an additional option: cancelling the entire workflow:
  115. .. code-block:: python
  116. def on_action(self, task_state, user, action_name):
  117. if action_name == 'cancel':
  118. return task_state.workflow_state.cancel(user=user)
  119. else:
  120. return super().on_action(task_state, user, workflow_state)
  121. ``Task.get_task_states_user_can_moderate(user, **kwargs)``:
  122. 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.
  123. For example:
  124. .. code-block:: python
  125. def get_task_states_user_can_moderate(self, user, **kwargs):
  126. if user == self.user:
  127. # get all task states linked to the (base class of) current task
  128. return TaskState.objects.filter(status=TaskState.STATUS_IN_PROGRESS, task=self.task_ptr)
  129. else:
  130. return TaskState.objects.none()
  131. ``Task.get_description()``
  132. A class method that returns the human-readable description for the task.
  133. For example:
  134. .. code-block:: python
  135. @classmethod
  136. def get_description(cls):
  137. return _("Members of the chosen Wagtail Groups can approve this task")
  138. Adding notifications
  139. ~~~~~~~~~~~~~~~~~~~~
  140. Wagtail's notifications are sent by ``wagtail.admin.mail.Notifier`` subclasses: callables intended to be connected to a signal.
  141. By default, email notifications are sent upon workflow submission, approval and rejection, and upon submission to a group approval task.
  142. As an example, we'll add email notifications for when our new task is started.
  143. .. code-block:: python
  144. # <project>/mail.py
  145. from wagtail.admin.mail import EmailNotificationMixin, Notifier
  146. from wagtail.core.models import TaskState
  147. from .models import UserApprovalTaskState
  148. class BaseUserApprovalTaskStateEmailNotifier(EmailNotificationMixin, Notifier):
  149. """A base notifier to send updates for UserApprovalTask events"""
  150. def __init__(self):
  151. # Allow UserApprovalTaskState and TaskState to send notifications
  152. super().__init__((UserApprovalTaskState, TaskState))
  153. def can_handle(self, instance, **kwargs):
  154. if super().can_handle(instance, **kwargs) and isinstance(instance.task.specific, UserApprovalTask):
  155. # Don't send notifications if a Task has been cancelled and then resumed - ie page was updated to a new revision
  156. return not TaskState.objects.filter(workflow_state=instance.workflow_state, task=instance.task, status=TaskState.STATUS_CANCELLED).exists()
  157. return False
  158. def get_context(self, task_state, **kwargs):
  159. context = super().get_context(task_state, **kwargs)
  160. context['page'] = task_state.workflow_state.page
  161. context['task'] = task_state.task.specific
  162. return context
  163. def get_recipient_users(self, task_state, **kwargs):
  164. # Send emails to the user assigned to the task
  165. approving_user = task_state.task.specific.user
  166. recipients = {approving_user}
  167. return recipients
  168. class UserApprovalTaskStateSubmissionEmailNotifier(BaseUserApprovalTaskStateEmailNotifier):
  169. """A notifier to send updates for UserApprovalTask submission events"""
  170. notification = 'submitted'
  171. Similarly, you could define notifier subclasses for approval and rejection notifications.
  172. Next, you need to instantiate the notifier, and connect it to the ``task_submitted`` signal.
  173. .. code-block:: python
  174. # <project>/signal_handlers.py
  175. from wagtail.core.signals import task_submitted
  176. from .mail import UserApprovalTaskStateSubmissionEmailNotifier
  177. task_submission_email_notifier = UserApprovalTaskStateSubmissionEmailNotifier()
  178. def register_signal_handlers():
  179. task_submitted.connect(user_approval_task_submission_email_notifier, dispatch_uid='user_approval_task_submitted_email_notification')
  180. ``register_signal_handlers()`` should then be run on loading the app: for example, by adding it to the ``ready()`` method in your ``AppConfig``
  181. (and making sure this config is set as ``default_app_config`` in ``<project>/__init__.py``).
  182. .. code-block:: python
  183. # <project>/apps.py
  184. from django.apps import AppConfig
  185. class MyAppConfig(AppConfig):
  186. name = 'myappname'
  187. label = 'myapplabel'
  188. verbose_name = 'My verbose app name'
  189. def ready(self):
  190. from .signal_handlers import register_signal_handlers
  191. register_signal_handlers()