1349 lines
46 KiB
Python
1349 lines
46 KiB
Python
|
|
from django import forms
|
||
|
|
from django.conf import settings
|
||
|
|
from django.contrib.auth.models import Group
|
||
|
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||
|
|
from django.contrib.contenttypes.models import ContentType
|
||
|
|
from django.core import checks
|
||
|
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||
|
|
from django.db import models, transaction
|
||
|
|
from django.db.models import Q
|
||
|
|
from django.db.models.expressions import OuterRef, Subquery
|
||
|
|
from django.utils import timezone
|
||
|
|
from django.utils.functional import cached_property
|
||
|
|
from django.utils.module_loading import import_string
|
||
|
|
from django.utils.text import capfirst
|
||
|
|
from django.utils.translation import gettext_lazy as _
|
||
|
|
from modelcluster.fields import ParentalKey
|
||
|
|
from modelcluster.models import (
|
||
|
|
ClusterableModel,
|
||
|
|
)
|
||
|
|
|
||
|
|
from wagtail.coreutils import get_content_type_label
|
||
|
|
from wagtail.forms import TaskStateCommentForm
|
||
|
|
from wagtail.locks import WorkflowLock
|
||
|
|
from wagtail.log_actions import log
|
||
|
|
from wagtail.query import SpecificQuerySetMixin
|
||
|
|
from wagtail.signals import (
|
||
|
|
task_approved,
|
||
|
|
task_cancelled,
|
||
|
|
task_rejected,
|
||
|
|
task_submitted,
|
||
|
|
workflow_approved,
|
||
|
|
workflow_cancelled,
|
||
|
|
workflow_rejected,
|
||
|
|
workflow_submitted,
|
||
|
|
)
|
||
|
|
|
||
|
|
from .copying import _copy, _copy_m2m_relations
|
||
|
|
from .draft_state import DraftStateMixin
|
||
|
|
from .locking import LockableMixin
|
||
|
|
from .orderable import Orderable
|
||
|
|
from .revisions import Revision, RevisionMixin
|
||
|
|
from .specific import SpecificMixin
|
||
|
|
|
||
|
|
|
||
|
|
class WorkflowContentType(models.Model):
|
||
|
|
content_type = models.OneToOneField(
|
||
|
|
ContentType,
|
||
|
|
related_name="wagtail_workflow_content_type",
|
||
|
|
verbose_name=_("content type"),
|
||
|
|
on_delete=models.CASCADE,
|
||
|
|
primary_key=True,
|
||
|
|
unique=True,
|
||
|
|
)
|
||
|
|
workflow = models.ForeignKey(
|
||
|
|
"Workflow",
|
||
|
|
related_name="workflow_content_types",
|
||
|
|
verbose_name=_("workflow"),
|
||
|
|
on_delete=models.CASCADE,
|
||
|
|
)
|
||
|
|
|
||
|
|
def __str__(self):
|
||
|
|
content_type_label = get_content_type_label(self.content_type)
|
||
|
|
return f"WorkflowContentType: {content_type_label} - {self.workflow}"
|
||
|
|
|
||
|
|
|
||
|
|
class WorkflowStateQuerySet(models.QuerySet):
|
||
|
|
def active(self):
|
||
|
|
"""
|
||
|
|
Filters to only ``STATUS_IN_PROGRESS`` and ``STATUS_NEEDS_CHANGES`` WorkflowStates.
|
||
|
|
"""
|
||
|
|
return self.filter(
|
||
|
|
Q(status=WorkflowState.STATUS_IN_PROGRESS)
|
||
|
|
| Q(status=WorkflowState.STATUS_NEEDS_CHANGES)
|
||
|
|
)
|
||
|
|
|
||
|
|
def for_instance(self, instance):
|
||
|
|
"""
|
||
|
|
Filters to only WorkflowStates for the given instance.
|
||
|
|
"""
|
||
|
|
try:
|
||
|
|
# Use RevisionMixin.get_base_content_type() if available
|
||
|
|
return self.filter(
|
||
|
|
base_content_type=instance.get_base_content_type(),
|
||
|
|
object_id=str(instance.pk),
|
||
|
|
)
|
||
|
|
except AttributeError:
|
||
|
|
# Fallback to ContentType for the model
|
||
|
|
return self.filter(
|
||
|
|
content_type=ContentType.objects.get_for_model(
|
||
|
|
instance, for_concrete_model=False
|
||
|
|
),
|
||
|
|
object_id=str(instance.pk),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
WorkflowStateManager = models.Manager.from_queryset(WorkflowStateQuerySet)
|
||
|
|
|
||
|
|
|
||
|
|
class WorkflowState(models.Model):
|
||
|
|
"""Tracks the status of a started Workflow on an object."""
|
||
|
|
|
||
|
|
STATUS_IN_PROGRESS = "in_progress"
|
||
|
|
STATUS_APPROVED = "approved"
|
||
|
|
STATUS_NEEDS_CHANGES = "needs_changes"
|
||
|
|
STATUS_CANCELLED = "cancelled"
|
||
|
|
STATUS_CHOICES = (
|
||
|
|
(STATUS_IN_PROGRESS, _("In progress")),
|
||
|
|
(STATUS_APPROVED, _("Approved")),
|
||
|
|
(STATUS_NEEDS_CHANGES, _("Needs changes")),
|
||
|
|
(STATUS_CANCELLED, _("Cancelled")),
|
||
|
|
)
|
||
|
|
|
||
|
|
content_type = models.ForeignKey(
|
||
|
|
ContentType, on_delete=models.CASCADE, related_name="+"
|
||
|
|
)
|
||
|
|
base_content_type = models.ForeignKey(
|
||
|
|
ContentType, on_delete=models.CASCADE, related_name="+"
|
||
|
|
)
|
||
|
|
object_id = models.CharField(max_length=255, verbose_name=_("object id"))
|
||
|
|
|
||
|
|
content_object = GenericForeignKey(
|
||
|
|
"base_content_type", "object_id", for_concrete_model=False
|
||
|
|
)
|
||
|
|
content_object.wagtail_reference_index_ignore = True
|
||
|
|
|
||
|
|
workflow = models.ForeignKey(
|
||
|
|
"Workflow",
|
||
|
|
on_delete=models.CASCADE,
|
||
|
|
verbose_name=_("workflow"),
|
||
|
|
related_name="workflow_states",
|
||
|
|
)
|
||
|
|
status = models.fields.CharField(
|
||
|
|
choices=STATUS_CHOICES,
|
||
|
|
verbose_name=_("status"),
|
||
|
|
max_length=50,
|
||
|
|
default=STATUS_IN_PROGRESS,
|
||
|
|
)
|
||
|
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("created at"))
|
||
|
|
requested_by = models.ForeignKey(
|
||
|
|
settings.AUTH_USER_MODEL,
|
||
|
|
verbose_name=_("requested by"),
|
||
|
|
null=True,
|
||
|
|
blank=True,
|
||
|
|
editable=True,
|
||
|
|
on_delete=models.SET_NULL,
|
||
|
|
related_name="requested_workflows",
|
||
|
|
)
|
||
|
|
current_task_state = models.OneToOneField(
|
||
|
|
"TaskState",
|
||
|
|
on_delete=models.SET_NULL,
|
||
|
|
null=True,
|
||
|
|
blank=True,
|
||
|
|
verbose_name=_("current task state"),
|
||
|
|
)
|
||
|
|
|
||
|
|
# allows a custom function to be called on finishing the Workflow successfully.
|
||
|
|
on_finish = import_string(
|
||
|
|
getattr(
|
||
|
|
settings,
|
||
|
|
"WAGTAIL_FINISH_WORKFLOW_ACTION",
|
||
|
|
"wagtail.workflows.publish_workflow_state",
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
objects = WorkflowStateManager()
|
||
|
|
|
||
|
|
def clean(self):
|
||
|
|
super().clean()
|
||
|
|
|
||
|
|
if self.status in (self.STATUS_IN_PROGRESS, self.STATUS_NEEDS_CHANGES):
|
||
|
|
# The unique constraint is conditional, and so not supported on the MySQL backend - so an additional check is done here
|
||
|
|
if (
|
||
|
|
WorkflowState.objects.active()
|
||
|
|
.filter(
|
||
|
|
base_content_type_id=self.base_content_type_id,
|
||
|
|
object_id=self.object_id,
|
||
|
|
)
|
||
|
|
.exclude(pk=self.pk)
|
||
|
|
.exists()
|
||
|
|
):
|
||
|
|
raise ValidationError(
|
||
|
|
_(
|
||
|
|
"There may only be one in progress or needs changes workflow state per page/snippet."
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
def save(self, *args, **kwargs):
|
||
|
|
self.full_clean()
|
||
|
|
return super().save(*args, **kwargs)
|
||
|
|
|
||
|
|
def __str__(self):
|
||
|
|
return _(
|
||
|
|
"Workflow '%(workflow_name)s' on %(model_name)s '%(title)s': %(status)s"
|
||
|
|
) % {
|
||
|
|
"workflow_name": self.workflow,
|
||
|
|
"model_name": self.content_object._meta.verbose_name,
|
||
|
|
"title": self.content_object,
|
||
|
|
"status": self.status,
|
||
|
|
}
|
||
|
|
|
||
|
|
def resume(self, user=None):
|
||
|
|
"""Put a STATUS_NEEDS_CHANGES workflow state back into STATUS_IN_PROGRESS, and restart the current task"""
|
||
|
|
from wagtail.models import Page
|
||
|
|
|
||
|
|
if self.status != self.STATUS_NEEDS_CHANGES:
|
||
|
|
raise PermissionDenied
|
||
|
|
revision = self.current_task_state.revision
|
||
|
|
current_task_state = self.current_task_state
|
||
|
|
self.current_task_state = None
|
||
|
|
self.status = self.STATUS_IN_PROGRESS
|
||
|
|
self.save()
|
||
|
|
|
||
|
|
instance = self.content_object
|
||
|
|
if isinstance(instance, Page):
|
||
|
|
instance = self.content_object.specific
|
||
|
|
|
||
|
|
log(
|
||
|
|
instance=instance,
|
||
|
|
action="wagtail.workflow.resume",
|
||
|
|
data={
|
||
|
|
"workflow": {
|
||
|
|
"id": self.workflow_id,
|
||
|
|
"title": self.workflow.name,
|
||
|
|
"status": self.status,
|
||
|
|
"task_state_id": current_task_state.id,
|
||
|
|
"task": {
|
||
|
|
"id": current_task_state.task.id,
|
||
|
|
"title": current_task_state.task.name,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
},
|
||
|
|
revision=revision,
|
||
|
|
user=user,
|
||
|
|
)
|
||
|
|
return self.update(user=user, next_task=current_task_state.task)
|
||
|
|
|
||
|
|
def user_can_cancel(self, user):
|
||
|
|
if (
|
||
|
|
isinstance(self.content_object, LockableMixin)
|
||
|
|
and self.content_object.locked
|
||
|
|
and self.content_object.locked_by != user
|
||
|
|
):
|
||
|
|
return False
|
||
|
|
return (
|
||
|
|
user == self.requested_by
|
||
|
|
or user == getattr(self.content_object, "owner", None)
|
||
|
|
or (
|
||
|
|
self.current_task_state
|
||
|
|
and self.current_task_state.status
|
||
|
|
== self.current_task_state.STATUS_IN_PROGRESS
|
||
|
|
and "approve"
|
||
|
|
in [
|
||
|
|
action[0]
|
||
|
|
for action in self.current_task_state.task.get_actions(
|
||
|
|
self.content_object, user
|
||
|
|
)
|
||
|
|
]
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
def update(self, user=None, next_task=None):
|
||
|
|
"""
|
||
|
|
Checks the status of the current task, and progresses (or ends) the workflow if appropriate.
|
||
|
|
If the workflow progresses, next_task will be used to start a specific task next if provided.
|
||
|
|
"""
|
||
|
|
if self.status != self.STATUS_IN_PROGRESS:
|
||
|
|
# Updating a completed or cancelled workflow should have no effect
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
current_status = self.current_task_state.status
|
||
|
|
except AttributeError:
|
||
|
|
current_status = None
|
||
|
|
if current_status == TaskState.STATUS_REJECTED:
|
||
|
|
self.status = self.STATUS_NEEDS_CHANGES
|
||
|
|
self.save()
|
||
|
|
workflow_rejected.send(sender=self.__class__, instance=self, user=user)
|
||
|
|
else:
|
||
|
|
if not next_task:
|
||
|
|
next_task = self.get_next_task()
|
||
|
|
if next_task:
|
||
|
|
if (
|
||
|
|
(not self.current_task_state)
|
||
|
|
or self.current_task_state.status
|
||
|
|
!= self.current_task_state.STATUS_IN_PROGRESS
|
||
|
|
):
|
||
|
|
# if not on a task, or the next task to move to is not the current task (ie current task's status is
|
||
|
|
# not STATUS_IN_PROGRESS), move to the next task
|
||
|
|
self.current_task_state = next_task.specific.start(self, user=user)
|
||
|
|
self.save()
|
||
|
|
# if task has auto-approved, update the workflow again
|
||
|
|
if (
|
||
|
|
self.current_task_state.status
|
||
|
|
!= self.current_task_state.STATUS_IN_PROGRESS
|
||
|
|
):
|
||
|
|
self.update(user=user)
|
||
|
|
# otherwise, continue on the current task
|
||
|
|
else:
|
||
|
|
# if there is no uncompleted task, finish the workflow.
|
||
|
|
self.finish(user=user)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def successful_task_states(self):
|
||
|
|
successful_task_states = self.task_states.filter(
|
||
|
|
Q(status=TaskState.STATUS_APPROVED) | Q(status=TaskState.STATUS_SKIPPED)
|
||
|
|
)
|
||
|
|
if getattr(settings, "WAGTAIL_WORKFLOW_REQUIRE_REAPPROVAL_ON_EDIT", False):
|
||
|
|
successful_task_states = successful_task_states.filter(
|
||
|
|
revision=self.content_object.get_latest_revision()
|
||
|
|
)
|
||
|
|
|
||
|
|
return successful_task_states
|
||
|
|
|
||
|
|
def get_next_task(self):
|
||
|
|
"""
|
||
|
|
Returns the next active task, which has not been either approved or skipped.
|
||
|
|
"""
|
||
|
|
return (
|
||
|
|
Task.objects.filter(workflow_tasks__workflow=self.workflow, active=True)
|
||
|
|
.exclude(task_states__in=self.successful_task_states)
|
||
|
|
.order_by("workflow_tasks__sort_order")
|
||
|
|
.first()
|
||
|
|
)
|
||
|
|
|
||
|
|
def cancel(self, user=None):
|
||
|
|
"""Cancels the workflow state"""
|
||
|
|
from wagtail.models import Page
|
||
|
|
|
||
|
|
if self.status not in (self.STATUS_IN_PROGRESS, self.STATUS_NEEDS_CHANGES):
|
||
|
|
raise PermissionDenied
|
||
|
|
self.status = self.STATUS_CANCELLED
|
||
|
|
self.save()
|
||
|
|
|
||
|
|
instance = self.content_object
|
||
|
|
if isinstance(instance, Page):
|
||
|
|
instance = self.content_object.specific
|
||
|
|
|
||
|
|
log(
|
||
|
|
instance=instance,
|
||
|
|
action="wagtail.workflow.cancel",
|
||
|
|
data={
|
||
|
|
"workflow": {
|
||
|
|
"id": self.workflow_id,
|
||
|
|
"title": self.workflow.name,
|
||
|
|
"status": self.status,
|
||
|
|
"task_state_id": self.current_task_state.id,
|
||
|
|
"task": {
|
||
|
|
"id": self.current_task_state.task.id,
|
||
|
|
"title": self.current_task_state.task.name,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
},
|
||
|
|
revision=self.current_task_state.revision,
|
||
|
|
user=user,
|
||
|
|
)
|
||
|
|
|
||
|
|
for state in self.task_states.filter(status=TaskState.STATUS_IN_PROGRESS):
|
||
|
|
# Cancel all in progress task states
|
||
|
|
state.specific.cancel(user=user)
|
||
|
|
workflow_cancelled.send(sender=self.__class__, instance=self, user=user)
|
||
|
|
|
||
|
|
@transaction.atomic
|
||
|
|
def finish(self, user=None):
|
||
|
|
"""
|
||
|
|
Finishes a successful in progress workflow, marking it as approved and performing the ``on_finish`` action.
|
||
|
|
"""
|
||
|
|
if self.status != self.STATUS_IN_PROGRESS:
|
||
|
|
raise PermissionDenied
|
||
|
|
self.status = self.STATUS_APPROVED
|
||
|
|
self.save()
|
||
|
|
self.on_finish(user=user)
|
||
|
|
workflow_approved.send(sender=self.__class__, instance=self, user=user)
|
||
|
|
|
||
|
|
def copy_approved_task_states_to_revision(self, revision):
|
||
|
|
"""
|
||
|
|
Creates copies of previously approved task states with revision set to a different revision.
|
||
|
|
"""
|
||
|
|
approved_states = TaskState.objects.filter(
|
||
|
|
workflow_state=self, status=TaskState.STATUS_APPROVED
|
||
|
|
)
|
||
|
|
for state in approved_states:
|
||
|
|
state.copy(update_attrs={"revision": revision})
|
||
|
|
|
||
|
|
def revisions(self):
|
||
|
|
"""
|
||
|
|
Returns all revisions associated with task states linked to the current workflow state.
|
||
|
|
"""
|
||
|
|
return Revision.objects.filter(
|
||
|
|
base_content_type_id=self.base_content_type_id,
|
||
|
|
object_id=self.object_id,
|
||
|
|
id__in=self.task_states.values_list("revision_id", flat=True),
|
||
|
|
).defer("content")
|
||
|
|
|
||
|
|
def _get_applicable_task_states(self):
|
||
|
|
"""
|
||
|
|
Returns the set of task states whose status applies to the current revision.
|
||
|
|
"""
|
||
|
|
task_states = TaskState.objects.filter(workflow_state_id=self.id)
|
||
|
|
# If WAGTAIL_WORKFLOW_REQUIRE_REAPPROVAL_ON_EDIT=True, this is only task states created on the current revision
|
||
|
|
if getattr(settings, "WAGTAIL_WORKFLOW_REQUIRE_REAPPROVAL_ON_EDIT", False):
|
||
|
|
latest_revision_id = (
|
||
|
|
self.revisions()
|
||
|
|
.order_by("-created_at", "-id")
|
||
|
|
.values_list("id", flat=True)
|
||
|
|
.first()
|
||
|
|
)
|
||
|
|
task_states = task_states.filter(revision_id=latest_revision_id)
|
||
|
|
return task_states
|
||
|
|
|
||
|
|
def all_tasks_with_status(self):
|
||
|
|
"""
|
||
|
|
Returns a list of Task objects that are linked with this workflow state's
|
||
|
|
workflow. The status of that task in this workflow state is annotated in the
|
||
|
|
``.status`` field. And a displayable version of that status is annotated in the
|
||
|
|
``.status_display`` field.
|
||
|
|
|
||
|
|
This is different to querying TaskState as it also returns tasks that haven't
|
||
|
|
been started yet (so won't have a TaskState).
|
||
|
|
"""
|
||
|
|
# Get the set of task states whose status applies to the current revision
|
||
|
|
task_states = self._get_applicable_task_states()
|
||
|
|
|
||
|
|
tasks = list(
|
||
|
|
self.workflow.tasks.annotate(
|
||
|
|
status=Subquery(
|
||
|
|
task_states.filter(
|
||
|
|
task_id=OuterRef("id"),
|
||
|
|
)
|
||
|
|
.order_by("-started_at", "-id")
|
||
|
|
.values("status")[:1]
|
||
|
|
),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
# Manually annotate status_display
|
||
|
|
status_choices = dict(TaskState.STATUS_CHOICES)
|
||
|
|
for task in tasks:
|
||
|
|
task.status_display = status_choices.get(task.status, _("Not started"))
|
||
|
|
|
||
|
|
return tasks
|
||
|
|
|
||
|
|
def all_tasks_with_state(self):
|
||
|
|
"""
|
||
|
|
Returns a list of Task objects that are linked with this WorkflowState's
|
||
|
|
workflow, and have the latest task state.
|
||
|
|
|
||
|
|
In a "Submit for moderation -> reject at step 1 -> resubmit -> accept" workflow, this ensures
|
||
|
|
the task list reflects the accept, rather than the reject.
|
||
|
|
"""
|
||
|
|
task_states = self._get_applicable_task_states()
|
||
|
|
|
||
|
|
tasks = list(
|
||
|
|
self.workflow.tasks.annotate(
|
||
|
|
task_state_id=Subquery(
|
||
|
|
task_states.filter(
|
||
|
|
task_id=OuterRef("id"),
|
||
|
|
)
|
||
|
|
.order_by("-started_at", "-id")
|
||
|
|
.values("id")[:1]
|
||
|
|
),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
task_states = {task_state.id: task_state for task_state in task_states}
|
||
|
|
# Manually annotate task_state
|
||
|
|
for task in tasks:
|
||
|
|
task.task_state = task_states.get(task.task_state_id)
|
||
|
|
|
||
|
|
return tasks
|
||
|
|
|
||
|
|
@property
|
||
|
|
def is_active(self):
|
||
|
|
return self.status not in [self.STATUS_APPROVED, self.STATUS_CANCELLED]
|
||
|
|
|
||
|
|
@property
|
||
|
|
def is_at_final_task(self):
|
||
|
|
"""
|
||
|
|
Returns the next active task, which has not been either approved or skipped.
|
||
|
|
"""
|
||
|
|
last_task = (
|
||
|
|
Task.objects.filter(workflow_tasks__workflow=self.workflow, active=True)
|
||
|
|
.exclude(task_states__in=self.successful_task_states)
|
||
|
|
.order_by("workflow_tasks__sort_order")
|
||
|
|
.last()
|
||
|
|
)
|
||
|
|
|
||
|
|
return self.get_next_task() == last_task
|
||
|
|
|
||
|
|
class Meta:
|
||
|
|
verbose_name = _("Workflow state")
|
||
|
|
verbose_name_plural = _("Workflow states")
|
||
|
|
# prevent multiple STATUS_IN_PROGRESS/STATUS_NEEDS_CHANGES workflows for the same object. This is only supported by specific databases (e.g. Postgres, SQL Server), so is checked additionally on save.
|
||
|
|
constraints = [
|
||
|
|
models.UniqueConstraint(
|
||
|
|
fields=["base_content_type", "object_id"],
|
||
|
|
condition=Q(status__in=("in_progress", "needs_changes")),
|
||
|
|
name="unique_in_progress_workflow",
|
||
|
|
)
|
||
|
|
]
|
||
|
|
indexes = [
|
||
|
|
models.Index(
|
||
|
|
fields=["content_type", "object_id"],
|
||
|
|
name="workflowstate_ct_id_idx",
|
||
|
|
),
|
||
|
|
models.Index(
|
||
|
|
fields=["base_content_type", "object_id"],
|
||
|
|
name="workflowstate_base_ct_id_idx",
|
||
|
|
),
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
class WorkflowManager(models.Manager):
|
||
|
|
def active(self):
|
||
|
|
return self.filter(active=True)
|
||
|
|
|
||
|
|
|
||
|
|
class AbstractWorkflow(ClusterableModel):
|
||
|
|
name = models.CharField(max_length=255, verbose_name=_("name"))
|
||
|
|
active = models.BooleanField(
|
||
|
|
verbose_name=_("active"),
|
||
|
|
default=True,
|
||
|
|
help_text=_(
|
||
|
|
"Active workflows can be added to pages/snippets. Deactivating a workflow does not remove it from existing pages/snippets."
|
||
|
|
),
|
||
|
|
)
|
||
|
|
objects = WorkflowManager()
|
||
|
|
|
||
|
|
def __str__(self):
|
||
|
|
return self.name
|
||
|
|
|
||
|
|
@property
|
||
|
|
def tasks(self):
|
||
|
|
"""
|
||
|
|
Returns all ``Task`` instances linked to this workflow.
|
||
|
|
"""
|
||
|
|
return Task.objects.filter(workflow_tasks__workflow=self).order_by(
|
||
|
|
"workflow_tasks__sort_order"
|
||
|
|
)
|
||
|
|
|
||
|
|
@transaction.atomic
|
||
|
|
def start(self, obj, user):
|
||
|
|
"""
|
||
|
|
Initiates a workflow by creating an instance of ``WorkflowState``.
|
||
|
|
"""
|
||
|
|
|
||
|
|
state = WorkflowState(
|
||
|
|
content_type=obj.get_content_type(),
|
||
|
|
base_content_type=obj.get_base_content_type(),
|
||
|
|
object_id=str(obj.pk),
|
||
|
|
workflow=self,
|
||
|
|
status=WorkflowState.STATUS_IN_PROGRESS,
|
||
|
|
requested_by=user,
|
||
|
|
)
|
||
|
|
state.save()
|
||
|
|
state.update(user=user)
|
||
|
|
workflow_submitted.send(sender=state.__class__, instance=state, user=user)
|
||
|
|
|
||
|
|
next_task_data = None
|
||
|
|
if state.current_task_state:
|
||
|
|
next_task_data = {
|
||
|
|
"id": state.current_task_state.task.id,
|
||
|
|
"title": state.current_task_state.task.name,
|
||
|
|
}
|
||
|
|
log(
|
||
|
|
instance=obj,
|
||
|
|
action="wagtail.workflow.start",
|
||
|
|
data={
|
||
|
|
"workflow": {
|
||
|
|
"id": self.id,
|
||
|
|
"title": self.name,
|
||
|
|
"status": state.status,
|
||
|
|
"next": next_task_data,
|
||
|
|
"task_state_id": state.current_task_state.id
|
||
|
|
if state.current_task_state
|
||
|
|
else None,
|
||
|
|
}
|
||
|
|
},
|
||
|
|
revision=obj.get_latest_revision(),
|
||
|
|
user=user,
|
||
|
|
)
|
||
|
|
|
||
|
|
return state
|
||
|
|
|
||
|
|
@transaction.atomic
|
||
|
|
def deactivate(self, user=None):
|
||
|
|
"""
|
||
|
|
Sets the workflow as inactive, and cancels all in progress instances of ``WorkflowState`` linked to this workflow.
|
||
|
|
"""
|
||
|
|
from wagtail.models import WorkflowPage
|
||
|
|
|
||
|
|
self.active = False
|
||
|
|
in_progress_states = WorkflowState.objects.filter(
|
||
|
|
workflow=self, status=WorkflowState.STATUS_IN_PROGRESS
|
||
|
|
)
|
||
|
|
for state in in_progress_states:
|
||
|
|
state.cancel(user=user)
|
||
|
|
WorkflowPage.objects.filter(workflow=self).delete()
|
||
|
|
WorkflowContentType.objects.filter(workflow=self).delete()
|
||
|
|
self.save()
|
||
|
|
|
||
|
|
def all_pages(self):
|
||
|
|
"""
|
||
|
|
Returns a queryset of all the pages that this Workflow applies to.
|
||
|
|
"""
|
||
|
|
from wagtail.models import Page
|
||
|
|
|
||
|
|
pages = Page.objects.none()
|
||
|
|
|
||
|
|
for workflow_page in self.workflow_pages.all():
|
||
|
|
pages |= workflow_page.get_pages()
|
||
|
|
|
||
|
|
return pages
|
||
|
|
|
||
|
|
class Meta:
|
||
|
|
verbose_name = _("workflow")
|
||
|
|
verbose_name_plural = _("workflows")
|
||
|
|
abstract = True
|
||
|
|
|
||
|
|
|
||
|
|
class Workflow(AbstractWorkflow):
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
class WorkflowTask(Orderable):
|
||
|
|
workflow = ParentalKey(
|
||
|
|
"Workflow",
|
||
|
|
on_delete=models.CASCADE,
|
||
|
|
verbose_name=_("workflow_tasks"),
|
||
|
|
related_name="workflow_tasks",
|
||
|
|
)
|
||
|
|
task = models.ForeignKey(
|
||
|
|
"Task",
|
||
|
|
on_delete=models.CASCADE,
|
||
|
|
verbose_name=_("task"),
|
||
|
|
related_name="workflow_tasks",
|
||
|
|
limit_choices_to={"active": True},
|
||
|
|
)
|
||
|
|
|
||
|
|
class Meta(Orderable.Meta):
|
||
|
|
unique_together = [("workflow", "task")]
|
||
|
|
verbose_name = _("workflow task order")
|
||
|
|
verbose_name_plural = _("workflow task orders")
|
||
|
|
|
||
|
|
|
||
|
|
class TaskQuerySet(SpecificQuerySetMixin, models.QuerySet):
|
||
|
|
def active(self):
|
||
|
|
return self.filter(active=True)
|
||
|
|
|
||
|
|
|
||
|
|
TaskManager = models.Manager.from_queryset(TaskQuerySet)
|
||
|
|
|
||
|
|
|
||
|
|
class Task(SpecificMixin, models.Model):
|
||
|
|
name = models.CharField(max_length=255, verbose_name=_("name"))
|
||
|
|
content_type = models.ForeignKey(
|
||
|
|
ContentType,
|
||
|
|
verbose_name=_("content type"),
|
||
|
|
related_name="wagtail_tasks",
|
||
|
|
on_delete=models.CASCADE,
|
||
|
|
)
|
||
|
|
active = models.BooleanField(
|
||
|
|
verbose_name=_("active"),
|
||
|
|
default=True,
|
||
|
|
help_text=_(
|
||
|
|
"Active tasks can be added to workflows. Deactivating a task does not remove it from existing workflows."
|
||
|
|
),
|
||
|
|
)
|
||
|
|
objects = TaskManager()
|
||
|
|
|
||
|
|
admin_form_fields = ["name"]
|
||
|
|
admin_form_readonly_on_edit_fields = ["name"]
|
||
|
|
|
||
|
|
def __init__(self, *args, **kwargs):
|
||
|
|
super().__init__(*args, **kwargs)
|
||
|
|
if not self.id:
|
||
|
|
# this model is being newly created
|
||
|
|
# rather than retrieved from the db;
|
||
|
|
if not self.content_type_id:
|
||
|
|
# set content type to correctly represent the model class
|
||
|
|
# that this was created as
|
||
|
|
self.content_type = ContentType.objects.get_for_model(self)
|
||
|
|
|
||
|
|
def __str__(self):
|
||
|
|
return self.name
|
||
|
|
|
||
|
|
@property
|
||
|
|
def workflows(self):
|
||
|
|
"""
|
||
|
|
Returns all ``Workflow`` instances that use this task.
|
||
|
|
"""
|
||
|
|
return Workflow.objects.filter(workflow_tasks__task=self)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def active_workflows(self):
|
||
|
|
"""
|
||
|
|
Return a ``QuerySet``` of active workflows that this task is part of.
|
||
|
|
"""
|
||
|
|
return Workflow.objects.active().filter(workflow_tasks__task=self)
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def get_verbose_name(cls):
|
||
|
|
"""
|
||
|
|
Returns the human-readable "verbose name" of this task model e.g "Group approval task".
|
||
|
|
"""
|
||
|
|
# This is similar to doing cls._meta.verbose_name.title()
|
||
|
|
# except this doesn't convert any characters to lowercase
|
||
|
|
return capfirst(cls._meta.verbose_name)
|
||
|
|
|
||
|
|
task_state_class = None
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def get_task_state_class(self):
|
||
|
|
return self.task_state_class or TaskState
|
||
|
|
|
||
|
|
def start(self, workflow_state, user=None):
|
||
|
|
"""
|
||
|
|
Start this task on the provided workflow state by creating an instance of TaskState.
|
||
|
|
"""
|
||
|
|
task_state = self.get_task_state_class()(workflow_state=workflow_state)
|
||
|
|
task_state.status = TaskState.STATUS_IN_PROGRESS
|
||
|
|
task_state.revision = workflow_state.content_object.get_latest_revision()
|
||
|
|
task_state.task = self
|
||
|
|
task_state.save()
|
||
|
|
task_submitted.send(
|
||
|
|
sender=task_state.specific.__class__,
|
||
|
|
instance=task_state.specific,
|
||
|
|
user=user,
|
||
|
|
)
|
||
|
|
return task_state
|
||
|
|
|
||
|
|
@transaction.atomic
|
||
|
|
def on_action(self, task_state, user, action_name, **kwargs):
|
||
|
|
"""
|
||
|
|
Performs an action on a task state determined by the ``action_name`` string passed.
|
||
|
|
"""
|
||
|
|
if action_name == "approve":
|
||
|
|
task_state.approve(user=user, **kwargs)
|
||
|
|
elif action_name == "reject":
|
||
|
|
task_state.reject(user=user, **kwargs)
|
||
|
|
|
||
|
|
def user_can_access_editor(self, obj, user):
|
||
|
|
"""
|
||
|
|
Returns ``True`` if a user who would not normally be able to access the editor for the
|
||
|
|
object should be able to if the object is currently on this task.
|
||
|
|
Note that returning ``False`` does not remove permissions from users who would otherwise have them.
|
||
|
|
"""
|
||
|
|
return False
|
||
|
|
|
||
|
|
def locked_for_user(self, obj, user):
|
||
|
|
"""
|
||
|
|
Returns ``True`` if the object should be locked to a given user's edits.
|
||
|
|
This can be used to prevent editing by non-reviewers.
|
||
|
|
"""
|
||
|
|
return False
|
||
|
|
|
||
|
|
def user_can_lock(self, obj, user):
|
||
|
|
"""
|
||
|
|
Returns ``True`` if a user who would not normally be able to lock the object should be able to
|
||
|
|
if the object is currently on this task.
|
||
|
|
Note that returning ``False`` does not remove permissions from users who would otherwise have them.
|
||
|
|
"""
|
||
|
|
return False
|
||
|
|
|
||
|
|
def user_can_unlock(self, obj, user):
|
||
|
|
"""
|
||
|
|
Returns ``True`` if a user who would not normally be able to unlock the object should be able to
|
||
|
|
if the object is currently on this task.
|
||
|
|
Note that returning ``False`` does not remove permissions from users who would otherwise have them.
|
||
|
|
"""
|
||
|
|
return False
|
||
|
|
|
||
|
|
def get_actions(self, obj, user):
|
||
|
|
"""
|
||
|
|
Get the list of action strings (name, verbose_name, whether the action requires additional data - see
|
||
|
|
``get_form_for_action``) for actions the current user can perform for this task on the given object.
|
||
|
|
These strings should be the same as those able to be passed to ``on_action``.
|
||
|
|
"""
|
||
|
|
return []
|
||
|
|
|
||
|
|
def get_form_for_action(self, action):
|
||
|
|
return TaskStateCommentForm
|
||
|
|
|
||
|
|
def get_template_for_action(self, action):
|
||
|
|
"""
|
||
|
|
Specifies a template for the workflow action modal.
|
||
|
|
"""
|
||
|
|
return ""
|
||
|
|
|
||
|
|
def get_task_states_user_can_moderate(self, user, **kwargs):
|
||
|
|
"""Returns a ``QuerySet`` of the task states the current user can moderate"""
|
||
|
|
return TaskState.objects.none()
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def get_description(cls):
|
||
|
|
"""
|
||
|
|
Returns the task description.
|
||
|
|
"""
|
||
|
|
return ""
|
||
|
|
|
||
|
|
@transaction.atomic
|
||
|
|
def deactivate(self, user=None):
|
||
|
|
"""
|
||
|
|
Set ``active`` to False and cancel all in progress task states linked to this task.
|
||
|
|
"""
|
||
|
|
self.active = False
|
||
|
|
self.save()
|
||
|
|
in_progress_states = TaskState.objects.filter(
|
||
|
|
task=self, status=TaskState.STATUS_IN_PROGRESS
|
||
|
|
)
|
||
|
|
for state in in_progress_states:
|
||
|
|
state.cancel(user=user)
|
||
|
|
|
||
|
|
class Meta:
|
||
|
|
verbose_name = _("task")
|
||
|
|
verbose_name_plural = _("tasks")
|
||
|
|
|
||
|
|
|
||
|
|
class AbstractGroupApprovalTask(Task):
|
||
|
|
groups = models.ManyToManyField(
|
||
|
|
Group,
|
||
|
|
verbose_name=_("groups"),
|
||
|
|
help_text=_(
|
||
|
|
"Pages/snippets at this step in a workflow will be moderated or approved by these groups of users"
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
admin_form_fields = Task.admin_form_fields + ["groups"]
|
||
|
|
admin_form_widgets = {
|
||
|
|
"groups": forms.CheckboxSelectMultiple,
|
||
|
|
}
|
||
|
|
|
||
|
|
def start(self, workflow_state, user=None):
|
||
|
|
if (
|
||
|
|
isinstance(workflow_state.content_object, LockableMixin)
|
||
|
|
and workflow_state.content_object.locked_by
|
||
|
|
):
|
||
|
|
# If the person who locked the object isn't in one of the groups, unlock the object
|
||
|
|
if not workflow_state.content_object.locked_by.groups.filter(
|
||
|
|
id__in=self.groups.all()
|
||
|
|
).exists():
|
||
|
|
workflow_state.content_object.locked = False
|
||
|
|
workflow_state.content_object.locked_by = None
|
||
|
|
workflow_state.content_object.locked_at = None
|
||
|
|
workflow_state.content_object.save(
|
||
|
|
update_fields=["locked", "locked_by", "locked_at"]
|
||
|
|
)
|
||
|
|
|
||
|
|
return super().start(workflow_state, user=user)
|
||
|
|
|
||
|
|
def _user_in_groups(self, user):
|
||
|
|
# Cache the check whether "this user is in any of this
|
||
|
|
# GroupApprovalTask's groups" on the user object, in case we do it
|
||
|
|
# against the same user and task multiple times in a request.
|
||
|
|
# Use a dict to map the task id to the check result, in case we also
|
||
|
|
# check against different GroupApprovalTasks for the same user.
|
||
|
|
cache_attr = "_group_approval_task_checks"
|
||
|
|
if not (checks_cache := getattr(user, cache_attr, {})):
|
||
|
|
setattr(user, cache_attr, checks_cache)
|
||
|
|
|
||
|
|
if self.pk not in checks_cache:
|
||
|
|
checks_cache[self.pk] = self.groups.filter(
|
||
|
|
id__in=user.groups.all()
|
||
|
|
).exists()
|
||
|
|
|
||
|
|
return checks_cache[self.pk]
|
||
|
|
|
||
|
|
def user_can_access_editor(self, obj, user):
|
||
|
|
return user.is_superuser or self._user_in_groups(user)
|
||
|
|
|
||
|
|
def locked_for_user(self, obj, user):
|
||
|
|
return not (user.is_superuser or self._user_in_groups(user))
|
||
|
|
|
||
|
|
def user_can_lock(self, obj, user):
|
||
|
|
return self._user_in_groups(user)
|
||
|
|
|
||
|
|
def user_can_unlock(self, obj, user):
|
||
|
|
return False
|
||
|
|
|
||
|
|
def get_actions(self, obj, user):
|
||
|
|
if user.is_superuser or self._user_in_groups(user):
|
||
|
|
return [
|
||
|
|
("reject", _("Request changes"), True),
|
||
|
|
("approve", _("Approve"), False),
|
||
|
|
("approve", _("Approve with comment"), True),
|
||
|
|
]
|
||
|
|
|
||
|
|
return []
|
||
|
|
|
||
|
|
def get_task_states_user_can_moderate(self, user, **kwargs):
|
||
|
|
if user.is_superuser or self._user_in_groups(user):
|
||
|
|
return self.task_states.filter(status=TaskState.STATUS_IN_PROGRESS)
|
||
|
|
else:
|
||
|
|
return TaskState.objects.none()
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def get_description(cls):
|
||
|
|
return _("Members of the chosen Wagtail Groups can approve this task")
|
||
|
|
|
||
|
|
class Meta:
|
||
|
|
abstract = True
|
||
|
|
verbose_name = _("Group approval task")
|
||
|
|
verbose_name_plural = _("Group approval tasks")
|
||
|
|
|
||
|
|
|
||
|
|
class GroupApprovalTask(AbstractGroupApprovalTask):
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
class BaseTaskStateManager(models.Manager):
|
||
|
|
def reviewable_by(self, user):
|
||
|
|
tasks = Task.objects.filter(active=True).specific()
|
||
|
|
states = TaskState.objects.none()
|
||
|
|
for task in tasks:
|
||
|
|
states = states | task.get_task_states_user_can_moderate(user=user)
|
||
|
|
return states
|
||
|
|
|
||
|
|
|
||
|
|
class TaskStateQuerySet(SpecificQuerySetMixin, models.QuerySet):
|
||
|
|
def for_instance(self, instance):
|
||
|
|
"""
|
||
|
|
Filters to only TaskStates for the given instance
|
||
|
|
"""
|
||
|
|
try:
|
||
|
|
# Use RevisionMixin.get_base_content_type() if available
|
||
|
|
return self.filter(
|
||
|
|
workflow_state__base_content_type=instance.get_base_content_type(),
|
||
|
|
workflow_state__object_id=str(instance.pk),
|
||
|
|
)
|
||
|
|
except AttributeError:
|
||
|
|
# Fallback to ContentType for the model
|
||
|
|
return self.filter(
|
||
|
|
workflow_state__content_type=ContentType.objects.get_for_model(
|
||
|
|
instance, for_concrete_model=False
|
||
|
|
),
|
||
|
|
workflow_state__object_id=str(instance.pk),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
TaskStateManager = BaseTaskStateManager.from_queryset(TaskStateQuerySet)
|
||
|
|
|
||
|
|
|
||
|
|
class TaskState(SpecificMixin, models.Model):
|
||
|
|
"""Tracks the status of a given Task for a particular revision."""
|
||
|
|
|
||
|
|
STATUS_IN_PROGRESS = "in_progress"
|
||
|
|
STATUS_APPROVED = "approved"
|
||
|
|
STATUS_REJECTED = "rejected"
|
||
|
|
STATUS_SKIPPED = "skipped"
|
||
|
|
STATUS_CANCELLED = "cancelled"
|
||
|
|
STATUS_CHOICES = (
|
||
|
|
(STATUS_IN_PROGRESS, _("In progress")),
|
||
|
|
(STATUS_APPROVED, _("Approved")),
|
||
|
|
(STATUS_REJECTED, _("Rejected")),
|
||
|
|
(STATUS_SKIPPED, _("Skipped")),
|
||
|
|
(STATUS_CANCELLED, _("Cancelled")),
|
||
|
|
)
|
||
|
|
|
||
|
|
workflow_state = models.ForeignKey(
|
||
|
|
"WorkflowState",
|
||
|
|
on_delete=models.CASCADE,
|
||
|
|
verbose_name=_("workflow state"),
|
||
|
|
related_name="task_states",
|
||
|
|
)
|
||
|
|
revision = models.ForeignKey(
|
||
|
|
"Revision",
|
||
|
|
on_delete=models.CASCADE,
|
||
|
|
verbose_name=_("revision"),
|
||
|
|
related_name="task_states",
|
||
|
|
)
|
||
|
|
task = models.ForeignKey(
|
||
|
|
"Task",
|
||
|
|
on_delete=models.CASCADE,
|
||
|
|
verbose_name=_("task"),
|
||
|
|
related_name="task_states",
|
||
|
|
)
|
||
|
|
status = models.fields.CharField(
|
||
|
|
choices=STATUS_CHOICES,
|
||
|
|
verbose_name=_("status"),
|
||
|
|
max_length=50,
|
||
|
|
default=STATUS_IN_PROGRESS,
|
||
|
|
)
|
||
|
|
started_at = models.DateTimeField(verbose_name=_("started at"), auto_now_add=True)
|
||
|
|
finished_at = models.DateTimeField(
|
||
|
|
verbose_name=_("finished at"), blank=True, null=True
|
||
|
|
)
|
||
|
|
finished_by = models.ForeignKey(
|
||
|
|
settings.AUTH_USER_MODEL,
|
||
|
|
verbose_name=_("finished by"),
|
||
|
|
null=True,
|
||
|
|
blank=True,
|
||
|
|
on_delete=models.SET_NULL,
|
||
|
|
related_name="finished_task_states",
|
||
|
|
)
|
||
|
|
comment = models.TextField(blank=True)
|
||
|
|
content_type = models.ForeignKey(
|
||
|
|
ContentType,
|
||
|
|
verbose_name=_("content type"),
|
||
|
|
related_name="wagtail_task_states",
|
||
|
|
on_delete=models.CASCADE,
|
||
|
|
)
|
||
|
|
exclude_fields_in_copy = []
|
||
|
|
default_exclude_fields_in_copy = ["id"]
|
||
|
|
|
||
|
|
objects = TaskStateManager()
|
||
|
|
|
||
|
|
def __init__(self, *args, **kwargs):
|
||
|
|
super().__init__(*args, **kwargs)
|
||
|
|
if not self.id:
|
||
|
|
# this model is being newly created
|
||
|
|
# rather than retrieved from the db;
|
||
|
|
if not self.content_type_id:
|
||
|
|
# set content type to correctly represent the model class
|
||
|
|
# that this was created as
|
||
|
|
self.content_type = ContentType.objects.get_for_model(self)
|
||
|
|
|
||
|
|
def __str__(self):
|
||
|
|
return _("Task '%(task_name)s' on Revision '%(revision_info)s': %(status)s") % {
|
||
|
|
"task_name": self.task,
|
||
|
|
"revision_info": self.revision,
|
||
|
|
"status": self.status,
|
||
|
|
}
|
||
|
|
|
||
|
|
@transaction.atomic
|
||
|
|
def approve(self, user=None, update=True, comment=""):
|
||
|
|
"""
|
||
|
|
Approve the task state and update the workflow state.
|
||
|
|
"""
|
||
|
|
if self.status != self.STATUS_IN_PROGRESS:
|
||
|
|
raise PermissionDenied
|
||
|
|
self.status = self.STATUS_APPROVED
|
||
|
|
self.finished_at = timezone.now()
|
||
|
|
self.finished_by = user
|
||
|
|
self.comment = comment
|
||
|
|
self.save()
|
||
|
|
|
||
|
|
self.log_state_change_action(user, "approve")
|
||
|
|
if update:
|
||
|
|
self.workflow_state.update(user=user)
|
||
|
|
task_approved.send(
|
||
|
|
sender=self.specific.__class__, instance=self.specific, user=user
|
||
|
|
)
|
||
|
|
return self
|
||
|
|
|
||
|
|
@transaction.atomic
|
||
|
|
def reject(self, user=None, update=True, comment=""):
|
||
|
|
"""
|
||
|
|
Reject the task state and update the workflow state.
|
||
|
|
"""
|
||
|
|
if self.status != self.STATUS_IN_PROGRESS:
|
||
|
|
raise PermissionDenied
|
||
|
|
self.status = self.STATUS_REJECTED
|
||
|
|
self.finished_at = timezone.now()
|
||
|
|
self.finished_by = user
|
||
|
|
self.comment = comment
|
||
|
|
self.save()
|
||
|
|
|
||
|
|
self.log_state_change_action(user, "reject")
|
||
|
|
if update:
|
||
|
|
self.workflow_state.update(user=user)
|
||
|
|
task_rejected.send(
|
||
|
|
sender=self.specific.__class__, instance=self.specific, user=user
|
||
|
|
)
|
||
|
|
|
||
|
|
return self
|
||
|
|
|
||
|
|
@cached_property
|
||
|
|
def task_type_started_at(self):
|
||
|
|
"""
|
||
|
|
Finds the first chronological started_at for successive TaskStates - ie started_at if the task had not been restarted.
|
||
|
|
"""
|
||
|
|
task_states = (
|
||
|
|
TaskState.objects.filter(workflow_state=self.workflow_state)
|
||
|
|
.order_by("-started_at")
|
||
|
|
.select_related("task")
|
||
|
|
)
|
||
|
|
started_at = None
|
||
|
|
for task_state in task_states:
|
||
|
|
if task_state.task == self.task:
|
||
|
|
started_at = task_state.started_at
|
||
|
|
elif started_at:
|
||
|
|
break
|
||
|
|
return started_at
|
||
|
|
|
||
|
|
@transaction.atomic
|
||
|
|
def cancel(self, user=None, resume=False, comment=""):
|
||
|
|
"""
|
||
|
|
Cancel the task state and update the workflow state.
|
||
|
|
If ``resume`` is set to True, then upon update the workflow state is passed the current task as ``next_task``,
|
||
|
|
causing it to start a new task state on the current task if possible.
|
||
|
|
"""
|
||
|
|
self.status = self.STATUS_CANCELLED
|
||
|
|
self.finished_at = timezone.now()
|
||
|
|
self.comment = comment
|
||
|
|
self.finished_by = user
|
||
|
|
self.save()
|
||
|
|
if resume:
|
||
|
|
self.workflow_state.update(user=user, next_task=self.task.specific)
|
||
|
|
else:
|
||
|
|
self.workflow_state.update(user=user)
|
||
|
|
task_cancelled.send(
|
||
|
|
sender=self.specific.__class__, instance=self.specific, user=user
|
||
|
|
)
|
||
|
|
return self
|
||
|
|
|
||
|
|
def copy(self, update_attrs=None, exclude_fields=None):
|
||
|
|
"""
|
||
|
|
Copy this task state, excluding the attributes in the ``exclude_fields`` list and updating any attributes
|
||
|
|
to values specified in the ``update_attrs`` dictionary of ``attribute``: ``new value`` pairs.
|
||
|
|
"""
|
||
|
|
exclude_fields = (
|
||
|
|
self.default_exclude_fields_in_copy
|
||
|
|
+ self.exclude_fields_in_copy
|
||
|
|
+ (exclude_fields or [])
|
||
|
|
)
|
||
|
|
instance, child_object_map = _copy(self.specific, exclude_fields, update_attrs)
|
||
|
|
instance.save()
|
||
|
|
_copy_m2m_relations(self, instance, exclude_fields=exclude_fields)
|
||
|
|
return instance
|
||
|
|
|
||
|
|
def get_comment(self):
|
||
|
|
"""
|
||
|
|
Returns a string that is displayed in workflow history.
|
||
|
|
|
||
|
|
This could be a comment by the reviewer, or generated.
|
||
|
|
Use mark_safe to return HTML.
|
||
|
|
"""
|
||
|
|
return self.comment
|
||
|
|
|
||
|
|
def log_state_change_action(self, user, action):
|
||
|
|
"""Log the approval/rejection action"""
|
||
|
|
obj = self.revision.as_object()
|
||
|
|
next_task = self.workflow_state.get_next_task()
|
||
|
|
next_task_data = None
|
||
|
|
if next_task:
|
||
|
|
next_task_data = {"id": next_task.id, "title": next_task.name}
|
||
|
|
log(
|
||
|
|
instance=obj,
|
||
|
|
action=f"wagtail.workflow.{action}",
|
||
|
|
user=user,
|
||
|
|
data={
|
||
|
|
"workflow": {
|
||
|
|
"id": self.workflow_state.workflow.id,
|
||
|
|
"title": self.workflow_state.workflow.name,
|
||
|
|
"status": self.status,
|
||
|
|
"task_state_id": self.id,
|
||
|
|
"task": {
|
||
|
|
"id": self.task.id,
|
||
|
|
"title": self.task.name,
|
||
|
|
},
|
||
|
|
"next": next_task_data,
|
||
|
|
},
|
||
|
|
"comment": self.get_comment(),
|
||
|
|
},
|
||
|
|
revision=self.revision,
|
||
|
|
)
|
||
|
|
|
||
|
|
class Meta:
|
||
|
|
verbose_name = _("Task state")
|
||
|
|
verbose_name_plural = _("Task states")
|
||
|
|
|
||
|
|
|
||
|
|
class WorkflowMixin:
|
||
|
|
"""A mixin that allows a model to have workflows."""
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def check(cls, **kwargs):
|
||
|
|
return [
|
||
|
|
*super().check(**kwargs),
|
||
|
|
*cls._check_draftstate_and_revision_mixins(),
|
||
|
|
]
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def _check_draftstate_and_revision_mixins(cls):
|
||
|
|
mro = cls.mro()
|
||
|
|
error = checks.Error(
|
||
|
|
"WorkflowMixin requires DraftStateMixin and RevisionMixin (in that order).",
|
||
|
|
hint=(
|
||
|
|
"Make sure your model's inheritance order is as follows: "
|
||
|
|
"WorkflowMixin, DraftStateMixin, RevisionMixin."
|
||
|
|
),
|
||
|
|
obj=cls,
|
||
|
|
id="wagtailcore.E006",
|
||
|
|
)
|
||
|
|
|
||
|
|
try:
|
||
|
|
if not (
|
||
|
|
mro.index(WorkflowMixin)
|
||
|
|
< mro.index(DraftStateMixin)
|
||
|
|
< mro.index(RevisionMixin)
|
||
|
|
):
|
||
|
|
return [error]
|
||
|
|
except ValueError:
|
||
|
|
return [error]
|
||
|
|
|
||
|
|
return []
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def get_default_workflow(cls):
|
||
|
|
"""
|
||
|
|
Returns the active workflow assigned to the model.
|
||
|
|
|
||
|
|
For non-``Page`` models, workflows are assigned to the model's content type,
|
||
|
|
thus shared across all instances instead of being assigned to individual
|
||
|
|
instances (unless :meth:`~WorkflowMixin.get_workflow` is overridden).
|
||
|
|
|
||
|
|
This method is used to determine the workflow to use when creating new
|
||
|
|
instances of the model. On ``Page`` models, this method is unused as the
|
||
|
|
workflow can be determined from the parent page's workflow.
|
||
|
|
"""
|
||
|
|
if not getattr(settings, "WAGTAIL_WORKFLOW_ENABLED", True):
|
||
|
|
return None
|
||
|
|
|
||
|
|
content_type = ContentType.objects.get_for_model(cls, for_concrete_model=False)
|
||
|
|
workflow_content_type = (
|
||
|
|
WorkflowContentType.objects.filter(
|
||
|
|
workflow__active=True,
|
||
|
|
content_type=content_type,
|
||
|
|
)
|
||
|
|
.select_related("workflow")
|
||
|
|
.first()
|
||
|
|
)
|
||
|
|
|
||
|
|
if workflow_content_type:
|
||
|
|
return workflow_content_type.workflow
|
||
|
|
return None
|
||
|
|
|
||
|
|
@property
|
||
|
|
def has_workflow(self):
|
||
|
|
"""
|
||
|
|
Returns ```True``` if the object has an active workflow assigned, otherwise ```False```.
|
||
|
|
"""
|
||
|
|
return self.get_workflow() is not None
|
||
|
|
|
||
|
|
def get_workflow(self):
|
||
|
|
"""
|
||
|
|
Returns the active workflow assigned to the object.
|
||
|
|
"""
|
||
|
|
return self.get_default_workflow()
|
||
|
|
|
||
|
|
@property
|
||
|
|
def workflow_states(self):
|
||
|
|
"""
|
||
|
|
Returns workflow states that belong to the object.
|
||
|
|
|
||
|
|
To allow filtering ``WorkflowState`` queries by the object,
|
||
|
|
subclasses should define a
|
||
|
|
:class:`~django.contrib.contenttypes.fields.GenericRelation` to
|
||
|
|
:class:`~wagtail.models.WorkflowState` with the desired
|
||
|
|
``related_query_name``. This property can be replaced with the
|
||
|
|
``GenericRelation`` or overridden to allow custom logic, which can be
|
||
|
|
useful if the model has inheritance.
|
||
|
|
"""
|
||
|
|
return WorkflowState.objects.for_instance(self)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def workflow_in_progress(self):
|
||
|
|
"""
|
||
|
|
Returns ```True``` if a workflow is in progress on the current object, otherwise ```False```.
|
||
|
|
"""
|
||
|
|
if not getattr(settings, "WAGTAIL_WORKFLOW_ENABLED", True):
|
||
|
|
return False
|
||
|
|
|
||
|
|
# `_current_workflow_states` may be populated by `prefetch_workflow_states`
|
||
|
|
# on querysets as a performance optimization
|
||
|
|
if hasattr(self, "_current_workflow_states"):
|
||
|
|
for state in self._current_workflow_states:
|
||
|
|
if state.status == WorkflowState.STATUS_IN_PROGRESS:
|
||
|
|
return True
|
||
|
|
return False
|
||
|
|
|
||
|
|
return self.workflow_states.filter(
|
||
|
|
status=WorkflowState.STATUS_IN_PROGRESS
|
||
|
|
).exists()
|
||
|
|
|
||
|
|
@property
|
||
|
|
def current_workflow_state(self):
|
||
|
|
"""
|
||
|
|
Returns the in progress or needs changes workflow state on this object, if it exists.
|
||
|
|
"""
|
||
|
|
if not getattr(settings, "WAGTAIL_WORKFLOW_ENABLED", True):
|
||
|
|
return None
|
||
|
|
|
||
|
|
# `_current_workflow_states` may be populated by `prefetch_workflow_states`
|
||
|
|
# on querysets as a performance optimization
|
||
|
|
if hasattr(self, "_current_workflow_states"):
|
||
|
|
try:
|
||
|
|
return self._current_workflow_states[0]
|
||
|
|
except IndexError:
|
||
|
|
return
|
||
|
|
|
||
|
|
return (
|
||
|
|
self.workflow_states.active()
|
||
|
|
.select_related("current_task_state__task")
|
||
|
|
.first()
|
||
|
|
)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def current_workflow_task_state(self):
|
||
|
|
"""
|
||
|
|
Returns (specific class of) the current task state of the workflow on this object, if it exists.
|
||
|
|
"""
|
||
|
|
current_workflow_state = self.current_workflow_state
|
||
|
|
if (
|
||
|
|
current_workflow_state
|
||
|
|
and current_workflow_state.status == WorkflowState.STATUS_IN_PROGRESS
|
||
|
|
and current_workflow_state.current_task_state
|
||
|
|
):
|
||
|
|
return current_workflow_state.current_task_state.specific
|
||
|
|
|
||
|
|
@property
|
||
|
|
def current_workflow_task(self):
|
||
|
|
"""
|
||
|
|
Returns (specific class of) the current task in progress on this object, if it exists.
|
||
|
|
"""
|
||
|
|
current_workflow_task_state = self.current_workflow_task_state
|
||
|
|
if current_workflow_task_state:
|
||
|
|
return current_workflow_task_state.task.specific
|
||
|
|
|
||
|
|
@property
|
||
|
|
def status_string(self):
|
||
|
|
if not self.live:
|
||
|
|
if self.expired:
|
||
|
|
return _("expired")
|
||
|
|
elif self.approved_schedule:
|
||
|
|
return _("scheduled")
|
||
|
|
elif self.workflow_in_progress:
|
||
|
|
return _("in moderation")
|
||
|
|
else:
|
||
|
|
return _("draft")
|
||
|
|
else:
|
||
|
|
if self.approved_schedule:
|
||
|
|
return _("live + scheduled")
|
||
|
|
elif self.workflow_in_progress:
|
||
|
|
return _("live + in moderation")
|
||
|
|
elif self.has_unpublished_changes:
|
||
|
|
return _("live + draft")
|
||
|
|
else:
|
||
|
|
return _("live")
|
||
|
|
|
||
|
|
def get_lock(self):
|
||
|
|
# Standard locking should take precedence over workflow locking
|
||
|
|
# because it's possible for both to be used at the same time
|
||
|
|
lock = super().get_lock()
|
||
|
|
if lock:
|
||
|
|
return lock
|
||
|
|
|
||
|
|
current_workflow_task = self.current_workflow_task
|
||
|
|
if current_workflow_task:
|
||
|
|
return WorkflowLock(self, current_workflow_task)
|