diff --git a/common/models/mixins/__init__.py b/common/models/mixins/__init__.py
index d2e203dd5..0592410b9 100644
--- a/common/models/mixins/__init__.py
+++ b/common/models/mixins/__init__.py
@@ -1,6 +1,7 @@
"""Mixins for models."""
from django.db import models
+from django.db.models import signals
class TimestampedMixin(models.Model):
@@ -11,3 +12,37 @@ class TimestampedMixin(models.Model):
class Meta:
abstract = True
+
+
+class WithSignalManagerMixin:
+ """A mixin that overrides default Manager methods to send pre_save signals
+ for model instances."""
+
+ def bulk_create(self, objs, **kwargs) -> list:
+ for obj in objs:
+ signals.pre_save.send(sender=self.model, instance=obj)
+ return super().bulk_create(objs, **kwargs)
+
+
+class WithSignalQuerysetMixin:
+ """A mixin that overrides default QuerySet methods to send pre_save signals
+ for model instances."""
+
+ def update(self, **kwargs) -> int:
+ old_instances_map = {}
+ pk_list = []
+ for old_instance in self.iterator():
+ old_instances_map[old_instance.pk] = old_instance
+ pk_list.append(old_instance.pk)
+
+ rows_updated = super().update(**kwargs)
+
+ for new_instance in self.model.objects.filter(pk__in=pk_list):
+ old_instance = old_instances_map.get(new_instance.pk)
+ signals.pre_save.send(
+ sender=self.model,
+ instance=new_instance,
+ old_instance=old_instance,
+ )
+
+ return rows_updated
diff --git a/common/tests/factories.py b/common/tests/factories.py
index f3335c637..34f0bdbf2 100644
--- a/common/tests/factories.py
+++ b/common/tests/factories.py
@@ -7,6 +7,7 @@
import factory
from django.contrib.auth import get_user_model
+from django.db.models import signals
from factory.fuzzy import FuzzyChoice
from factory.fuzzy import FuzzyText
from faker import Faker
@@ -1565,6 +1566,7 @@ class SubTaskFactory(TaskFactory):
parent_task = factory.SubFactory(TaskFactory)
+@factory.django.mute_signals(signals.pre_save)
class TaskAssigneeFactory(factory.django.DjangoModelFactory):
user = factory.SubFactory(UserFactory)
assignment_type = FuzzyChoice(TaskAssignee.AssignmentType.values)
diff --git a/conftest.py b/conftest.py
index 54ff9db39..8c9ed10c0 100644
--- a/conftest.py
+++ b/conftest.py
@@ -290,7 +290,7 @@ def api_client() -> APIClient:
@pytest.fixture
def policy_group(db) -> Group:
- group = factories.UserGroupFactory.create(name="Policy")
+ group = factories.UserGroupFactory.create(name="Tariff Managers")
for app_label, codename in [
("common", "add_trackedmodel"),
@@ -301,6 +301,9 @@ def policy_group(db) -> Group:
("publishing", "consume_from_packaging_queue"),
("publishing", "manage_packaging_queue"),
("publishing", "view_envelope"),
+ ("tasks", "add_task"),
+ ("tasks", "change_task"),
+ ("tasks", "delete_task"),
("tasks", "add_taskassignee"),
("tasks", "change_taskassignee"),
("tasks", "add_comment"),
diff --git a/tasks/admin.py b/tasks/admin.py
index 593611372..01086b346 100644
--- a/tasks/admin.py
+++ b/tasks/admin.py
@@ -6,6 +6,7 @@
from tasks.models import ProgressState
from tasks.models import Task
from tasks.models import TaskAssignee
+from tasks.models import TaskLog
class TaskAdminMixin:
@@ -77,6 +78,19 @@ def task_id(self, obj):
return self.link_to_task(obj.task)
+class TaskLogAdmin(admin.ModelAdmin):
+ list_display = ["task", "action", "created_at"]
+ list_filter = ["action"]
+ readonly_fields = [
+ "id",
+ "action",
+ "description",
+ "task",
+ "instigator",
+ "created_at",
+ ]
+
+
admin.site.register(Task, TaskAdmin)
admin.site.register(Category, CategoryAdmin)
@@ -84,3 +98,5 @@ def task_id(self, obj):
admin.site.register(ProgressState, ProgressStateAdmin)
admin.site.register(TaskAssignee, TaskAssigneeAdmin)
+
+admin.site.register(TaskLog, TaskLogAdmin)
diff --git a/tasks/filters.py b/tasks/filters.py
new file mode 100644
index 000000000..b65c79793
--- /dev/null
+++ b/tasks/filters.py
@@ -0,0 +1,28 @@
+from django.forms import CheckboxSelectMultiple
+from django.urls import reverse_lazy
+from django_filters import ModelMultipleChoiceFilter
+
+from common.filters import TamatoFilter
+from tasks.models import ProgressState
+from tasks.models import Task
+
+
+class TaskFilter(TamatoFilter):
+
+ search_fields = (
+ "id",
+ "title",
+ "description",
+ )
+ clear_url = reverse_lazy("workflow:task-ui-list")
+
+ progress_state = ModelMultipleChoiceFilter(
+ label="Status",
+ help_text="Select all that apply",
+ queryset=ProgressState.objects.all(),
+ widget=CheckboxSelectMultiple,
+ )
+
+ class Meta:
+ model = Task
+ fields = ["search", "category", "progress_state"]
diff --git a/tasks/forms.py b/tasks/forms.py
new file mode 100644
index 000000000..9334f1ef6
--- /dev/null
+++ b/tasks/forms.py
@@ -0,0 +1,70 @@
+from crispy_forms_gds.helper import FormHelper
+from crispy_forms_gds.layout import Layout
+from crispy_forms_gds.layout import Size
+from crispy_forms_gds.layout import Submit
+from django.forms import ModelForm
+
+from common.forms import delete_form_for
+from tasks.models import Task
+from workbaskets.models import WorkBasket
+
+
+class TaskBaseForm(ModelForm):
+ class Meta:
+ model = Task
+ exclude = ["parent_task", "creator"]
+
+ error_messages = {
+ "title": {
+ "required": "Enter a title",
+ },
+ "description": {
+ "required": "Enter a description",
+ },
+ "progress_state": {
+ "required": "Select a status",
+ },
+ }
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.init_fields()
+ self.init_layout()
+
+ def init_fields(self):
+ self.fields["progress_state"].label = "Status"
+ self.fields["workbasket"].queryset = WorkBasket.objects.editable()
+
+ def init_layout(self):
+ self.helper = FormHelper(self)
+ self.helper.label_size = Size.SMALL
+ self.helper.legend_size = Size.SMALL
+ self.helper.layout = Layout(
+ "title",
+ "description",
+ "category",
+ "progress_state",
+ "workbasket",
+ Submit(
+ "submit",
+ "Save",
+ data_module="govuk-button",
+ data_prevent_double_click="true",
+ ),
+ )
+
+
+class TaskCreateForm(TaskBaseForm):
+ def save(self, user, commit=True):
+ instance = super().save(commit=False)
+ instance.creator = user
+ if commit:
+ instance.save()
+ return instance
+
+
+class TaskUpdateForm(TaskBaseForm):
+ pass
+
+
+TaskDeleteForm = delete_form_for(Task)
diff --git a/tasks/jinja2/tasks/confirm_create.jinja b/tasks/jinja2/tasks/confirm_create.jinja
new file mode 100644
index 000000000..f4feb04b4
--- /dev/null
+++ b/tasks/jinja2/tasks/confirm_create.jinja
@@ -0,0 +1,42 @@
+{% extends "common/confirm_create.jinja" %}
+
+{% from "components/breadcrumbs/macro.njk" import govukBreadcrumbs %}
+{% from "components/panel/macro.njk" import govukPanel %}
+{% from "components/button/macro.njk" import govukButton %}
+
+{% set page_title = "Task created" %}
+
+{% block breadcrumb %}
+ {{ govukBreadcrumbs({
+ "items": [
+ {"text": "Home", "href": url("home")},
+ {"text": "Create a new task", "href": url("workflow:task-ui-create") },
+ {"text": "Task created"}
+ ]
+ }) }}
+{% endblock %}
+
+{% block panel %}
+ {{ govukPanel({
+ "titleText": "Task: " ~ object.title,
+ "text": "You have created a new task",
+ "classes": "govuk-!-margin-bottom-7"
+ }) }}
+{% endblock %}
+
+{% block button_group %}
+ {{ govukButton({
+ "text": "View task",
+ "href": url('workflow:task-ui-detail', kwargs={"pk": object.pk}),
+ "classes": "govuk-button"
+ }) }}
+ {{ govukButton({
+ "text": "Return to homepage",
+ "href": url("home"),
+ "classes": "govuk-button--secondary"
+ }) }}
+{% endblock %}
+
+{% block actions %}
+
Find and view tasks
+{% endblock %}
diff --git a/tasks/jinja2/tasks/confirm_delete.jinja b/tasks/jinja2/tasks/confirm_delete.jinja
new file mode 100644
index 000000000..65b53069a
--- /dev/null
+++ b/tasks/jinja2/tasks/confirm_delete.jinja
@@ -0,0 +1,41 @@
+{% extends "layouts/confirm.jinja" %}
+
+{% from "components/breadcrumbs/macro.njk" import govukBreadcrumbs %}
+{% from "components/panel/macro.njk" import govukPanel %}
+{% from "components/button/macro.njk" import govukButton %}
+
+{% set page_title = "Task deleted" %}
+
+{% block breadcrumb %}
+ {{ govukBreadcrumbs({
+ "items": [
+ {"text": "Find and view tasks", "href": url("workflow:task-ui-list")},
+ {"text": page_title}
+ ]
+ }) }}
+{% endblock %}
+
+{% block panel %}
+ {{ govukPanel({
+ "titleText": "Task ID: " ~ deleted_pk,
+ "text": "Task has been deleted",
+ "classes": "govuk-!-margin-bottom-7"
+ }) }}
+{% endblock %}
+
+{% block button_group %}
+ {{ govukButton({
+ "text": "Find and view tasks",
+ "href": url("workflow:task-ui-list"),
+ "classes": "govuk-button"
+ }) }}
+ {{ govukButton({
+ "text": "Return to homepage",
+ "href": url("home"),
+ "classes": "govuk-button--secondary"
+ }) }}
+{% endblock %}
+
+{% block actions %}
+ Create a task
+{% endblock %}
diff --git a/tasks/jinja2/tasks/confirm_update.jinja b/tasks/jinja2/tasks/confirm_update.jinja
new file mode 100644
index 000000000..7e6b712ff
--- /dev/null
+++ b/tasks/jinja2/tasks/confirm_update.jinja
@@ -0,0 +1,41 @@
+{% extends "common/confirm_update.jinja" %}
+
+{% from "components/breadcrumbs/macro.njk" import govukBreadcrumbs %}
+{% from "components/panel/macro.njk" import govukPanel %}
+{% from "components/button/macro.njk" import govukButton %}
+
+{% set page_title = "Task updated" %}
+
+{% block breadcrumb %}
+ {{ govukBreadcrumbs({
+ "items": [
+ {"text": "Task: " ~ object.title, "href": url("workflow:task-ui-detail", kwargs={"pk": object.pk})},
+ {"text": page_title}
+ ]
+ }) }}
+{% endblock %}
+
+{% block panel %}
+ {{ govukPanel({
+ "titleText": "Task: " ~ object.title,
+ "text": "Task has been updated",
+ "classes": "govuk-!-margin-bottom-7"
+ }) }}
+{% endblock %}
+
+{% block button_group %}
+ {{ govukButton({
+ "text": "View task",
+ "href": url('workflow:task-ui-detail', kwargs={"pk": object.pk}),
+ "classes": "govuk-button"
+ }) }}
+ {{ govukButton({
+ "text": "Return to homepage",
+ "href": url("home"),
+ "classes": "govuk-button--secondary"
+ }) }}
+{% endblock %}
+
+{% block actions %}
+ Find and view tasks
+{% endblock %}
diff --git a/tasks/jinja2/tasks/delete.jinja b/tasks/jinja2/tasks/delete.jinja
new file mode 100644
index 000000000..9cc761efc
--- /dev/null
+++ b/tasks/jinja2/tasks/delete.jinja
@@ -0,0 +1,26 @@
+{% extends "common/delete.jinja" %}
+
+{% from "components/breadcrumbs.jinja" import breadcrumbs %}
+{% from "components/warning-text/macro.njk" import govukWarningText %}
+{% from "components/button/macro.njk" import govukButton %}
+
+{% set page_title = "Delete Task:" ~ object.title %}
+
+{% block breadcrumb %}
+ {{ breadcrumbs(request, [
+ {"text": "Find and view tasks", "href": url("workflow:task-ui-list")},
+ {"text": "Task: " ~ object.title, "href": url("workflow:task-ui-detail", kwargs={"pk": object.pk})},
+ {"text": page_title}
+ ])
+ }}
+{% endblock %}
+
+{% block form %}
+ {{ govukWarningText({
+ "text": "Are you sure you want to delete this task?"
+ }) }}
+
+ {% call django_form(action=url("workflow:task-ui-delete", kwargs={"pk": object.pk})) %}
+ {{ crispy(form) }}
+ {% endcall %}
+{% endblock %}
diff --git a/tasks/jinja2/tasks/detail.jinja b/tasks/jinja2/tasks/detail.jinja
new file mode 100644
index 000000000..665b4564b
--- /dev/null
+++ b/tasks/jinja2/tasks/detail.jinja
@@ -0,0 +1,115 @@
+{% extends "layouts/layout.jinja" %}
+{% from "components/breadcrumbs.jinja" import breadcrumbs %}
+{% from "components/button/macro.njk" import govukButton %}
+
+{% set page_title = "Task: " ~ object.title %}
+
+{% set edit_url = url("workflow:task-ui-update", kwargs={"pk": object.pk}) %}
+
+{% block breadcrumb %}
+ {{ breadcrumbs(request, [
+ {"text": "Find and view tasks", "href": url("workflow:task-ui-list")},
+ {"text": page_title}
+ ]
+ ) }}
+{% endblock %}
+
+{% block content %}
+
+
+ Details
+
+
+
+
-
+ ID
+
+ -
+ {{ object.pk }}
+
+
+
+
+
+
+
+
+
+
+
-
+ Status
+
+
-
+ {{ object.progress_state }}
+
+
-
+ Change status
+
+
+
+
+
-
+ Created
+
+ -
+ {{ "{:%d %b %Y %H:%M}".format(object.created_at) }}
+
+
+
+
+
-
+ Created by
+
+ -
+ {{ object.creator.get_displayname() }}
+
+
+
+
+
+ {{ govukButton({
+ "text": "Delete task",
+ "href": url("workflow:task-ui-delete", kwargs={"pk": object.pk}),
+ "classes": "govuk-button--warning"
+ }) }}
+ {{ govukButton({
+ "text": "Find and view tasks",
+ "href": url("workflow:task-ui-list"),
+ "classes": "govuk-button--secondary"
+ }) }}
+
+{% endblock %}
diff --git a/tasks/jinja2/tasks/edit.jinja b/tasks/jinja2/tasks/edit.jinja
new file mode 100644
index 000000000..9ce73f211
--- /dev/null
+++ b/tasks/jinja2/tasks/edit.jinja
@@ -0,0 +1,19 @@
+{% extends "layouts/form.jinja" %}
+{% from "components/breadcrumbs.jinja" import breadcrumbs %}
+
+{% set page_title = "Edit task details" %}
+
+{% block breadcrumb %}
+ {{ breadcrumbs(request, [
+ {"text": "Find and view tasks", "href": url("workflow:task-ui-list")},
+ {"text": "Task: " ~ object.title, "href": url("workflow:task-ui-detail", kwargs={"pk": object.pk})},
+ {"text": page_title}
+ ])
+ }}
+{% endblock %}
+
+{% block form %}
+ {% call django_form() %}
+ {{ crispy(form) }}
+ {% endcall %}
+{% endblock %}
diff --git a/tasks/jinja2/tasks/list.jinja b/tasks/jinja2/tasks/list.jinja
new file mode 100644
index 000000000..fa120a392
--- /dev/null
+++ b/tasks/jinja2/tasks/list.jinja
@@ -0,0 +1,78 @@
+{% extends "layouts/layout.jinja" %}
+
+{% from "components/table/macro.njk" import govukTable %}
+{% from "components/create_sortable_anchor.jinja" import create_sortable_anchor %}
+
+{% set page_title = "Find and view tasks" %}
+
+{% set base_url = url("workflow:task-ui-list") %}
+
+{% block content %}
+ {{ page_title }}
+
+ Search for tasks.
+ Alternatively, create a new task.
+
+
+
+
+
Search and filter
+
+
+
+
+ {% if paginator.count > 0 %}
+ {% include "includes/common/pagination-list-summary.jinja" %}
+ {% endif %}
+ {% if object_list %}
+ {% set table_rows = [] %}
+ {% for object in object_list %}
+ {%- set task_linked_id -%}
+
{{ object.pk }}
+ {%- endset -%}
+
+ {%- set workbasket_linked_id -%}
+
{{ object.workbasket.pk }}
+ {%- endset -%}
+
+ {{ table_rows.append([
+ {"text": task_linked_id},
+ {"text": object.title},
+ {"text": object.category},
+ {"text": object.progress_state},
+ {"text": workbasket_linked_id if object.workbasket else "-"},
+ {"text": "{:%d %b %Y}".format(object.created_at)},
+ ]) or "" }}
+ {% endfor %}
+
+ {{ govukTable({
+ "head": [
+ {"text": "ID"},
+ {"text": "Title"},
+ {"text": "Category"},
+ {"text": "Status"},
+ {"text": "Workbasket"},
+ {"text": create_sortable_anchor(request, "created_at", "Created", base_url)},
+ ],
+ "rows": table_rows
+ }) }}
+ {% else %}
+
There are no results for your search, please:
+
+ - check the spelling of your keywords
+ - use more general keywords
+ - select or deselect different filters
+
+ {% endif %}
+ {% include "includes/common/pagination.jinja" %}
+
+
+{% endblock %}
diff --git a/tasks/migrations/0007_create_tasklog.py b/tasks/migrations/0007_create_tasklog.py
index 15a81debf..05659f3fa 100644
--- a/tasks/migrations/0007_create_tasklog.py
+++ b/tasks/migrations/0007_create_tasklog.py
@@ -40,6 +40,7 @@ class Migration(migrations.Migration):
("TASK_UNASSIGNED", "Task Unassigned"),
("PROGRESS_STATE_UPDATED", "Progress State Updated"),
],
+ editable=False,
max_length=100,
),
),
@@ -56,7 +57,8 @@ class Migration(migrations.Migration):
"task",
models.ForeignKey(
editable=False,
- on_delete=django.db.models.deletion.PROTECT,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
related_name="logs",
to="tasks.task",
),
diff --git a/tasks/models.py b/tasks/models.py
index 424b026b3..28cad75a1 100644
--- a/tasks/models.py
+++ b/tasks/models.py
@@ -3,13 +3,25 @@
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
+from django.db import transaction
+from django.urls import reverse
from common.models.mixins import TimestampedMixin
+from common.models.mixins import WithSignalManagerMixin
+from common.models.mixins import WithSignalQuerysetMixin
from workbaskets.models import WorkBasket
User = get_user_model()
+class TaskManager(WithSignalManagerMixin, models.Manager):
+ pass
+
+
+class TaskQueryset(WithSignalQuerysetMixin, models.QuerySet):
+ pass
+
+
class Task(TimestampedMixin):
title = models.CharField(max_length=255)
description = models.TextField()
@@ -44,9 +56,14 @@ class Task(TimestampedMixin):
related_name="created_tasks",
)
+ objects = TaskManager.from_queryset(TaskQueryset)()
+
def __str__(self):
return self.title
+ def get_url(self):
+ return reverse("workflow:task-ui-detail", kwargs={"pk": self.pk})
+
class Category(models.Model):
name = models.CharField(
@@ -78,7 +95,11 @@ def __str__(self):
return self.get_name_display()
-class TaskAssigneeQueryset(models.QuerySet):
+class TaskAssigneeManager(WithSignalManagerMixin, models.Manager):
+ pass
+
+
+class TaskAssigneeQueryset(WithSignalQuerysetMixin, models.QuerySet):
def assigned(self):
return self.exclude(unassigned_at__isnull=False)
@@ -122,7 +143,7 @@ class AssignmentType(models.TextChoices):
null=True,
)
- objects = TaskAssigneeQueryset.as_manager()
+ objects = TaskAssigneeManager.from_queryset(TaskAssigneeQueryset)()
def __str__(self):
return (
@@ -134,13 +155,17 @@ def is_assigned(self):
return True if not self.unassigned_at else False
@classmethod
- def unassign_user(cls, user, task):
+ def unassign_user(cls, user, task, instigator):
+ from tasks.signals import set_current_instigator
+
try:
assignment = cls.objects.get(user=user, task=task)
if assignment.unassigned_at:
return False
- assignment.unassigned_at = datetime.now()
- assignment.save(update_fields=["unassigned_at"])
+ set_current_instigator(instigator)
+ with transaction.atomic():
+ assignment.unassigned_at = datetime.now()
+ assignment.save(update_fields=["unassigned_at"])
return True
except cls.DoesNotExist:
return False
@@ -218,11 +243,13 @@ class AuditActionType(models.TextChoices):
action = models.CharField(
max_length=100,
choices=AuditActionType.choices,
+ editable=False,
)
description = models.TextField(editable=False)
task = models.ForeignKey(
Task,
- on_delete=models.PROTECT,
+ null=True,
+ on_delete=models.SET_NULL,
editable=False,
related_name="logs",
)
diff --git a/tasks/signals.py b/tasks/signals.py
new file mode 100644
index 000000000..af6392408
--- /dev/null
+++ b/tasks/signals.py
@@ -0,0 +1,67 @@
+import threading
+
+from django.db.models.signals import pre_save
+from django.dispatch import receiver
+
+from tasks.models import Task
+from tasks.models import TaskAssignee
+from tasks.models import TaskLog
+
+_thread_locals = threading.local()
+
+
+def get_current_instigator():
+ return getattr(_thread_locals, "instigator", None)
+
+
+def set_current_instigator(instigator):
+ _thread_locals.instigator = instigator
+
+
+@receiver(pre_save, sender=Task)
+def create_tasklog_for_task_update(sender, instance, old_instance=None, **kwargs):
+ """
+ Creates a `TaskLog` entry when a `Task` is being updated and the update
+ action is a `TaskLog.AuditActionType`.
+
+ Note that this signal is triggered before the `Task` instance is saved.
+ """
+ if instance._state.adding:
+ return
+
+ old_instance = old_instance or Task.objects.get(pk=instance.pk)
+
+ if instance.progress_state != old_instance.progress_state:
+ TaskLog.objects.create(
+ task=instance,
+ action=TaskLog.AuditActionType.PROGRESS_STATE_UPDATED,
+ instigator=get_current_instigator(),
+ progress_state=instance.progress_state,
+ )
+
+
+@receiver(pre_save, sender=TaskAssignee)
+def create_tasklog_for_task_assignee(sender, instance, old_instance=None, **kwargs):
+ """
+ Creates a `TaskLog` entry when a user is assigned to or unassigned from a
+ `Task`.
+
+ Note that this signal is triggered before the `TaskAssignee` instance is saved.
+ """
+ if instance._state.adding:
+ return TaskLog.objects.create(
+ task=instance.task,
+ action=TaskLog.AuditActionType.TASK_ASSIGNED,
+ instigator=get_current_instigator(),
+ assignee=instance.user,
+ )
+
+ old_instance = old_instance or TaskAssignee.objects.get(pk=instance.pk)
+
+ if instance.unassigned_at and instance.unassigned_at != old_instance.unassigned_at:
+ return TaskLog.objects.create(
+ task=instance.task,
+ action=TaskLog.AuditActionType.TASK_UNASSIGNED,
+ instigator=get_current_instigator(),
+ assignee=old_instance.user,
+ )
diff --git a/tasks/tests/test_models.py b/tasks/tests/test_models.py
index 2201e0c2d..ec8f6fb81 100644
--- a/tasks/tests/test_models.py
+++ b/tasks/tests/test_models.py
@@ -29,9 +29,9 @@ def test_task_assignee_unassign_user_classmethod(task_assignee):
user = task_assignee.user
task = task_assignee.task
- assert TaskAssignee.unassign_user(user=user, task=task)
+ assert TaskAssignee.unassign_user(user=user, task=task, instigator=user)
# User has already been unassigned
- assert not TaskAssignee.unassign_user(user=user, task=task)
+ assert not TaskAssignee.unassign_user(user=user, task=task, instigator=user)
def test_task_assignee_assigned_queryset(
@@ -41,7 +41,7 @@ def test_task_assignee_assigned_queryset(
user = task_assignee.user
task = task_assignee.task
- TaskAssignee.unassign_user(user=user, task=task)
+ TaskAssignee.unassign_user(user=user, task=task, instigator=user)
assert not TaskAssignee.objects.assigned()
@@ -53,7 +53,7 @@ def test_task_assignee_unassigned_queryset(
user = task_assignee.user
task = task_assignee.task
- TaskAssignee.unassign_user(user=user, task=task)
+ TaskAssignee.unassign_user(user=user, task=task, instigator=user)
assert TaskAssignee.objects.unassigned().count() == 1
diff --git a/tasks/tests/test_views.py b/tasks/tests/test_views.py
new file mode 100644
index 000000000..b7ecd58c8
--- /dev/null
+++ b/tasks/tests/test_views.py
@@ -0,0 +1,35 @@
+from django.urls import reverse
+
+from common.tests.factories import ProgressStateFactory
+from common.tests.factories import TaskFactory
+from tasks.models import ProgressState
+from tasks.models import TaskLog
+
+
+def test_task_update_view_update_progress_state(valid_user_client):
+ """Tests that `TaskUpdateView` updates `Task.progress_state` and that a
+ related `TaskLog` entry is also created."""
+ instance = TaskFactory.create(progress_state__name=ProgressState.State.TO_DO)
+ new_progress_state = ProgressStateFactory.create(
+ name=ProgressState.State.IN_PROGRESS,
+ )
+ form_data = {
+ "progress_state": new_progress_state.pk,
+ "title": instance.title,
+ "description": instance.description,
+ }
+ url = reverse(
+ "workflow:task-ui-update",
+ kwargs={"pk": instance.pk},
+ )
+ response = valid_user_client.post(url, form_data)
+ assert response.status_code == 302
+
+ instance.refresh_from_db()
+
+ assert instance.progress_state == new_progress_state
+ assert TaskLog.objects.get(
+ task=instance,
+ action=TaskLog.AuditActionType.PROGRESS_STATE_UPDATED,
+ instigator=response.wsgi_request.user,
+ )
diff --git a/tasks/urls.py b/tasks/urls.py
new file mode 100644
index 000000000..176b76bff
--- /dev/null
+++ b/tasks/urls.py
@@ -0,0 +1,33 @@
+from django.urls import include
+from django.urls import path
+
+from tasks import views
+
+app_name = "workflow"
+
+ui_patterns = [
+ path("", views.TaskListView.as_view(), name="task-ui-list"),
+ path("/", views.TaskDetailView.as_view(), name="task-ui-detail"),
+ path("create/", views.TaskCreateView.as_view(), name="task-ui-create"),
+ path(
+ "/confirm-create",
+ views.TaskConfirmCreateView.as_view(),
+ name="task-ui-confirm-create",
+ ),
+ path("/update/", views.TaskUpdateView.as_view(), name="task-ui-update"),
+ path(
+ "/confirm-update",
+ views.TaskConfirmUpdateView.as_view(),
+ name="task-ui-confirm-update",
+ ),
+ path("/delete/", views.TaskDeleteView.as_view(), name="task-ui-delete"),
+ path(
+ "/confirm-delete",
+ views.TaskConfirmDeleteView.as_view(),
+ name="task-ui-confirm-delete",
+ ),
+]
+
+urlpatterns = [
+ path("tasks/", include(ui_patterns)),
+]
diff --git a/tasks/views.py b/tasks/views.py
new file mode 100644
index 000000000..c2394b226
--- /dev/null
+++ b/tasks/views.py
@@ -0,0 +1,114 @@
+from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.db import transaction
+from django.http import HttpResponseRedirect
+from django.urls import reverse
+from django.views.generic.base import TemplateView
+from django.views.generic.detail import DetailView
+from django.views.generic.edit import CreateView
+from django.views.generic.edit import DeleteView
+from django.views.generic.edit import UpdateView
+
+from common.views import SortingMixin
+from common.views import WithPaginationListView
+from tasks.filters import TaskFilter
+from tasks.forms import TaskCreateForm
+from tasks.forms import TaskDeleteForm
+from tasks.forms import TaskUpdateForm
+from tasks.models import Task
+from tasks.signals import set_current_instigator
+
+
+class TaskListView(PermissionRequiredMixin, SortingMixin, WithPaginationListView):
+ model = Task
+ template_name = "tasks/list.jinja"
+ permission_required = "tasks.view_task"
+ paginate_by = 20
+ filterset_class = TaskFilter
+ sort_by_fields = ["created_at"]
+
+ def get_queryset(self):
+ queryset = Task.objects.all()
+ ordering = self.get_ordering()
+ if ordering:
+ ordering = (ordering,)
+ queryset = queryset.order_by(*ordering)
+ return queryset
+
+
+class TaskDetailView(PermissionRequiredMixin, DetailView):
+ model = Task
+ template_name = "tasks/detail.jinja"
+ permission_required = "tasks.view_task"
+
+
+class TaskCreateView(PermissionRequiredMixin, CreateView):
+ model = Task
+ template_name = "layouts/create.jinja"
+ permission_required = "tasks.add_task"
+ form_class = TaskCreateForm
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["page_title"] = "Create a task"
+ return context
+
+ def form_valid(self, form):
+ self.object = form.save(user=self.request.user)
+ return HttpResponseRedirect(self.get_success_url())
+
+ def get_success_url(self):
+ return reverse("workflow:task-ui-confirm-create", kwargs={"pk": self.object.pk})
+
+
+class TaskConfirmCreateView(PermissionRequiredMixin, DetailView):
+ model = Task
+ template_name = "tasks/confirm_create.jinja"
+ permission_required = "tasks.add_task"
+
+
+class TaskUpdateView(PermissionRequiredMixin, UpdateView):
+ model = Task
+ template_name = "tasks/edit.jinja"
+ permission_required = "tasks.change_task"
+ form_class = TaskUpdateForm
+
+ def form_valid(self, form):
+ set_current_instigator(self.request.user)
+ with transaction.atomic():
+ self.object = form.save()
+ return HttpResponseRedirect(self.get_success_url())
+
+ def get_success_url(self):
+ return reverse("workflow:task-ui-confirm-update", kwargs={"pk": self.object.pk})
+
+
+class TaskConfirmUpdateView(PermissionRequiredMixin, DetailView):
+ model = Task
+ template_name = "tasks/confirm_update.jinja"
+ permission_required = "tasks.change_task"
+
+
+class TaskDeleteView(PermissionRequiredMixin, DeleteView):
+ model = Task
+ template_name = "tasks/delete.jinja"
+ permission_required = "tasks.delete_task"
+ form_class = TaskDeleteForm
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs["instance"] = self.object
+ return kwargs
+
+ def get_success_url(self):
+ return reverse("workflow:task-ui-confirm-delete", kwargs={"pk": self.object.pk})
+
+
+class TaskConfirmDeleteView(PermissionRequiredMixin, TemplateView):
+ model = Task
+ template_name = "tasks/confirm_delete.jinja"
+ permission_required = "tasks.delete_task"
+
+ def get_context_data(self, **kwargs):
+ context_data = super().get_context_data(**kwargs)
+ context_data["deleted_pk"] = self.kwargs["pk"]
+ return context_data
diff --git a/urls.py b/urls.py
index 61e6aca58..d82888efc 100644
--- a/urls.py
+++ b/urls.py
@@ -37,6 +37,7 @@
path("", include("regulations.urls")),
path("", include("reports.urls")),
path("", include("taric_parsers.urls")),
+ path("", include("tasks.urls", namespace="workflow")),
path("", include("workbaskets.urls", namespace="workbaskets")),
path("", include("reference_documents.urls", namespace="reference_documents")),
]
diff --git a/workbaskets/forms.py b/workbaskets/forms.py
index 7d6c0827d..b2fb5002a 100644
--- a/workbaskets/forms.py
+++ b/workbaskets/forms.py
@@ -11,6 +11,7 @@
from crispy_forms_gds.layout import Submit
from django import forms
from django.contrib.auth import get_user_model
+from django.db import transaction
from django.db.models import Q
from django.urls import reverse
@@ -20,6 +21,7 @@
from tasks.models import Comment
from tasks.models import Task
from tasks.models import TaskAssignee
+from tasks.signals import set_current_instigator
from workbaskets import models
from workbaskets import validators
from workbaskets.util import serialize_uploaded_data
@@ -248,7 +250,10 @@ def init_layout(self):
),
)
+ @transaction.atomic
def assign_users(self, task):
+ set_current_instigator(self.request.user)
+
assignment_type = self.cleaned_data["assignment_type"]
assignees = [
@@ -309,7 +314,10 @@ def init_layout(self):
),
)
+ @transaction.atomic
def unassign_users(self):
+ set_current_instigator(self.request.user)
+
assignees = self.cleaned_data["assignees"]
for assignee in assignees:
assignee.unassigned_at = datetime.now()
diff --git a/workbaskets/tests/test_models.py b/workbaskets/tests/test_models.py
index a9e23c086..c837459e6 100644
--- a/workbaskets/tests/test_models.py
+++ b/workbaskets/tests/test_models.py
@@ -462,7 +462,7 @@ def test_unassigned_workbasket_cannot_be_queued():
)
assert workbasket.is_fully_assigned()
- TaskAssignee.unassign_user(user=worker, task=task)
+ TaskAssignee.unassign_user(user=worker, task=task, instigator=worker)
assert not workbasket.is_fully_assigned()
diff --git a/workbaskets/tests/test_views.py b/workbaskets/tests/test_views.py
index 8baa0f142..9abc1721c 100644
--- a/workbaskets/tests/test_views.py
+++ b/workbaskets/tests/test_views.py
@@ -27,6 +27,7 @@
from measures.models import Measure
from tasks.models import Comment
from tasks.models import TaskAssignee
+from tasks.models import TaskLog
from workbaskets import models
from workbaskets.tasks import check_workbasket_sync
from workbaskets.validators import WorkflowStatus
@@ -2275,18 +2276,36 @@ def test_disabled_packaging_for_unassigned_workbasket(
assert not packaging_button.has_attr("disabled")
-def test_workbasket_assign_users_view(valid_user, valid_user_client, user_workbasket):
- valid_user.user_permissions.add(
- Permission.objects.get(codename="add_taskassignee"),
- )
- response = valid_user_client.get(
- reverse(
- "workbaskets:workbasket-ui-assign-users",
- kwargs={"pk": user_workbasket.pk},
- ),
+def test_workbasket_assign_users_view(valid_user, valid_user_client, new_workbasket):
+ """Tests that a user can be assigned to a workbasket and that a `TaskLog`
+ entry is created together with the `TaskAssignee` instance."""
+ url = reverse(
+ "workbaskets:workbasket-ui-assign-users",
+ kwargs={"pk": new_workbasket.pk},
)
+
+ form_data = {
+ "users": [valid_user.pk],
+ "assignment_type": TaskAssignee.AssignmentType.WORKBASKET_WORKER,
+ }
+
+ response = valid_user_client.get(url)
assert response.status_code == 200
- assert "Assign users to workbasket" in str(response.content)
+
+ response = valid_user_client.post(url, form_data)
+ assert response.status_code == 302
+
+ assert TaskAssignee.objects.get(
+ user=valid_user,
+ assignment_type=TaskAssignee.AssignmentType.WORKBASKET_WORKER,
+ task__workbasket=new_workbasket,
+ )
+
+ assert TaskLog.objects.get(
+ task__workbasket=new_workbasket,
+ action=TaskLog.AuditActionType.TASK_ASSIGNED,
+ instigator=response.wsgi_request.user,
+ )
def test_workbasket_assign_users_view_without_permission(client, user_workbasket):
@@ -2301,18 +2320,35 @@ def test_workbasket_assign_users_view_without_permission(client, user_workbasket
assert response.status_code == 403
-def test_workbasket_unassign_users_view(valid_user, valid_user_client, user_workbasket):
- valid_user.user_permissions.add(
- Permission.objects.get(codename="change_taskassignee"),
- )
- response = valid_user_client.get(
- reverse(
- "workbaskets:workbasket-ui-unassign-users",
- kwargs={"pk": user_workbasket.pk},
- ),
+def test_workbasket_unassign_users_view(valid_user, valid_user_client):
+ """Tests that a user can be unassigned from a workbasket and that a
+ `TaskLog` entry is created together with the updated `TaskAssignee`
+ instance."""
+ assignee = factories.TaskAssigneeFactory.create(user=valid_user)
+ workbasket = assignee.task.workbasket
+
+ url = reverse(
+ "workbaskets:workbasket-ui-unassign-users",
+ kwargs={"pk": workbasket.pk},
)
+ form_data = {
+ "assignees": [assignee.pk],
+ }
+
+ response = valid_user_client.get(url)
assert response.status_code == 200
- assert "Unassign users from workbasket" in str(response.content)
+
+ response = valid_user_client.post(url, form_data)
+ assert response.status_code == 302
+
+ assignee.refresh_from_db()
+
+ assert not assignee.is_assigned
+ assert TaskLog.objects.get(
+ task__workbasket=workbasket,
+ action=TaskLog.AuditActionType.TASK_UNASSIGNED,
+ instigator=response.wsgi_request.user,
+ )
def test_workbasket_unassign_users_view_without_permission(client, user_workbasket):
diff --git a/workbaskets/views/ui.py b/workbaskets/views/ui.py
index 3b7f682a8..62705411a 100644
--- a/workbaskets/views/ui.py
+++ b/workbaskets/views/ui.py
@@ -1661,7 +1661,6 @@ def get_form_kwargs(self):
)
return kwargs
- @atomic
def form_valid(self, form):
form.unassign_users()
return redirect(self.get_success_url())