diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index a4620a412..1bb09902a 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,5 +1,6 @@
-# TP-??? Your PR title here
+# TP2000-??? Your PR title here
+## Checklist
+- Requires migrations? Yes / No
+- Requires dependency updates? Yes / No
+
+
+
+
+
+
+
+
+
+
+
+ Status
+
+
+ {{ object.progress_state }}
+
+
+ Change status
+
+
+
+
+
+ Workbasket
+
+
+ {{ workbasket_linked_id }}
+
+
+ Change status
+
+
+
+ {% if object.parent_task %}
+ {{ parent_summary_list_row }}
+ {% endif %}
+
+
+
+ Created
+
+
+ {{ "{:%d %b %Y %H:%M}".format(object.created_at) }}
+
+
+
+
+
+
+
+
+ Created by
+
+
+ {{ object.creator.get_displayname() if object.creator else "-" }}
+
+
+
+
+
+
+
+
+ Assignees
+
+
+
+ {{ display_assignees(current_assignees) }}
+
+
+
+
+
+
+
+
+
+ {% if not object.parent_task %}
+ {{ govukButton({
+ "text": "Delete task",
+ "href": url("workflow:task-ui-delete", kwargs={"pk": object.pk}),
+ "classes": "govuk-button--warning"
+ }) }}
+ {% else %}
+ {{ govukButton({
+ "text": "Delete subtask",
+ "href": url("workflow:subtask-ui-delete", kwargs={"pk": object.pk}),
+ "classes": "govuk-button--warning"
+ }) }}
+ {% endif %}
+
+ {{ govukButton({
+ "text": "Find and view tasks",
+ "href": url("workflow:task-ui-list"),
+ "classes": "govuk-button--secondary"
+ }) }}
+
+ {% if not object.parent_task %}
+ {{ govukButton({
+ "text": "Create subtask",
+ "href": url("workflow:subtask-ui-create", kwargs={"parent_task_pk": object.pk}),
+ "classes": "govuk-button--secondary"
+ }) }}
+ {% endif %}
+
+
+{% set assign_users_link = url("workflow:task-ui-assign-users", kwargs={"pk": object.pk}) %}
+{% set unassign_users_link = url("workflow:task-ui-unassign-users", kwargs={"pk": object.pk}) %}
+
+
+{% endblock %}
diff --git a/tasks/jinja2/tasks/edit.jinja b/tasks/jinja2/tasks/edit.jinja
new file mode 100644
index 000000000..0f8ebe1ee
--- /dev/null
+++ b/tasks/jinja2/tasks/edit.jinja
@@ -0,0 +1,19 @@
+{% extends "layouts/form.jinja" %}
+{% from "components/breadcrumbs.jinja" import breadcrumbs %}
+
+
+{% 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}
+ ],
+ with_workbasket=False,
+ ) }}
+{% endblock %}
+
+{% block form %}
+ {% call django_form() %}
+ {{ crispy(form) }}
+ {% endcall %}
+{% endblock %}
diff --git a/tasks/jinja2/tasks/includes/task_list.jinja b/tasks/jinja2/tasks/includes/task_list.jinja
new file mode 100644
index 000000000..6aaff7228
--- /dev/null
+++ b/tasks/jinja2/tasks/includes/task_list.jinja
@@ -0,0 +1,23 @@
+{% from "components/table/macro.njk" import govukTable %}
+{% from "tasks/macros/display_details.jinja" import display_status %}
+
+{% set table_rows = [] %}
+
+{% for object in object_list %}
+ {% set object_link -%}
+ {{ object.title }}
+ {%- endset %}
+
+ {{ table_rows.append([
+ {"html": object_link},
+ {"text": display_status(object.progress_state.__str__())},
+ ]) or "" }}
+{% endfor %}
+
+{{ govukTable({
+ "head": [
+ {"text": "Name", "classes": "govuk-visually-hidden"},
+ {"text": "Status", "classes": "govuk-visually-hidden"},
+ ],
+ "rows": table_rows
+}) }}
diff --git a/tasks/jinja2/tasks/includes/task_queue.jinja b/tasks/jinja2/tasks/includes/task_queue.jinja
new file mode 100644
index 000000000..b8dcc46e9
--- /dev/null
+++ b/tasks/jinja2/tasks/includes/task_queue.jinja
@@ -0,0 +1,72 @@
+{% from "components/table/macro.njk" import govukTable %}
+{% from "tasks/macros/item_actions.jinja" import render_item_button %}
+
+{%- set table_rows = [] -%}
+
+{%- for obj in object_list %}
+ {%- set up_down_cell_content %}
+ {% if object_list|length == 1 %}
+ {# No buttons needed for a single item #}
+ {% elif loop.index == 1 %}
+ {{ render_item_button(obj, "demote", use_icon=True) }}
+ {% elif loop.index == 2 %}
+ {{ render_item_button(obj, "promote", use_icon=True) }}
+
+ {% if 2 < object_list | length %}
+ {{ render_item_button(obj, "demote", use_icon=True) }}
+ {% endif %}
+ {% elif loop.index == object_list | length %}
+ {{ render_item_button(obj, "promote", use_icon=True) }}
+ {% else %}
+ {{ render_item_button(obj, "promote", use_icon=True) }}
+
+ {{ render_item_button(obj, "demote", use_icon=True) }}
+ {% endif %}
+ {% endset -%}
+
+ {%- set title_cell_content %}
+ {{ obj.title }}
+ {% endset -%}
+
+ {%- set order_cell_content %}
+ {% if object_list|length == 1 %}
+ {# No buttons needed for a single item #}
+ {% elif loop.index == 1 %}
+ {{ render_item_button(obj, "demote_to_last") }}
+ {% elif loop.index == object_list | length %}
+ {{ render_item_button(obj, "promote_to_first") }}
+ {% else %}
+ {{ render_item_button(obj, "promote_to_first") }}
+
+ {{ render_item_button(obj, "demote_to_last") }}
+ {% endif %}
+ {% endset -%}
+
+ {%- set remove_cell_content %}
+ Remove
+ {% endset -%}
+
+ {{ table_rows.append([
+ {"html": up_down_cell_content},
+ {"text": loop.index},
+ {"html": title_cell_content},
+ {"html": order_cell_content},
+ {"html": remove_cell_content},
+ ]) or "" }}
+
+{% endfor -%}
+
+
diff --git a/tasks/jinja2/tasks/list.jinja b/tasks/jinja2/tasks/list.jinja
new file mode 100644
index 000000000..b85fe2a7a
--- /dev/null
+++ b/tasks/jinja2/tasks/list.jinja
@@ -0,0 +1,88 @@
+{% 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" %}
+
+{% block breadcrumb %}
+ {{ breadcrumbs(request, [
+ {"text": page_title}
+ ],
+ with_workbasket=False,
+ ) }}
+{% endblock %}
+
+{% 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 -%}
+ {% if object.workbasket %}
+
{{ object.workbasket.pk }}
+ {% else %}
+ -
+ {%- endif -%}
+ {%- endset -%}
+
+ {{ table_rows.append([
+ {"text": task_linked_id},
+ {"text": object.title},
+ {"text": object.category or "-"},
+ {"text": object.progress_state},
+ {"text": workbasket_linked_id},
+ {"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", sorting_urls["created_at"])},
+ ],
+ "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/jinja2/tasks/macros/display_details.jinja b/tasks/jinja2/tasks/macros/display_details.jinja
new file mode 100644
index 000000000..841324fbe
--- /dev/null
+++ b/tasks/jinja2/tasks/macros/display_details.jinja
@@ -0,0 +1,42 @@
+{% from "components/tag/macro.njk" import govukTag %}
+
+{% macro display_assignees(assignees) -%}
+ {% if not assignees %}
+ None
+ {% else %}
+ {% for assignee in assignees %}
+ {{ assignee.user.get_displayname() }}{% if not loop.last %}, {% endif %}
+ {% endfor %}
+ {% endif %}
+{%- endmacro %}
+
+{% macro display_workbasket(workbasket) -%}
+ {% if not workbasket %}
+ None
+ {% else %}
+ {{ workbasket.pk }} - {{ workbasket.status }}
+ {% endif %}
+{%- endmacro %}
+
+{% macro display_summary_row_stacked(key, value) -%}
+
+
{{ key }}
+ {{ value }}
+
+{%- endmacro %}
+
+{% macro display_status(status) %}
+ {% set status_to_class_map = ({
+ "In progress": "govuk-tag--blue",
+ "Done": "govuk-tag--green",
+ "To do": "govuk-tag--purple",
+ }) %}
+
+ {{ govukTag({
+ "text": status,
+ "classes": status_to_class_map[status],
+ }) }}
+{% endmacro %}
diff --git a/tasks/jinja2/tasks/macros/item_actions.jinja b/tasks/jinja2/tasks/macros/item_actions.jinja
new file mode 100644
index 000000000..89e209b0b
--- /dev/null
+++ b/tasks/jinja2/tasks/macros/item_actions.jinja
@@ -0,0 +1,29 @@
+{% macro render_item_button(obj, action, use_icon=False) %}
+ {% set action_text = {
+ "promote": "Move up",
+ "demote": "Move down",
+ "promote_to_first": "Move to first",
+ "demote_to_last": "Move to last",
+ }[action] %}
+
+ {% set icon_src = {
+ "promote": "/common/images/chevron-up.svg",
+ "demote": "/common/images/chevron-down.svg",
+ }[action] %}
+
+
+{% endmacro %}
diff --git a/tasks/jinja2/tasks/workflows/confirm_create.jinja b/tasks/jinja2/tasks/workflows/confirm_create.jinja
new file mode 100644
index 000000000..a84c0b9d6
--- /dev/null
+++ b/tasks/jinja2/tasks/workflows/confirm_create.jinja
@@ -0,0 +1,61 @@
+{% extends "common/confirm_create.jinja" %}
+
+{% from "components/breadcrumbs.jinja" import breadcrumbs %}
+{% from "components/panel/macro.njk" import govukPanel %}
+{% from "components/button/macro.njk" import govukButton %}
+
+{% set object_name = object._meta.verbose_name %}
+{% set page_title = object_name ~ " created" %}
+
+{% block breadcrumb %}
+ {{ breadcrumbs(
+ request,
+ [
+ {
+ "text": "Find and view workflow templates",
+ "href": object.get_url("list")},
+ {
+ "text": "Create a " ~ object_name,
+ "href": object.get_url("create"),
+ },
+ {"text": page_title}
+ ],
+ False,
+ ) }}
+{% endblock %}
+
+{% block panel %}
+ {{ govukPanel({
+ "titleText": object_name|capitalize ~ ": " ~ object,
+ "text": "You have created a new " ~ object_name,
+ "classes": "govuk-!-margin-bottom-7"
+ }) }}
+{% endblock %}
+
+{% block button_group %}
+ {{ govukButton({
+ "text": "View " ~ object_name,
+ "href": object.get_url("detail"),
+ "classes": "govuk-button"
+ }) }}
+
+ {% if object_name == "workflow" %}
+ {{ govukButton({
+ "text": "Create a task",
+ "href": "#TODO",
+ "classes": "govuk-button--secondary"
+ }) }}
+ {% elif object_name == "workflow template" %}
+ {{ govukButton({
+ "text": "Create a task template",
+ "href": url("workflow:task-template-ui-create", kwargs={"workflow_template_pk": object.pk}),
+ "classes": "govuk-button--secondary"
+ }) }}
+ {% endif %}
+{% endblock %}
+
+{% block actions %}
+
+ Find and view {{object_name}}s
+
+{% endblock %}
diff --git a/tasks/jinja2/tasks/workflows/confirm_delete.jinja b/tasks/jinja2/tasks/workflows/confirm_delete.jinja
new file mode 100644
index 000000000..5c333a081
--- /dev/null
+++ b/tasks/jinja2/tasks/workflows/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 = verbose_name|capitalize ~ " deleted" %}
+
+{% block breadcrumb %}
+ {{ breadcrumbs(
+ request,
+ [
+ {"text": "Find and view "~ verbose_name ~ "s", "href": list_url},
+ {"text": page_title}
+ ],
+ False,
+ ) }}
+{% endblock %}
+
+{% block panel %}
+ {{ govukPanel({
+ "titleText": verbose_name|capitalize ~ " ID: " ~ deleted_pk,
+ "text": verbose_name|capitalize ~ " has been deleted",
+ "classes": "govuk-!-margin-bottom-7"
+ }) }}
+{% endblock %}
+
+{% block button_group %}
+ {{ govukButton({
+ "text": "Find and view " ~ verbose_name ~ "s",
+ "href": list_url,
+ "classes": "govuk-button"
+ }) }}
+ {{ govukButton({
+ "text": "Create a " ~ verbose_name,
+ "href": create_url,
+ "classes": "govuk-button--secondary"
+ }) }}
+{% endblock %}
+
+{% block actions %}{% endblock %}
diff --git a/tasks/jinja2/tasks/workflows/confirm_update.jinja b/tasks/jinja2/tasks/workflows/confirm_update.jinja
new file mode 100644
index 000000000..1aeaed0e9
--- /dev/null
+++ b/tasks/jinja2/tasks/workflows/confirm_update.jinja
@@ -0,0 +1,46 @@
+{% extends "layouts/confirm.jinja" %}
+
+{% from "components/breadcrumbs.jinja" import breadcrumbs %}
+{% from "components/panel/macro.njk" import govukPanel %}
+{% from "components/button/macro.njk" import govukButton %}
+
+{% set page_title = verbose_name|capitalize ~ " updated" %}
+
+{% block breadcrumb %}
+ {{ breadcrumbs(
+ request,
+ [
+ {"text": "Find and view " ~ verbose_name ~ "s", "href": object.get_url("list")},
+ {"text": verbose_name|capitalize ~ " " ~ object.id, "href": object.get_url("detail")},
+ {"text": page_title}
+ ],
+ with_workbasket=False,
+ ) }}
+{% endblock %}
+
+{% block panel %}
+ {{ govukPanel({
+ "titleText": verbose_name|capitalize ~ " " ~ object.id,
+ "text": "You have updated the " ~ verbose_name,
+ "classes": "govuk-!-margin-bottom-7"
+ }) }}
+{% endblock %}
+
+{% block button_group %}
+ {{ govukButton({
+ "text": "View " ~ verbose_name,
+ "href": object.get_url("detail"),
+ "classes": "govuk-button"
+ }) }}
+ {% if verbose_name == "ticket template" %}
+ {{ govukButton({
+ "text": "Add a step",
+ "href": url("workflow:task-template-ui-create", kwargs={"workflow_template_pk": object.pk}),
+ "classes": "govuk-button--secondary"
+ }) }}
+ {% endif %}
+{% endblock %}
+
+{% block actions %}
+ Find and view {{verbose_name}}s
+{% endblock %}
diff --git a/tasks/jinja2/tasks/workflows/create.jinja b/tasks/jinja2/tasks/workflows/create.jinja
new file mode 100644
index 000000000..f2f0e13d8
--- /dev/null
+++ b/tasks/jinja2/tasks/workflows/create.jinja
@@ -0,0 +1,16 @@
+{% extends "layouts/create.jinja" %}
+
+{% from "components/breadcrumbs.jinja" import breadcrumbs %}
+
+{% set page_title = "Create a new " ~ verbose_name %}
+
+{% block breadcrumb %}
+ {{ breadcrumbs(
+ request,
+ [
+ {"text": "Find and view "~ verbose_name ~ "s", "href": list_url},
+ {"text": page_title}
+ ],
+ False,
+ ) }}
+{% endblock %}
diff --git a/tasks/jinja2/tasks/workflows/delete.jinja b/tasks/jinja2/tasks/workflows/delete.jinja
new file mode 100644
index 000000000..fb49ee889
--- /dev/null
+++ b/tasks/jinja2/tasks/workflows/delete.jinja
@@ -0,0 +1,32 @@
+{% extends "layouts/form.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 " ~ verbose_name ~ ": " ~ object.title %}
+
+{% block breadcrumb %}
+ {{ breadcrumbs(
+ request,
+ [
+ {"text": "Find and view "~ verbose_name ~ "s", "href": object.get_url("list")},
+ {
+ "text": verbose_name|capitalize ~ ": " ~ object.title,
+ "href": object.get_url("detail"),
+ },
+ {"text": page_title}
+ ],
+ False,
+ ) }}
+{% endblock %}
+
+{% block form %}
+ {{ govukWarningText({
+ "text": "Are you sure you want to delete this " ~ verbose_name ~ "?",
+ }) }}
+
+ {% call django_form(action=object.get_url("delete")) %}
+ {{ crispy(form) }}
+ {% endcall %}
+{% endblock %}
diff --git a/tasks/jinja2/tasks/workflows/detail.jinja b/tasks/jinja2/tasks/workflows/detail.jinja
new file mode 100644
index 000000000..16c1b852a
--- /dev/null
+++ b/tasks/jinja2/tasks/workflows/detail.jinja
@@ -0,0 +1,90 @@
+{% extends "layouts/layout.jinja" %}
+{% from "components/breadcrumbs.jinja" import breadcrumbs %}
+{% from "components/button/macro.njk" import govukButton %}
+{% from "tasks/macros/display_details.jinja" import display_assignees %}
+{% from "tasks/macros/display_details.jinja" import display_workbasket %}
+{% from "tasks/macros/display_details.jinja" import display_summary_row_stacked %}
+{% from "tasks/macros/display_details.jinja" import display_status %}
+
+{% set page_title = verbose_name|capitalize ~ " " ~ object.id %}
+
+{% block breadcrumb %}
+ {{ breadcrumbs(request, [
+ {"text": "Find and view " ~ verbose_name ~ "s", "href": object.get_url("list")},
+ {"text": page_title}
+ ],
+ with_workbasket=False,
+ ) }}
+{% endblock %}
+
+{% block content %}
+
+
+
+ {{ object.title }}
+ {{ page_title }}
+
+
+
+
+ {{ govukButton({
+ "text": "Edit " ~ verbose_name,
+ "href": object.get_url("edit"),
+ "classes": "align-right",
+ }) }}
+
+
+
+
+
+
+
+
Description
+
{{ object.description }}
+
+
Steps
+ {% if object_list %}
+ {% include list_include %}
+ {% else %}
+
There are no steps for this {{ verbose_name }}.
+ {% endif %}
+
+
+
+
Details
+
+ {% if verbose_name == "ticket" %}
+ {{ display_summary_row_stacked("Workbasket", display_workbasket(object.summary_task.workbasket)) }}
+ {{ display_summary_row_stacked("Status", display_status(object.summary_task.progress_state.__str__())) }}
+ {{ display_summary_row_stacked("Assignee", display_assignees(object.summary_task.assignees.assigned())) }}
+ {{ display_summary_row_stacked("Work type", object.creator_template.title) }}
+ {{ display_summary_row_stacked("Created date", object.summary_task.created_at|format_date) }}
+ {{ display_summary_row_stacked("Entry into force date", object.eif_date|format_date) }}
+ {{ display_summary_row_stacked("Policy contact", object.policy_contact or "None") }}
+ {% elif verbose_name == "ticket template" %}
+ {{ display_summary_row_stacked("Created by", object.creator.get_displayname()) }}
+ {{ display_summary_row_stacked("Created date", object.created_at|format_date) }}
+ {% endif %}
+
+
+
+
+ {% if verbose_name == "ticket template" %}
+
+
+
+ {{ govukButton({
+ "text": "Add a step",
+ "href": url("workflow:task-template-ui-create", kwargs={"workflow_template_pk": object.pk}),
+ }) }}
+ {{ govukButton({
+ "text": "Delete " ~ verbose_name,
+ "href": object.get_url("delete"),
+ "classes": "govuk-button--warning"
+ }) }}
+
+
+
+ {% endif %}
+
+{% endblock %}
diff --git a/tasks/jinja2/tasks/workflows/edit.jinja b/tasks/jinja2/tasks/workflows/edit.jinja
new file mode 100644
index 000000000..25feda85b
--- /dev/null
+++ b/tasks/jinja2/tasks/workflows/edit.jinja
@@ -0,0 +1,20 @@
+{% extends "layouts/form.jinja" %}
+{% from "components/breadcrumbs.jinja" import breadcrumbs %}
+
+{% set page_title = "Edit " ~ verbose_name ~ " " ~ object.id %}
+
+{% block breadcrumb %}
+ {{ breadcrumbs(request, [
+ {"text": "Find and view " ~ verbose_name ~ "s", "href": object.get_url("list")},
+ {"text": verbose_name|capitalize ~ " " ~ object.id, "href": object.get_url("detail")},
+ {"text": page_title}
+ ],
+ with_workbasket=False,
+ )}}
+{% endblock %}
+
+{% block form %}
+ {% call django_form() %}
+ {{ crispy(form) }}
+ {% endcall %}
+{% endblock %}
diff --git a/tasks/jinja2/tasks/workflows/list.jinja b/tasks/jinja2/tasks/workflows/list.jinja
new file mode 100644
index 000000000..18b7a72a9
--- /dev/null
+++ b/tasks/jinja2/tasks/workflows/list.jinja
@@ -0,0 +1,92 @@
+{% 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 workflows" %}
+
+{% block breadcrumb %}
+ {{ breadcrumbs(request, [
+ {"text": page_title}
+ ],
+ with_workbasket=False,
+ ) }}
+{% endblock %}
+
+{% block content %}
+ {{ page_title }}
+
+ Search for workflows. Alternatively,
+ create a new workflow
+
+
+
+
+
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.taskworkflow.pk }}
+ {%- endset -%}
+
+ {%- set workbasket_linked_id -%}
+ {% if object.workbasket %}
+
{{ object.workbasket.pk }}
+ {%- else -%}
+ -
+ {%- endif -%}
+ {%- endset -%}
+
+ {{ table_rows.append([
+ {"text": task_linked_id},
+ {"text": object.title},
+ {"text": object.category or "-"},
+ {"text": object.progress_state},
+ {"text": workbasket_linked_id},
+ {"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", sorting_urls["created_at"])},
+ ],
+ "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/jinja2/tasks/workflows/task-and-workflow-list.jinja b/tasks/jinja2/tasks/workflows/task-and-workflow-list.jinja
new file mode 100644
index 000000000..b9f7f8d1d
--- /dev/null
+++ b/tasks/jinja2/tasks/workflows/task-and-workflow-list.jinja
@@ -0,0 +1,111 @@
+{% 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 and workflows" %}
+
+{% block breadcrumb %}
+ {{ breadcrumbs(request, [
+ {"text": page_title}
+ ],
+ with_workbasket=False,
+ ) }}
+{% endblock %}
+
+{% block content %}
+ {{ page_title }}
+
+ Search for tasks and workflows. Alternatively
+ create a new task
+ or
+ create a new workflow.
+
+
+
+
+
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 -%}
+ {% if object.taskworkflow %}
+
{{ object.pk }}
+ {% else %}
+
{{ object.pk }}
+ {% endif %}
+ {%- endset -%}
+
+ {%- set workbasket_linked_id -%}
+ {% if object.workbasket %}
+
{{ object.workbasket.pk }}
+ {%- else -%}
+ -
+ {%- endif -%}
+ {%- endset -%}
+
+ {%- set object_type -%}
+ {% if object.is_summary_task %}
+ Workflow
+ {% else %}
+ Task
+ {% endif %}
+ {%- endset -%}
+
+ {{ table_rows.append([
+ {"text": task_linked_id},
+ {"text": object.title},
+ {"text": object_type},
+ {"text": object.category},
+ {"text": object.progress_state},
+ {"text": workbasket_linked_id},
+ {"text": "{:%d %b %Y}".format(object.created_at)},
+ ]) or "" }}
+ {% endfor %}
+
+ {{ govukTable({
+ "head": [
+ {"text": "ID"},
+ {"text": "Title"},
+ {"text": "Type"},
+ {"text": "Category"},
+ {"text": "Status"},
+ {"text": "Workbasket"},
+ {"text": create_sortable_anchor(request, "Created", sorting_urls["created_at"])},
+ ],
+ "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/jinja2/tasks/workflows/task_template_confirm_create.jinja b/tasks/jinja2/tasks/workflows/task_template_confirm_create.jinja
new file mode 100644
index 000000000..e3dcac8b0
--- /dev/null
+++ b/tasks/jinja2/tasks/workflows/task_template_confirm_create.jinja
@@ -0,0 +1,53 @@
+{% extends "common/confirm_create.jinja" %}
+
+{% from "components/breadcrumbs.jinja" import breadcrumbs %}
+{% from "components/panel/macro.njk" import govukPanel %}
+{% from "components/button/macro.njk" import govukButton %}
+
+{% set page_title = "Task template created" %}
+
+
+{% block breadcrumb %}
+ {{ breadcrumbs(
+ request,
+ [
+ {
+ "text": "Find and view workflow templates",
+ "href": url("workflow:task-workflow-template-ui-list"),
+ },
+ {
+ "text": "Workflow template: " ~ task_workflow_template.title,
+ "href": url(
+ "workflow:task-workflow-template-ui-detail",
+ kwargs={"pk": task_workflow_template.pk}),
+ },
+ {"text": page_title}
+ ],
+ False,
+ ) }}
+{% endblock %}
+
+{% block panel %}
+ {{ govukPanel({
+ "titleText": "Task template: " ~ object.title,
+ "text": "You have created a new task template",
+ "classes": "govuk-!-margin-bottom-7"
+ }) }}
+{% endblock %}
+
+{% block button_group %}
+ {{ govukButton({
+ "text": "View task template",
+ "href": url("workflow:task-template-ui-detail", kwargs={"pk": object.pk}),
+ "classes": "govuk-button"
+ }) }}
+ {{ govukButton({
+ "text": "Return to workflow template",
+ "href": url("workflow:task-workflow-template-ui-detail", kwargs={"pk": task_workflow_template.pk}),
+ "classes": "govuk-button--secondary"
+ }) }}
+{% endblock %}
+
+{% block actions %}
+ Find and edit workflow templates
+{% endblock %}
diff --git a/tasks/jinja2/tasks/workflows/task_template_confirm_delete.jinja b/tasks/jinja2/tasks/workflows/task_template_confirm_delete.jinja
new file mode 100644
index 000000000..66d11df2e
--- /dev/null
+++ b/tasks/jinja2/tasks/workflows/task_template_confirm_delete.jinja
@@ -0,0 +1,61 @@
+{% 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 template deleted" %}
+
+
+{% block breadcrumb %}
+ {{ breadcrumbs(
+ request,
+ [
+ {
+ "text": "Find and view workflow templates",
+ "href": url("workflow:task-workflow-template-ui-list"),
+ },
+ {
+ "text": "Workflow template: " ~ task_workflow_template.title,
+ "href": url(
+ "workflow:task-workflow-template-ui-detail",
+ kwargs={"pk": task_workflow_template.pk},
+ ),
+ },
+ {"text": page_title}
+ ],
+ False,
+ ) }}
+{% endblock %}
+
+{% block panel %}
+ {{ govukPanel({
+ "titleText": "Task template ID: " ~ deleted_pk,
+ "text": "Task template has been deleted",
+ "classes": "govuk-!-margin-bottom-7"
+ }) }}
+{% endblock %}
+
+{% block button_group %}
+ {{ govukButton({
+ "text": "View workflow template",
+ "href": url(
+ "workflow:task-workflow-template-ui-detail",
+ kwargs={"pk": task_workflow_template.pk},
+ ),
+ "classes": "govuk-button"
+ }) }}
+ {{ govukButton({
+ "text": "Return to homepage",
+ "href": url("home"),
+ "classes": "govuk-button--secondary"
+ }) }}
+{% endblock %}
+
+{% block actions %}
+
+ Create a task template
+
+{% endblock %}
diff --git a/tasks/jinja2/tasks/workflows/task_template_confirm_update.jinja b/tasks/jinja2/tasks/workflows/task_template_confirm_update.jinja
new file mode 100644
index 000000000..37c745db3
--- /dev/null
+++ b/tasks/jinja2/tasks/workflows/task_template_confirm_update.jinja
@@ -0,0 +1,52 @@
+{% extends "common/confirm_update.jinja" %}
+
+{% from "components/breadcrumbs.jinja" import breadcrumbs %}
+{% from "components/panel/macro.njk" import govukPanel %}
+{% from "components/button/macro.njk" import govukButton %}
+
+
+{% block breadcrumb %}
+ {{ breadcrumbs(
+ request,
+ [
+ {
+ "text": "Find and view workflow templates",
+ "href": url("workflow:task-workflow-template-ui-list"),
+ },
+ {
+ "text": "Workflow template: " ~ task_workflow_template.title,
+ "href": url(
+ "workflow:task-workflow-template-ui-detail",
+ kwargs={"pk": task_workflow_template.pk},
+ ),
+ },
+ {"text": page_title}
+ ],
+ False,
+ ) }}
+{% endblock %}
+
+{% block panel %}
+ {{ govukPanel({
+ "titleText": "Task template: " ~ object.title,
+ "text": "Task template has been updated",
+ "classes": "govuk-!-margin-bottom-7"
+ }) }}
+{% endblock %}
+
+{% block button_group %}
+ {{ govukButton({
+ "text": "View task template",
+ "href": url("workflow:task-template-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 edit workflow templates
+{% endblock %}
diff --git a/tasks/jinja2/tasks/workflows/task_template_delete.jinja b/tasks/jinja2/tasks/workflows/task_template_delete.jinja
new file mode 100644
index 000000000..0bb6a8066
--- /dev/null
+++ b/tasks/jinja2/tasks/workflows/task_template_delete.jinja
@@ -0,0 +1,43 @@
+{% 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 template:" ~ object.title %}
+
+
+{% block breadcrumb %}
+ {{ breadcrumbs(
+ request,
+ [
+ {
+ "text": "Find and view workflow templates",
+ "href": url("workflow:task-workflow-template-ui-list"),
+ },
+ {
+ "text": "Workflow template: " ~ task_workflow_template.title,
+ "href": url("workflow:task-workflow-template-ui-detail", kwargs={"pk": task_workflow_template.pk}),
+ },
+ {"text": page_title}
+ ],
+ False,
+ ) }}
+{% endblock %}
+
+{% block form %}
+ {{ govukWarningText({
+ "text": "Are you sure you want to delete this task template?"
+ }) }}
+
+ {% call django_form(
+ action=url(
+ "workflow:task-template-ui-delete",
+ kwargs={
+ "workflow_template_pk": task_workflow_template.pk,
+ "pk": object.pk,
+ }),
+ ) %}
+ {{ crispy(form) }}
+ {% endcall %}
+{% endblock %}
diff --git a/tasks/jinja2/tasks/workflows/task_template_detail.jinja b/tasks/jinja2/tasks/workflows/task_template_detail.jinja
new file mode 100644
index 000000000..286cd9ad5
--- /dev/null
+++ b/tasks/jinja2/tasks/workflows/task_template_detail.jinja
@@ -0,0 +1,77 @@
+{% extends "layouts/layout.jinja" %}
+
+{% from "components/breadcrumbs.jinja" import breadcrumbs %}
+{% from "components/button/macro.njk" import govukButton %}
+
+{% set page_title = "Task template: " ~ object.title %}
+
+
+{% block breadcrumb %}
+ {{ breadcrumbs(
+ request,
+ [
+ {
+ "text": "Find and view workflow templates",
+ "href": url("workflow:task-workflow-template-ui-list"),
+ },
+ {
+ "text": "Workflow template: " ~ task_workflow_template.title,
+ "href": url(
+ "workflow:task-workflow-template-ui-detail",
+ kwargs={"pk": task_workflow_template.pk}),
+ },
+ {"text": page_title}
+ ],
+ False,
+ ) }}
+{% endblock %}
+
+{% block content %}
+ {{ page_title }}
+
+ Details
+
+
+
+
- ID
+ - {{ object.pk }}
+
+
+
+
+
+
+
+
+
+ {{ govukButton({
+ "text": "Delete task template",
+ "href": url(
+ "workflow:task-template-ui-delete",
+ kwargs={
+ "workflow_template_pk": task_workflow_template.pk,
+ "pk": object.pk,
+ },
+ ),
+
+ "classes": "govuk-button--warning"
+ }) }}
+ {{ govukButton({
+ "text": "Return to workflow template",
+ "href": url("workflow:task-workflow-template-ui-detail", kwargs={"pk": task_workflow_template.pk}),
+ "classes": "govuk-button--secondary"
+ }) }}
+
+{% endblock %}
diff --git a/tasks/jinja2/tasks/workflows/task_template_save.jinja b/tasks/jinja2/tasks/workflows/task_template_save.jinja
new file mode 100644
index 000000000..5ac41d48d
--- /dev/null
+++ b/tasks/jinja2/tasks/workflows/task_template_save.jinja
@@ -0,0 +1,22 @@
+{% extends 'layouts/create.jinja' %}
+
+{% from "components/breadcrumbs.jinja" import breadcrumbs %}
+
+
+{% block breadcrumb %}
+ {{ breadcrumbs(
+ request,
+ [
+ {
+ "text": "Find and view workflow templates",
+ "href": url("workflow:task-workflow-template-ui-list"),
+ },
+ {
+ "text": "Workflow template: " ~ task_workflow_template.title,
+ "href": url("workflow:task-workflow-template-ui-detail", kwargs={"pk": task_workflow_template.pk}),
+ },
+ {"text": page_title}
+ ],
+ False,
+ ) }}
+{% endblock %}
diff --git a/tasks/jinja2/tasks/workflows/template_list.jinja b/tasks/jinja2/tasks/workflows/template_list.jinja
new file mode 100644
index 000000000..3900678c5
--- /dev/null
+++ b/tasks/jinja2/tasks/workflows/template_list.jinja
@@ -0,0 +1,77 @@
+{% 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 workflow templates" %}
+
+{% block breadcrumb %}
+ {{ breadcrumbs(request, [
+ {"text": page_title}
+ ],
+ with_workbasket=False,
+ ) }}
+{% endblock %}
+
+{% block content %}
+ {{ page_title }}
+
+ Search for workflow templates.
+ Alternatively, create a new workflow template.
+
+
+
+
+
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 workflow_template_linked_id -%}
+
{{ object.pk }}
+ {%- endset -%}
+
+ {{ table_rows.append([
+ {"text": workflow_template_linked_id},
+ {"text": object.title},
+ {"text": object.description|truncate(75)},
+ {"text": object.creator.get_displayname() if object.creator else "Unknown"},
+ {"text": object.created_at.strftime(datetime_format)},
+ {"text": object.updated_at.strftime(datetime_format)},
+ ]) or "" }}
+ {% endfor %}
+
+ {{ govukTable({
+ "head": [
+ {"text": "ID"},
+ {"text": "Title"},
+ {"text": "Description"},
+ {"text": "Created by"},
+ {"text": create_sortable_anchor(request, "Created", sorting_urls["created_at"])},
+ {"text": create_sortable_anchor(request, "Last updated", sorting_urls["updated_at"])},
+ ],
+ "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 %}
\ No newline at end of file
diff --git a/tasks/migrations/0003_rename_userassignment_taskassignee.py b/tasks/migrations/0003_rename_userassignment_taskassignee.py
new file mode 100644
index 000000000..45e8c73c1
--- /dev/null
+++ b/tasks/migrations/0003_rename_userassignment_taskassignee.py
@@ -0,0 +1,60 @@
+# Generated by Django 4.2.14 on 2024-09-05 14:06
+
+from django.conf import settings
+from django.db import migrations
+from django.db import models
+
+
+def forwards_delete_outdated_permissions(apps, schema_editor):
+ """Deletes old set of permissions belonging to `UserAssignment` following
+ the renaming to `TaskAssignee` as Django creates a new set of permissions
+ for the renamed table."""
+ Permission = apps.get_model("auth.Permission")
+ Permission.objects.filter(
+ models.Q(codename="add_userassignment")
+ | models.Q(codename="change_userassignment")
+ | models.Q(codename="delete_userassignment")
+ | models.Q(codename="view_userassignment"),
+ ).delete()
+
+
+def backwards_delete_outdated_permissions(apps, schema_editor):
+ """Deletes old set of permissions belonging to `TaskAssignee` following the
+ renaming back to `UserAssignment` as Django creates a new set of permissions
+ for the renamed table."""
+ Permission = apps.get_model("auth.Permission")
+ Permission.objects.filter(
+ models.Q(codename="add_taskassignee")
+ | models.Q(codename="change_taskassignee")
+ | models.Q(codename="delete_taskassignee")
+ | models.Q(codename="view_taskassignee"),
+ ).delete()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("tasks", "0002_comment"),
+ ]
+
+ operations = [
+ migrations.RenameModel(
+ old_name="UserAssignment",
+ new_name="TaskAssignee",
+ ),
+ migrations.AlterField(
+ model_name="taskassignee",
+ name="task",
+ field=models.ForeignKey(
+ editable=False,
+ on_delete=models.deletion.CASCADE,
+ related_name="assignees",
+ to="tasks.task",
+ ),
+ ),
+ migrations.RunPython(
+ forwards_delete_outdated_permissions,
+ backwards_delete_outdated_permissions,
+ ),
+ ]
diff --git a/tasks/migrations/0004_taskcategory_taskprogressstate_task_category_and_more.py b/tasks/migrations/0004_taskcategory_taskprogressstate_task_category_and_more.py
new file mode 100644
index 000000000..f76c7f31b
--- /dev/null
+++ b/tasks/migrations/0004_taskcategory_taskprogressstate_task_category_and_more.py
@@ -0,0 +1,134 @@
+# Generated by Django 4.2.14 on 2024-09-06 14:33
+
+import django.db.models.deletion
+from django.db import migrations
+from django.db import models
+
+from tasks.models import ProgressState as TaskProgressState
+from workbaskets.validators import WorkflowStatus
+
+
+def forwards_create_default_task_progress_state_instances(apps, schema_editor):
+ """Creates default `ProgressState` instances for `TO_DO`, `IN_PROGRESS` and
+ `DONE`."""
+ ProgressState = apps.get_model("tasks", "ProgressState")
+ progress_states = [ProgressState(name=state) for state in TaskProgressState.State]
+ ProgressState.objects.bulk_create(progress_states)
+
+
+def forwards_update_existing_tasks_progress_state(apps, schema_editor):
+ """
+ Updates `progress_state` on existing `Task` instances that have an
+ associated workbasket.
+
+ Tasks with an unpublished workbasket are set to `ProgressState.State.IN_PROGRESS`
+ and tasks with a published workbasket are set to `ProgressState.State.DONE`.
+ """
+ ProgressState = apps.get_model("tasks", "ProgressState")
+ inprogress_state = ProgressState.objects.get(
+ name=TaskProgressState.State.IN_PROGRESS,
+ )
+ done_state = ProgressState.objects.get(name=TaskProgressState.State.DONE)
+ Task = apps.get_model("tasks", "Task")
+
+ Task.objects.filter(
+ models.Q(workbasket__status=WorkflowStatus.EDITING)
+ | models.Q(workbasket__status=WorkflowStatus.QUEUED)
+ | models.Q(workbasket__status=WorkflowStatus.ERRORED),
+ ).update(progress_state=inprogress_state)
+
+ Task.objects.filter(
+ models.Q(workbasket__status=WorkflowStatus.PUBLISHED)
+ | models.Q(workbasket__status=WorkflowStatus.ARCHIVED),
+ ).update(progress_state=done_state)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("tasks", "0003_rename_userassignment_taskassignee"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Category",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("name", models.CharField(max_length=255, unique=True)),
+ ],
+ options={
+ "ordering": ["name"],
+ "verbose_name_plural": "categories",
+ },
+ ),
+ migrations.CreateModel(
+ name="ProgressState",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "name",
+ models.CharField(
+ choices=[
+ ("TO_DO", "To do"),
+ ("IN_PROGRESS", "In progress"),
+ ("DONE", "Done"),
+ ],
+ max_length=255,
+ unique=True,
+ ),
+ ),
+ ],
+ ),
+ migrations.RunPython(
+ forwards_create_default_task_progress_state_instances,
+ migrations.RunPython.noop,
+ ),
+ migrations.AddField(
+ model_name="task",
+ name="category",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="tasks.category",
+ ),
+ ),
+ migrations.AddField(
+ model_name="task",
+ name="progress_state",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="tasks.progressstate",
+ ),
+ ),
+ migrations.RunPython(
+ forwards_update_existing_tasks_progress_state,
+ migrations.RunPython.noop,
+ ),
+ migrations.AlterField(
+ model_name="task",
+ name="progress_state",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to="tasks.progressstate",
+ ),
+ ),
+ ]
diff --git a/tasks/migrations/0005_task_parent_task.py b/tasks/migrations/0005_task_parent_task.py
new file mode 100644
index 000000000..fa287684c
--- /dev/null
+++ b/tasks/migrations/0005_task_parent_task.py
@@ -0,0 +1,26 @@
+# Generated by Django 4.2.14 on 2024-09-09 08:14
+
+import django.db.models.deletion
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("tasks", "0004_taskcategory_taskprogressstate_task_category_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="task",
+ name="parent_task",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="subtasks",
+ to="tasks.task",
+ ),
+ ),
+ ]
diff --git a/tasks/migrations/0006_task_creator.py b/tasks/migrations/0006_task_creator.py
new file mode 100644
index 000000000..14a36da18
--- /dev/null
+++ b/tasks/migrations/0006_task_creator.py
@@ -0,0 +1,27 @@
+# Generated by Django 4.2.14 on 2024-09-09 08:46
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("tasks", "0005_task_parent_task"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="task",
+ name="creator",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="created_tasks",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ]
diff --git a/tasks/migrations/0007_create_tasklog.py b/tasks/migrations/0007_create_tasklog.py
new file mode 100644
index 000000000..05659f3fa
--- /dev/null
+++ b/tasks/migrations/0007_create_tasklog.py
@@ -0,0 +1,71 @@
+# Generated by Django 4.2.14 on 2024-09-09 11:03
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("tasks", "0006_task_creator"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="taskassignee",
+ name="assigned_by",
+ ),
+ migrations.CreateModel(
+ name="TaskLog",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ (
+ "action",
+ models.CharField(
+ choices=[
+ ("TASK_ASSIGNED", "Task Assigned"),
+ ("TASK_UNASSIGNED", "Task Unassigned"),
+ ("PROGRESS_STATE_UPDATED", "Progress State Updated"),
+ ],
+ editable=False,
+ max_length=100,
+ ),
+ ),
+ ("description", models.TextField(editable=False)),
+ (
+ "instigator",
+ models.ForeignKey(
+ editable=False,
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "task",
+ models.ForeignKey(
+ editable=False,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="logs",
+ to="tasks.task",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ ]
diff --git a/tasks/migrations/0008_alter_task_workbasket.py b/tasks/migrations/0008_alter_task_workbasket.py
new file mode 100644
index 000000000..391ef9983
--- /dev/null
+++ b/tasks/migrations/0008_alter_task_workbasket.py
@@ -0,0 +1,27 @@
+# Generated by Django 4.2.14 on 2024-09-10 18:06
+
+import django.db.models.deletion
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("workbaskets", "0008_datarow_dataupload"),
+ ("tasks", "0007_create_tasklog"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="task",
+ name="workbasket",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="tasks",
+ to="workbaskets.workbasket",
+ ),
+ ),
+ ]
diff --git a/tasks/migrations/0009_taskworkflowtemplate_taskworkflow_tasktemplate_and_more.py b/tasks/migrations/0009_taskworkflowtemplate_taskworkflow_tasktemplate_and_more.py
new file mode 100644
index 000000000..7eaf5611d
--- /dev/null
+++ b/tasks/migrations/0009_taskworkflowtemplate_taskworkflow_tasktemplate_and_more.py
@@ -0,0 +1,165 @@
+# Generated by Django 4.2.15 on 2024-11-04 12:23
+
+import django.db.models.deletion
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("tasks", "0008_alter_task_workbasket"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="TaskWorkflowTemplate",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("title", models.CharField(max_length=255)),
+ ("description", models.TextField(blank=True)),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.CreateModel(
+ name="TaskWorkflow",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("title", models.CharField(max_length=255)),
+ ("description", models.TextField(blank=True)),
+ (
+ "creator_template",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="tasks.taskworkflowtemplate",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.CreateModel(
+ name="TaskTemplate",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ ("title", models.CharField(max_length=255)),
+ ("description", models.TextField()),
+ (
+ "category",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="tasks.category",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.CreateModel(
+ name="TaskItemTemplate",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "position",
+ models.PositiveSmallIntegerField(db_index=True, editable=False),
+ ),
+ (
+ "queue",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="queue_items",
+ to="tasks.taskworkflowtemplate",
+ ),
+ ),
+ (
+ "task_template",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="tasks.tasktemplate",
+ ),
+ ),
+ ],
+ options={
+ "ordering": ["queue", "position"],
+ "abstract": False,
+ },
+ ),
+ migrations.CreateModel(
+ name="TaskItem",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "position",
+ models.PositiveSmallIntegerField(db_index=True, editable=False),
+ ),
+ (
+ "queue",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="queue_items",
+ to="tasks.taskworkflow",
+ ),
+ ),
+ (
+ "task",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="tasks.task",
+ ),
+ ),
+ ],
+ options={
+ "ordering": ["queue", "position"],
+ "abstract": False,
+ },
+ ),
+ ]
diff --git a/tasks/migrations/0010_alter_task_progress_state.py b/tasks/migrations/0010_alter_task_progress_state.py
new file mode 100644
index 000000000..08557c906
--- /dev/null
+++ b/tasks/migrations/0010_alter_task_progress_state.py
@@ -0,0 +1,26 @@
+# Generated by Django 4.2.16 on 2024-11-12 15:08
+
+import django.db.models.deletion
+from django.db import migrations
+from django.db import models
+
+import tasks.models.task
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("tasks", "0009_taskworkflowtemplate_taskworkflow_tasktemplate_and_more"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="task",
+ name="progress_state",
+ field=models.ForeignKey(
+ default=tasks.models.task.ProgressState.get_default_state_id,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="tasks.progressstate",
+ ),
+ ),
+ ]
diff --git a/tasks/migrations/0011_taskworkflow_summary_task.py b/tasks/migrations/0011_taskworkflow_summary_task.py
new file mode 100644
index 000000000..3c9e9c435
--- /dev/null
+++ b/tasks/migrations/0011_taskworkflow_summary_task.py
@@ -0,0 +1,24 @@
+# Generated by Django 4.2.16 on 2024-11-20 17:22
+
+import django.db.models.deletion
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("tasks", "0010_alter_task_progress_state"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="taskworkflow",
+ name="summary_task",
+ field=models.OneToOneField(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="tasks.task",
+ ),
+ ),
+ ]
diff --git a/tasks/migrations/0012_taskworkflow_assign_summary_task.py b/tasks/migrations/0012_taskworkflow_assign_summary_task.py
new file mode 100644
index 000000000..ac8848ab8
--- /dev/null
+++ b/tasks/migrations/0012_taskworkflow_assign_summary_task.py
@@ -0,0 +1,24 @@
+# Generated by Django 4.2.16 on 2024-11-20 17:27
+
+from django.db import migrations
+
+
+def assign_new_summary_task_on_workflow(apps, schema_editor):
+ Task = apps.get_model("tasks", "Task")
+ TaskWorkflow = apps.get_model("tasks", "TaskWorkflow")
+
+ for task_workflow in TaskWorkflow.objects.all():
+ task_workflow.summary_task = Task.objects.create(
+ title="Workflow summarising task",
+ description="Workflow summarising task.",
+ )
+ task_workflow.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("tasks", "0011_taskworkflow_summary_task"),
+ ]
+
+ operations = [migrations.RunPython(assign_new_summary_task_on_workflow)]
diff --git a/tasks/migrations/0013_alter_taskworkflow_summary_task.py b/tasks/migrations/0013_alter_taskworkflow_summary_task.py
new file mode 100644
index 000000000..8e257e1a0
--- /dev/null
+++ b/tasks/migrations/0013_alter_taskworkflow_summary_task.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.2.16 on 2024-11-20 17:31
+
+import django.db.models.deletion
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("tasks", "0012_taskworkflow_assign_summary_task"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="taskworkflow",
+ name="summary_task",
+ field=models.OneToOneField(
+ on_delete=django.db.models.deletion.PROTECT,
+ to="tasks.task",
+ ),
+ ),
+ ]
diff --git a/tasks/migrations/0014_remove_taskworkflow_description_and_more.py b/tasks/migrations/0014_remove_taskworkflow_description_and_more.py
new file mode 100644
index 000000000..91757eed9
--- /dev/null
+++ b/tasks/migrations/0014_remove_taskworkflow_description_and_more.py
@@ -0,0 +1,30 @@
+# Generated by Django 4.2.16 on 2024-11-20 17:46
+
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("tasks", "0013_alter_taskworkflow_summary_task"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="taskworkflow",
+ name="description",
+ ),
+ migrations.RemoveField(
+ model_name="taskworkflow",
+ name="title",
+ ),
+ migrations.AlterField(
+ model_name="taskworkflowtemplate",
+ name="description",
+ field=models.TextField(
+ blank=True,
+ help_text="Description of what this workflow template is used for. ",
+ ),
+ ),
+ ]
diff --git a/tasks/migrations/0015_alter_taskworkflow_options_and_more.py b/tasks/migrations/0015_alter_taskworkflow_options_and_more.py
new file mode 100644
index 000000000..41e835e11
--- /dev/null
+++ b/tasks/migrations/0015_alter_taskworkflow_options_and_more.py
@@ -0,0 +1,21 @@
+# Generated by Django 4.2.15 on 2024-11-26 13:55
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("tasks", "0014_remove_taskworkflow_description_and_more"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="taskworkflow",
+ options={"verbose_name": "workflow"},
+ ),
+ migrations.AlterModelOptions(
+ name="taskworkflowtemplate",
+ options={"verbose_name": "workflow template"},
+ ),
+ ]
diff --git a/tasks/migrations/0016_taskworkflowtemplate_creator.py b/tasks/migrations/0016_taskworkflowtemplate_creator.py
new file mode 100644
index 000000000..68ad3ffa1
--- /dev/null
+++ b/tasks/migrations/0016_taskworkflowtemplate_creator.py
@@ -0,0 +1,27 @@
+# Generated by Django 4.2.15 on 2024-12-05 15:15
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("tasks", "0015_alter_taskworkflow_options_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="taskworkflowtemplate",
+ name="creator",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="created_taskworkflowtemplates",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ]
diff --git a/tasks/migrations/0017_rename_queue_taskitem_workflow_and_more.py b/tasks/migrations/0017_rename_queue_taskitem_workflow_and_more.py
new file mode 100644
index 000000000..1c2c8b37d
--- /dev/null
+++ b/tasks/migrations/0017_rename_queue_taskitem_workflow_and_more.py
@@ -0,0 +1,51 @@
+# Generated by Django 4.2.15 on 2024-12-05 15:46
+
+import django.db.models.deletion
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("tasks", "0016_taskworkflowtemplate_creator"),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name="taskitem",
+ old_name="queue",
+ new_name="workflow",
+ ),
+ migrations.AlterField(
+ model_name="taskitem",
+ name="workflow",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="workflow_items",
+ to="tasks.taskworkflow",
+ ),
+ ),
+ migrations.AlterModelOptions(
+ name="taskitem",
+ options={"ordering": ["workflow", "position"]},
+ ),
+ migrations.RenameField(
+ model_name="taskitemtemplate",
+ old_name="queue",
+ new_name="workflow_template",
+ ),
+ migrations.AlterField(
+ model_name="taskitemtemplate",
+ name="workflow_template",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="workflow_template_items",
+ to="tasks.taskworkflowtemplate",
+ ),
+ ),
+ migrations.AlterModelOptions(
+ name="taskitemtemplate",
+ options={"ordering": ["workflow_template", "position"]},
+ ),
+ ]
diff --git a/tasks/migrations/0018_taskworkflowtemplate_created_at_and_more.py b/tasks/migrations/0018_taskworkflowtemplate_created_at_and_more.py
new file mode 100644
index 000000000..ab4565535
--- /dev/null
+++ b/tasks/migrations/0018_taskworkflowtemplate_created_at_and_more.py
@@ -0,0 +1,29 @@
+# Generated by Django 4.2.15 on 2024-12-09 14:53
+
+import django.utils.timezone
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("tasks", "0017_rename_queue_taskitem_workflow_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="taskworkflowtemplate",
+ name="created_at",
+ field=models.DateTimeField(
+ auto_now_add=True,
+ default=django.utils.timezone.now,
+ ),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name="taskworkflowtemplate",
+ name="updated_at",
+ field=models.DateTimeField(auto_now=True),
+ ),
+ ]
diff --git a/tasks/migrations/0019_alter_task_options_alter_tasktemplate_options_and_more.py b/tasks/migrations/0019_alter_task_options_alter_tasktemplate_options_and_more.py
new file mode 100644
index 000000000..9219fcb7f
--- /dev/null
+++ b/tasks/migrations/0019_alter_task_options_alter_tasktemplate_options_and_more.py
@@ -0,0 +1,29 @@
+# Generated by Django 4.2.17 on 2024-12-17 16:04
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("tasks", "0018_taskworkflowtemplate_created_at_and_more"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="task",
+ options={"ordering": ["id"]},
+ ),
+ migrations.AlterModelOptions(
+ name="tasktemplate",
+ options={"ordering": ["id"]},
+ ),
+ migrations.AlterModelOptions(
+ name="taskworkflow",
+ options={"ordering": ["id"], "verbose_name": "workflow"},
+ ),
+ migrations.AlterModelOptions(
+ name="taskworkflowtemplate",
+ options={"ordering": ["id"], "verbose_name": "workflow template"},
+ ),
+ ]
diff --git a/tasks/migrations/0020_alter_taskassignee_assignment_type.py b/tasks/migrations/0020_alter_taskassignee_assignment_type.py
new file mode 100644
index 000000000..46b091033
--- /dev/null
+++ b/tasks/migrations/0020_alter_taskassignee_assignment_type.py
@@ -0,0 +1,26 @@
+# Generated by Django 4.2.17 on 2024-12-23 14:55
+
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("tasks", "0019_alter_task_options_alter_tasktemplate_options_and_more"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="taskassignee",
+ name="assignment_type",
+ field=models.CharField(
+ choices=[
+ ("WORKBASKET_WORKER", "Workbasket worker"),
+ ("WORKBASKET_REVIEWER", "Workbasket reviewer"),
+ ("GENERAL", "General"),
+ ],
+ max_length=50,
+ ),
+ ),
+ ]
diff --git a/tasks/migrations/0021_taskworkflow_eif_date_taskworkflow_policy_contact.py b/tasks/migrations/0021_taskworkflow_eif_date_taskworkflow_policy_contact.py
new file mode 100644
index 000000000..ed635ddd8
--- /dev/null
+++ b/tasks/migrations/0021_taskworkflow_eif_date_taskworkflow_policy_contact.py
@@ -0,0 +1,24 @@
+# Generated by Django 4.2.18 on 2025-03-03 10:36
+
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("tasks", "0020_alter_taskassignee_assignment_type"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="taskworkflow",
+ name="eif_date",
+ field=models.DateField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="taskworkflow",
+ name="policy_contact",
+ field=models.CharField(blank=True, max_length=40, null=True),
+ ),
+ ]
diff --git a/tasks/models.py b/tasks/models.py
deleted file mode 100644
index 126675461..000000000
--- a/tasks/models.py
+++ /dev/null
@@ -1,113 +0,0 @@
-from datetime import datetime
-
-from django.conf import settings
-from django.db import models
-from django.utils.timezone import make_aware
-
-from common.models.mixins import TimestampedMixin
-from workbaskets.models import WorkBasket
-
-
-class Task(TimestampedMixin):
- title = models.CharField(max_length=255)
- description = models.TextField()
- workbasket = models.ForeignKey(
- WorkBasket,
- on_delete=models.PROTECT,
- related_name="tasks",
- )
-
- def __str__(self):
- return self.title
-
-
-class UserAssignmentQueryset(models.QuerySet):
- def assigned(self):
- return self.exclude(unassigned_at__isnull=False)
-
- def unassigned(self):
- return self.exclude(unassigned_at__isnull=True)
-
- def workbasket_workers(self):
- return self.filter(
- assignment_type=UserAssignment.AssignmentType.WORKBASKET_WORKER,
- )
-
- def workbasket_reviewers(self):
- return self.filter(
- assignment_type=UserAssignment.AssignmentType.WORKBASKET_REVIEWER,
- )
-
-
-class UserAssignment(TimestampedMixin):
- class AssignmentType(models.TextChoices):
- WORKBASKET_WORKER = "WORKBASKET_WORKER", "Workbasket worker"
- WORKBASKET_REVIEWER = "WORKBASKET_REVIEWER", "Workbasket reviewer"
-
- user = models.ForeignKey(
- settings.AUTH_USER_MODEL,
- on_delete=models.PROTECT,
- related_name="assigned_to",
- )
- assigned_by = models.ForeignKey(
- settings.AUTH_USER_MODEL,
- on_delete=models.PROTECT,
- editable=False,
- related_name="assigned_by",
- )
- assignment_type = models.CharField(
- choices=AssignmentType.choices,
- max_length=50,
- )
- task = models.ForeignKey(
- Task,
- on_delete=models.CASCADE,
- editable=False,
- related_name="user_assignments",
- )
- unassigned_at = models.DateTimeField(
- auto_now=False,
- blank=True,
- null=True,
- )
-
- objects = UserAssignmentQueryset.as_manager()
-
- def __str__(self):
- return (
- f"User: {self.user} ({self.assignment_type}), " f"Task ID: {self.task.id}"
- )
-
- @property
- def is_assigned(self):
- return True if not self.unassigned_at else False
-
- @classmethod
- def unassign_user(cls, user, task):
- try:
- assignment = cls.objects.get(user=user, task=task)
- if assignment.unassigned_at:
- return False
- assignment.unassigned_at = make_aware(datetime.now())
- assignment.save(update_fields=["unassigned_at"])
- return True
- except cls.DoesNotExist:
- return False
-
-
-class Comment(TimestampedMixin):
- author = models.ForeignKey(
- settings.AUTH_USER_MODEL,
- on_delete=models.PROTECT,
- editable=False,
- related_name="authored_comments",
- )
- content = models.TextField(
- max_length=1000 * 5, # Max words * average character word length.
- )
- task = models.ForeignKey(
- Task,
- on_delete=models.CASCADE,
- editable=False,
- related_name="comments",
- )
diff --git a/tasks/models/__init__.py b/tasks/models/__init__.py
new file mode 100644
index 000000000..95be31658
--- /dev/null
+++ b/tasks/models/__init__.py
@@ -0,0 +1,37 @@
+"""Models used by all apps in the project."""
+
+from tasks.models.logs import TaskLog
+from tasks.models.queue import Queue
+from tasks.models.queue import QueueItem
+from tasks.models.queue import RequiredFieldError
+from tasks.models.task import Category
+from tasks.models.task import Comment
+from tasks.models.task import ProgressState
+from tasks.models.task import Task
+from tasks.models.task import TaskAssignee
+from tasks.models.workflow import TaskItem
+from tasks.models.workflow import TaskItemTemplate
+from tasks.models.workflow import TaskTemplate
+from tasks.models.workflow import TaskWorkflow
+from tasks.models.workflow import TaskWorkflowTemplate
+
+__all__ = [
+ # tasks.models.logs
+ "TaskLog",
+ # tasks.models.queue
+ "Queue",
+ "QueueItem",
+ "RequiredFieldError",
+ # tasks.models.task
+ "Category",
+ "Comment",
+ "ProgressState",
+ "Task",
+ "TaskAssignee",
+ # tasks.models.workflow
+ "TaskWorkflow",
+ "TaskItem",
+ "TaskWorkflowTemplate",
+ "TaskItemTemplate",
+ "TaskTemplate",
+]
diff --git a/tasks/models/logs.py b/tasks/models/logs.py
new file mode 100644
index 000000000..30c4bc046
--- /dev/null
+++ b/tasks/models/logs.py
@@ -0,0 +1,99 @@
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.db import models
+
+from common.models.mixins import TimestampedMixin
+from tasks.models.task import Task
+
+User = get_user_model()
+
+
+class TaskLogManager(models.Manager):
+ def create(
+ self,
+ task: Task,
+ action: "TaskLog.AuditActionType",
+ instigator: User,
+ **kwargs,
+ ) -> "TaskLog":
+ """
+ Creates a new `TaskLog` instance with a generated description based on
+ `action`, saving it to the database and returning the created instance.
+
+ A TaskLog's `description` is generated using a template retrieved from `TaskLog.AUDIT_ACTION_MAP` that maps an `action` to its corresponding description.
+ Additional `kwargs` are required to format the description template depending on the provided action.
+ """
+
+ if action not in self.model.AuditActionType:
+ raise ValueError(
+ f"The action '{action}' is an invalid TaskLog.AuditActionType value.",
+ )
+
+ description_template = self.model.AUDIT_ACTION_MAP.get(action)
+ if not description_template:
+ raise ValueError(
+ f"No description template found for action '{action}' in TaskLog.AUDIT_ACTION_MAP.",
+ )
+
+ context = {"instigator": instigator}
+
+ if action in {
+ self.model.AuditActionType.TASK_ASSIGNED,
+ self.model.AuditActionType.TASK_UNASSIGNED,
+ }:
+ assignee = kwargs.pop("assignee", None)
+ if not assignee:
+ raise ValueError(f"Missing 'assignee' in kwargs for action '{action}'.")
+ context["assignee"] = assignee
+
+ elif action == self.model.AuditActionType.PROGRESS_STATE_UPDATED:
+ progress_state = kwargs.pop("progress_state", None)
+ if not progress_state:
+ raise ValueError(
+ f"Missing 'progress_state' in kwargs for action '{action}'.",
+ )
+ context["progress_state"] = progress_state
+
+ description = description_template.format(**context)
+
+ return super().create(
+ task=task,
+ action=action,
+ instigator=instigator,
+ description=description,
+ **kwargs,
+ )
+
+
+class TaskLog(TimestampedMixin):
+ class AuditActionType(models.TextChoices):
+ TASK_ASSIGNED = ("TASK_ASSIGNED",)
+ TASK_UNASSIGNED = ("TASK_UNASSIGNED",)
+ PROGRESS_STATE_UPDATED = ("PROGRESS_STATE_UPDATED",)
+
+ AUDIT_ACTION_MAP = {
+ AuditActionType.TASK_ASSIGNED: "{instigator} assigned {assignee}",
+ AuditActionType.TASK_UNASSIGNED: "{instigator} unassigned {assignee}",
+ AuditActionType.PROGRESS_STATE_UPDATED: "{instigator} changed the status to {progress_state}",
+ }
+
+ action = models.CharField(
+ max_length=100,
+ choices=AuditActionType.choices,
+ editable=False,
+ )
+ description = models.TextField(editable=False)
+ task = models.ForeignKey(
+ Task,
+ null=True,
+ on_delete=models.SET_NULL,
+ editable=False,
+ related_name="logs",
+ )
+ instigator = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.PROTECT,
+ editable=False,
+ )
+
+ objects = TaskLogManager()
diff --git a/tasks/models/queue.py b/tasks/models/queue.py
new file mode 100644
index 000000000..1dc679076
--- /dev/null
+++ b/tasks/models/queue.py
@@ -0,0 +1,303 @@
+from __future__ import annotations
+
+from typing import Self
+
+from django.core.exceptions import FieldDoesNotExist
+from django.core.exceptions import ObjectDoesNotExist
+from django.db import models
+from django.db.transaction import atomic
+
+from common.util import TableLock
+from common.util import get_related_names
+
+
+class RequiredFieldError(Exception):
+ pass
+
+
+class Queue(models.Model):
+ """
+ A (FIFO) queue.
+
+ Note: This abstract class only supports a single, reverse foreign-key relationship
+ to `QueueItem` for each instance (i.e `QueueItem` instances are assumed to belong only to a single `Queue` instance).
+ """
+
+ class Meta:
+ abstract = True
+
+ def get_items(self) -> models.QuerySet:
+ """Get all queue items as a queryset."""
+ related_name = get_related_names(self, QueueItem)[0]
+ return getattr(self, related_name).all()
+
+ def get_first(self) -> QueueItem | None:
+ """Get the first item in the queue."""
+ return self.get_items().first()
+
+ def get_last(self) -> QueueItem | None:
+ """Get the last item in the queue."""
+ return self.get_items().last()
+
+ def get_item(self, position: int) -> QueueItem | None:
+ """Get the item at `position` position in the queue."""
+ try:
+ return self.get_items().get(position=position)
+ except ObjectDoesNotExist:
+ return None
+
+ @property
+ def max_position(self) -> int:
+ """
+ Returns the highest item position in the queue.
+
+ If the queue is empty it returns zero.
+ """
+ max = self.get_items().aggregate(
+ max_position=models.Max("position"),
+ )["max_position"]
+ return max if max is not None else 0
+
+
+class QueueItemMetaClass(models.base.ModelBase):
+ def __new__(cls, name, bases, attrs):
+ new_class = super().__new__(cls, name, bases, attrs)
+
+ if not new_class._meta.abstract:
+ queue_field_name = getattr(new_class, "queue_field", None)
+ cls.validate_queue_field(new_class, queue_field_name)
+
+ return new_class
+
+ @staticmethod
+ def validate_queue_field(new_class: type[Self], queue_field_name: str) -> None:
+ """Validate that concrete subclasses of `QueueItem` have a ForeignKey
+ field to a subclass of `Queue` model."""
+ try:
+ queue_field = new_class._meta.get_field(queue_field_name)
+ except FieldDoesNotExist:
+ queue_field = None
+
+ if not queue_field or not isinstance(queue_field, models.ForeignKey):
+ raise RequiredFieldError(
+ f"{new_class.__name__} must have a 'queue' ForeignKey field. The name of the field must match the value given to the 'queue_field' attribute on the model.",
+ )
+
+ if not issubclass(queue_field.remote_field.model, Queue):
+ raise RequiredFieldError(
+ f"{queue_field} must be a ForeignKey field to a subclass of 'Queue' model.",
+ )
+
+
+class QueueItemManager(models.Manager):
+ @atomic
+ def create(self, **kwargs) -> QueueItem:
+ """Create a new item instance in a queue, given by the `queue` named
+ param, and place it in last position."""
+
+ with TableLock(self.model, lock=TableLock.EXCLUSIVE):
+ queue_field = self.model.queue_field
+ queue = kwargs.pop(queue_field)
+ position = kwargs.pop("position", (queue.get_items().count() + 1))
+
+ if position <= 0:
+ raise ValueError(
+ "QueueItem.position must be a positive integer greater than zero.",
+ )
+
+ return super().create(
+ position=position,
+ **{queue_field: queue},
+ **kwargs,
+ )
+
+
+class QueueItem(models.Model, metaclass=QueueItemMetaClass):
+ """Item that is a member of a Queue."""
+
+ class Meta:
+ abstract = True
+ ordering = ["queue", "position"]
+
+ queue_field: str = "queue"
+ """
+ The name of the required ForeignKey field relating this instance to a Queue
+ instance.
+
+ The value of this attribute can be inherited as is or overridden in
+ subclasses to reflect the specific purpose or role of the queue.
+
+ If this value is overridden, the subclass must redefine the `Meta` class
+ to maintain ordering based on the queue field.
+ """
+
+ position = models.PositiveSmallIntegerField(
+ db_index=True,
+ editable=False,
+ )
+ """
+ 1-based positioning - 1 is the first position.
+ """
+
+ objects = QueueItemManager()
+
+ def get_queue_field(self) -> str:
+ """Return the queue field name on this instance."""
+ return self.__class__.queue_field
+
+ def get_queue(self) -> type[Queue]:
+ """Return the queue instance related to this instance."""
+ return getattr(self, self.get_queue_field())
+
+ @atomic
+ def delete(self):
+ """Remove and delete instance from its queue, shuffling all successive
+ queued instances up one position."""
+ instance = self.__class__.objects.select_for_update(nowait=True).get(pk=self.pk)
+
+ to_update = list(
+ self.__class__.objects.select_for_update(nowait=True)
+ .filter(
+ position__gt=instance.position,
+ **{self.get_queue_field(): self.get_queue()},
+ )
+ .values_list("pk", flat=True),
+ )
+
+ self.__class__.objects.filter(pk__in=to_update).update(
+ position=models.F("position") - 1,
+ )
+
+ return super().delete()
+
+ @atomic
+ def promote(self) -> Self:
+ """
+ Promote the instance by one place up the queue.
+
+ No change is made if the instance is already in its queue's first place.
+
+ Returns the promoted instance with any database updates applied.
+ """
+ instance = self.__class__.objects.select_for_update(nowait=True).get(pk=self.pk)
+
+ if instance.position == 1:
+ return instance
+
+ item_to_demote = self.__class__.objects.select_for_update(nowait=True).get(
+ position=instance.position - 1,
+ **{self.get_queue_field(): self.get_queue()},
+ )
+ item_to_demote.position += 1
+ instance.position -= 1
+ self.__class__.objects.bulk_update([instance, item_to_demote], ["position"])
+ instance.refresh_from_db()
+
+ return instance
+
+ @atomic
+ def demote(self) -> Self:
+ """
+ Demote the instance by one place down the queue.
+
+ No change is made if the instance is already in its queue's last place.
+
+ Returns the demoted instance with any database updates applied.
+ """
+ instance = self.__class__.objects.select_for_update(nowait=True).get(pk=self.pk)
+
+ queue_field = self.get_queue_field()
+ queue = self.get_queue()
+ queue_kwarg = {
+ queue_field: queue,
+ }
+
+ if instance.position == queue.max_position:
+ return instance
+
+ item_to_promote = self.__class__.objects.select_for_update(nowait=True).get(
+ position=instance.position + 1,
+ **queue_kwarg,
+ )
+ item_to_promote.position -= 1
+ instance.position += 1
+ self.__class__.objects.bulk_update([instance, item_to_promote], ["position"])
+ instance.refresh_from_db()
+
+ return instance
+
+ @atomic
+ def promote_to_first(self) -> Self:
+ """
+ Promote the instance to the first place in the queue so that it occupies
+ position 1.
+
+ No change is made if the instance is already in its queue's first place.
+
+ Returns the promoted instance with any database updates applied.
+ """
+
+ instance = self.__class__.objects.select_for_update(nowait=True).get(pk=self.pk)
+
+ if instance.position == 1:
+ return instance
+
+ to_update = list(
+ self.__class__.objects.select_for_update(nowait=True)
+ .filter(
+ position__lt=instance.position,
+ **{self.get_queue_field(): self.get_queue()},
+ )
+ .values_list("pk", flat=True),
+ )
+
+ self.__class__.objects.filter(pk__in=to_update).update(
+ position=models.F("position") + 1,
+ )
+
+ instance.position = 1
+ instance.save(update_fields=["position"])
+ instance.refresh_from_db()
+
+ return instance
+
+ @atomic
+ def demote_to_last(self) -> Self:
+ """
+ Demote the instance to the last place in the queue so that it occupies
+ position of queue length.
+
+ No change is made if the instance is already in its queue's last place.
+
+ Returns the demoted instance with any database updates applied.
+ """
+ instance = self.__class__.objects.select_for_update(nowait=True).get(pk=self.pk)
+
+ queue_field = self.get_queue_field()
+ queue = self.get_queue()
+ queue_kwarg = {
+ queue_field: queue,
+ }
+
+ last_place = queue.max_position
+ if instance.position == last_place:
+ return instance
+
+ to_update = list(
+ self.__class__.objects.select_for_update(nowait=True)
+ .filter(
+ position__gt=instance.position,
+ **queue_kwarg,
+ )
+ .values_list("pk", flat=True),
+ )
+
+ self.__class__.objects.filter(pk__in=to_update).update(
+ position=models.F("position") - 1,
+ )
+
+ instance.position = last_place
+ instance.save(update_fields=["position"])
+ instance.refresh_from_db()
+
+ return instance
diff --git a/tasks/models/task.py b/tasks/models/task.py
new file mode 100644
index 000000000..294ed5150
--- /dev/null
+++ b/tasks/models/task.py
@@ -0,0 +1,344 @@
+from datetime import datetime
+
+from django.conf import settings
+from django.contrib import admin
+from django.contrib.auth import get_user_model
+from django.db import models
+from django.db import transaction
+from django.urls import reverse
+from django.utils.timezone import make_aware
+
+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 ProgressState(models.Model):
+ class State(models.TextChoices):
+ TO_DO = "TO_DO", "To do"
+ IN_PROGRESS = "IN_PROGRESS", "In progress"
+ DONE = "DONE", "Done"
+
+ DEFAULT_STATE_NAME = State.TO_DO
+ """The name of the default `State` object for `ProgressState`."""
+
+ name = models.CharField(
+ max_length=255,
+ choices=State.choices,
+ unique=True,
+ )
+
+ def __str__(self):
+ return self.get_name_display()
+
+ @classmethod
+ def get_default_state_id(cls):
+ """Get the id / pk of the default `State` object for `ProgressState`."""
+ # Failsafe get_or_create() avoids attempt to get non-existant instance.
+ default, _ = cls.objects.get_or_create(name=cls.DEFAULT_STATE_NAME)
+ return default.id
+
+
+class TaskManager(WithSignalManagerMixin, models.Manager):
+ pass
+
+
+class TaskQueryset(WithSignalQuerysetMixin, models.QuerySet):
+ def non_workflow(self) -> "TaskQueryset":
+ """Return a queryset of standalone Task instances that are not part of a
+ workflow and are not subtasks."""
+ return self.filter(
+ models.Q(parent_task__isnull=True)
+ & models.Q(taskitem__isnull=True)
+ & models.Q(taskworkflow__isnull=True),
+ )
+
+ def workflow_summary(self) -> "TaskQueryset":
+ """
+ Return a queryset of summary Task instances of TaskWorkflows, i.e. those
+ with a non-null related_name=taskworkflow.
+
+ Summary Task instances are never subtasks.
+ """
+ return self.filter(
+ models.Q(taskworkflow__isnull=False),
+ )
+
+ def top_level(self) -> "TaskQueryset":
+ """
+ Return a queryset of Task instances that are not subtasks and are
+ either:
+ 1. Stand-alone Task instances that are not part of a Workflow tasks
+ 2. Workflow.summary_task instances.
+
+ The intent is to provide a top-level filtering of Task instances,
+ permitting a combined at-a-glance view of Tasks and Workflow instances.
+ """
+ return self.filter(
+ (models.Q(taskitem__isnull=True) | models.Q(taskworkflow__isnull=False))
+ & models.Q(parent_task__isnull=True),
+ )
+
+ def parents(self):
+ """Returns a queryset of tasks who do not have subtasks linked to
+ them."""
+ return self.filter(
+ models.Q(parent_task=None),
+ )
+
+ def subtasks(self):
+ """Returns a queryset of tasks who have parent tasks linked to them."""
+ return self.exclude(models.Q(parent_task=None))
+
+
+class TaskBase(TimestampedMixin):
+ """Abstract model mixin containing model fields common to TaskTemplate and
+ Task models."""
+
+ class Meta:
+ abstract = True
+
+ title = models.CharField(max_length=255)
+ description = models.TextField()
+ category = models.ForeignKey(
+ "Category",
+ blank=True,
+ null=True,
+ on_delete=models.PROTECT,
+ )
+
+
+class Task(TaskBase):
+ progress_state = models.ForeignKey(
+ ProgressState,
+ default=ProgressState.get_default_state_id,
+ on_delete=models.PROTECT,
+ )
+ parent_task = models.ForeignKey(
+ "self",
+ blank=True,
+ null=True,
+ on_delete=models.CASCADE,
+ related_name="subtasks",
+ )
+ workbasket = models.ForeignKey(
+ WorkBasket,
+ blank=True,
+ null=True,
+ on_delete=models.PROTECT,
+ related_name="tasks",
+ )
+ creator = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ null=True,
+ on_delete=models.PROTECT,
+ related_name="created_tasks",
+ )
+
+ objects = TaskManager.from_queryset(TaskQueryset)()
+
+ class Meta(TaskBase.Meta):
+ abstract = False
+ ordering = ["id"]
+
+ @property
+ @admin.display(boolean=True)
+ def is_subtask(self) -> bool:
+ return bool(self.parent_task)
+
+ @property
+ def is_summary_task(self) -> bool:
+ return hasattr(self, "taskworkflow")
+
+ def __str__(self):
+ return self.title
+
+ def get_url(self, action: str = "detail"):
+ if action == "detail":
+ return reverse("workflow:task-ui-detail", kwargs={"pk": self.pk})
+
+ return "#NOT-IMPLEMENTED"
+
+
+class Category(models.Model):
+ name = models.CharField(
+ max_length=255,
+ unique=True,
+ )
+
+ class Meta:
+ ordering = ["name"]
+ verbose_name_plural = "categories"
+
+ def __str__(self):
+ return self.name
+
+
+class TaskAssigneeManager(WithSignalManagerMixin, models.Manager):
+ pass
+
+
+class TaskAssigneeQueryset(WithSignalQuerysetMixin, models.QuerySet):
+ def assigned(self):
+ return self.exclude(unassigned_at__isnull=False)
+
+ def unassigned(self):
+ return self.exclude(unassigned_at__isnull=True)
+
+ def workbasket_workers(self):
+ return self.filter(
+ assignment_type=TaskAssignee.AssignmentType.WORKBASKET_WORKER,
+ )
+
+ def workbasket_reviewers(self):
+ return self.filter(
+ assignment_type=TaskAssignee.AssignmentType.WORKBASKET_REVIEWER,
+ )
+
+
+class TaskAssignee(TimestampedMixin):
+ """
+ Model used to assocate Task instances with one or more Users.
+
+ The original intent was to associate two mandatory user roles to a workbasket:
+ - Worker who creates data in the workbasket - instances have
+ `assignment_type = AssignmentType.WORKBASKET_WORKER`
+ - Reviewer of workbasket data - instances have
+ `assignment_type = AssignmentType.WORKBASKET_REVIEWER`
+
+ In retrospect, these users should be assigned directly to the workbasket,
+ not via a Task, which includes an unnecessary level of indirection.
+
+ Current Task management introduces AssignmentType.GENERAL. TaskAssignee
+ instances with
+ `assignment_type = AssignmentType.GENERAL`
+ are actual task assignments, rather than the legacy approach to assigning
+ users to worker or reviewer roles.
+
+ Workbasket and new task assignment should be separated by introducing a new
+ Django Model, say, WorkBasketAssignee, and old assignments should be
+ migrated to instances of the new, replacement model.
+
+ class WorkBasketAssignee(TimestampedMixin):
+ class AssignmentType(models.TextChoices):
+ WORKBASKET_WORKER = "WORKBASKET_WORKER", "Workbasket worker"
+ WORKBASKET_REVIEWER = "WORKBASKET_REVIEWER", "Workbasket reviewer"
+
+ workbasket = models.ForeignKey(
+ WorkBasket,
+ blank=True,
+ null=True,
+ on_delete=models.CASCADE,
+ related_name="workbasketassignees",
+ )
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.PROTECT,
+ related_name="assigned_to",
+ )
+ assignment_type = models.CharField(
+ choices=AssignmentType.choices,
+ max_length=50,
+ )
+ unassigned_at = models.DateTimeField(
+ auto_now=False,
+ blank=True,
+ null=True,
+ )
+
+ @property
+ def is_assigned(self) -> bool:
+ return True if not self.unassigned_at else False
+
+ @classmethod
+ def unassign_user(cls, user, workbasket) -> bool:
+ try:
+ assignment = cls.objects.get(user=user, workbasket=workbasket)
+ except cls.DoesNotExist:
+ return False
+
+ if assignment.unassigned_at:
+ return False
+
+ with transaction.atomic():
+ assignment.unassigned_at = make_aware(datetime.now())
+ assignment.save(update_fields=["unassigned_at"])
+ return True
+
+ AssignmentType can then be stripped from TaskAssignee, since there'll only
+ be one type of assignee against tasks.
+ """
+
+ class AssignmentType(models.TextChoices):
+ WORKBASKET_WORKER = "WORKBASKET_WORKER", "Workbasket worker"
+ WORKBASKET_REVIEWER = "WORKBASKET_REVIEWER", "Workbasket reviewer"
+ GENERAL = "GENERAL", "General"
+
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.PROTECT,
+ related_name="assigned_to",
+ )
+ assignment_type = models.CharField(
+ choices=AssignmentType.choices,
+ max_length=50,
+ )
+ task = models.ForeignKey(
+ Task,
+ on_delete=models.CASCADE,
+ editable=False,
+ related_name="assignees",
+ )
+ unassigned_at = models.DateTimeField(
+ auto_now=False,
+ blank=True,
+ null=True,
+ )
+
+ objects = TaskAssigneeManager.from_queryset(TaskAssigneeQueryset)()
+
+ def __str__(self):
+ return (
+ f"User: {self.user} ({self.assignment_type}), " f"Task ID: {self.task.id}"
+ )
+
+ @property
+ def is_assigned(self):
+ return True if not self.unassigned_at else False
+
+ @classmethod
+ 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
+ set_current_instigator(instigator)
+ with transaction.atomic():
+ assignment.unassigned_at = make_aware(datetime.now())
+ assignment.save(update_fields=["unassigned_at"])
+ return True
+ except cls.DoesNotExist:
+ return False
+
+
+class Comment(TimestampedMixin):
+ author = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.PROTECT,
+ editable=False,
+ related_name="authored_comments",
+ )
+ content = models.TextField(
+ max_length=1000 * 5, # Max words * average character word length.
+ )
+ task = models.ForeignKey(
+ Task,
+ on_delete=models.CASCADE,
+ editable=False,
+ related_name="comments",
+ )
diff --git a/tasks/models/workflow.py b/tasks/models/workflow.py
new file mode 100644
index 000000000..f089d5106
--- /dev/null
+++ b/tasks/models/workflow.py
@@ -0,0 +1,286 @@
+from datetime import date
+
+from django.conf import settings
+from django.db import models
+from django.db.transaction import atomic
+from django.urls import reverse
+
+from common.models import User
+from common.models.mixins import TimestampedMixin
+from tasks.models.queue import Queue
+from tasks.models.queue import QueueItem
+from tasks.models.task import Task
+from tasks.models.task import TaskBase
+
+# ----------------------
+# - Workflows and tasks.
+# ----------------------
+
+
+class TaskWorkflow(Queue):
+ """Workflow of ordered Tasks."""
+
+ summary_task = models.OneToOneField(
+ Task,
+ on_delete=models.PROTECT,
+ )
+ """Provides task-like filtering and display capabilities for this
+ workflow."""
+ creator_template = models.ForeignKey(
+ "tasks.TaskWorkflowTemplate",
+ blank=False,
+ null=True,
+ on_delete=models.SET_NULL,
+ )
+ """The template from which this workflow was created, if any."""
+ eif_date = models.DateField(
+ blank=True,
+ null=True,
+ )
+ policy_contact = models.CharField(max_length=40, blank=True, null=True)
+
+ class Meta(Queue.Meta):
+ abstract = False
+ ordering = ["id"]
+ verbose_name = "workflow"
+
+ def __str__(self):
+ return self.title
+
+ @property
+ def title(self) -> str:
+ return self.summary_task.title
+
+ @property
+ def description(self) -> str:
+ return self.summary_task.description
+
+ def get_tasks(self) -> models.QuerySet:
+ """Get a QuerySet of the Tasks associated through their TaskItem
+ instances to this TaskWorkflow, ordered by the position of the
+ TaskItem."""
+ return Task.objects.filter(taskitem__workflow=self).order_by(
+ "taskitem__position",
+ )
+
+ def get_url(self, action: str = "detail"):
+ if action == "detail":
+ return reverse(
+ "workflow:task-workflow-ui-detail",
+ kwargs={"pk": self.pk},
+ )
+ elif action == "edit":
+ return reverse(
+ "workflow:task-workflow-ui-update",
+ kwargs={"pk": self.pk},
+ )
+ elif action == "delete":
+ return reverse(
+ "workflow:task-workflow-ui-delete",
+ kwargs={"pk": self.pk},
+ )
+ elif action == "create":
+ return reverse(
+ "workflow:task-workflow-ui-create",
+ )
+ elif action == "list":
+ return reverse(
+ "workflow:task-workflow-ui-list",
+ )
+
+ return "#NOT-IMPLEMENTED"
+
+
+class TaskItem(QueueItem):
+ """Task item queue management for Task instances (these should always be
+ subtasks)."""
+
+ queue_field = "workflow"
+
+ workflow = models.ForeignKey(
+ TaskWorkflow,
+ related_name="workflow_items",
+ on_delete=models.CASCADE,
+ )
+ task = models.OneToOneField(
+ "tasks.Task",
+ on_delete=models.CASCADE,
+ )
+ """The Task instance managed by this TaskItem."""
+
+ class Meta(QueueItem.Meta):
+ abstract = False
+ ordering = ["workflow", "position"]
+
+
+# ----------------------------------------
+# - Template workflows and template tasks.
+# ----------------------------------------
+
+
+class TaskWorkflowTemplate(Queue, TimestampedMixin):
+ """Template used to create TaskWorkflow instance."""
+
+ title = models.CharField(
+ max_length=255,
+ )
+ """
+ A title name for the instance.
+
+ This isn't the same as the title assigned to a TaskWorkflow instance
+ generated from a template.
+ """
+ description = models.TextField(
+ blank=True,
+ help_text="Description of what this workflow template is used for. ",
+ )
+ """
+ Description of what the instance is used for.
+
+ This isn't the same as the description that may be applied to a TaskWorkflow
+ instance generated from a template.
+ """
+ creator = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ null=True,
+ on_delete=models.PROTECT,
+ related_name="created_taskworkflowtemplates",
+ )
+
+ class Meta(Queue.Meta):
+ abstract = False
+ ordering = ["id"]
+ verbose_name = "workflow template"
+
+ def __str__(self):
+ return self.title
+
+ def get_task_templates(self) -> models.QuerySet:
+ """Get a QuerySet of the TaskTemplates associated through their
+ TaskItemTemplate instances to this TaskWorkflowTemplate, ordered by the
+ position of the TaskItemTemplate."""
+ return TaskTemplate.objects.filter(
+ taskitemtemplate__workflow_template=self,
+ ).order_by(
+ "taskitemtemplate__position",
+ )
+
+ @atomic
+ def create_task_workflow(
+ self,
+ title: str,
+ description: str,
+ creator: User,
+ # Take in additional data
+ eif_date: date,
+ policy_contact: str,
+ ) -> "TaskWorkflow":
+ """Create a workflow and it subtasks, using values from this template
+ workflow and its task templates."""
+
+ summary_task = Task.objects.create(
+ title=title,
+ description=description,
+ creator=creator,
+ )
+ task_workflow = TaskWorkflow.objects.create(
+ summary_task=summary_task,
+ creator_template=self,
+ # Pass new data to the workflow instance that will be made
+ eif_date=eif_date,
+ policy_contact=policy_contact,
+ )
+
+ task_item_templates = TaskItemTemplate.objects.select_related(
+ "task_template",
+ ).filter(workflow_template=self)
+ for task_item_template in task_item_templates:
+ task_template = task_item_template.task_template
+ task = Task.objects.create(
+ title=task_template.title,
+ description=task_template.description,
+ category=task_template.category,
+ creator=creator,
+ )
+ TaskItem.objects.create(
+ position=task_item_template.position,
+ workflow=task_workflow,
+ task=task,
+ )
+
+ return task_workflow
+
+ def get_url(self, action: str = "detail"):
+ if action == "detail":
+ return reverse(
+ "workflow:task-workflow-template-ui-detail",
+ kwargs={"pk": self.pk},
+ )
+ elif action == "edit":
+ return reverse(
+ "workflow:task-workflow-template-ui-update",
+ kwargs={"pk": self.pk},
+ )
+ elif action == "delete":
+ return reverse(
+ "workflow:task-workflow-template-ui-delete",
+ kwargs={"pk": self.pk},
+ )
+ elif action == "create":
+ return reverse(
+ "workflow:task-workflow-template-ui-create",
+ )
+ elif action == "list":
+ return reverse(
+ "workflow:task-workflow-template-ui-list",
+ )
+
+ return "#NOT-IMPLEMENTED"
+
+
+class TaskItemTemplate(QueueItem):
+ """Queue item management for TaskTemplate instances."""
+
+ queue_field = "workflow_template"
+
+ workflow_template = models.ForeignKey(
+ TaskWorkflowTemplate,
+ related_name="workflow_template_items",
+ on_delete=models.CASCADE,
+ )
+ task_template = models.OneToOneField(
+ "tasks.TaskTemplate",
+ on_delete=models.CASCADE,
+ )
+
+ class Meta(QueueItem.Meta):
+ abstract = False
+ ordering = ["workflow_template", "position"]
+
+
+class TaskTemplate(TaskBase):
+ """Template used to create Task instances from within a template
+ workflow."""
+
+ def get_url(self, action: str = "detail"):
+ if action == "detail":
+ return reverse("workflow:task-template-ui-detail", kwargs={"pk": self.pk})
+ elif action == "edit":
+ return reverse("workflow:task-template-ui-update", kwargs={"pk": self.pk})
+ elif action == "delete":
+ return reverse(
+ "workflow:task-template-ui-delete",
+ kwargs={
+ "workflow_template_pk": self.taskitemtemplate.workflow_template.pk,
+ "pk": self.pk,
+ },
+ )
+
+ return "#NOT-IMPLEMENTED"
+
+ class Meta(TaskBase.Meta):
+ abstract = False
+ ordering = ["id"]
+
+ def __str__(self):
+ return self.title
diff --git a/tasks/signals.py b/tasks/signals.py
new file mode 100644
index 000000000..1f13fa0a4
--- /dev/null
+++ b/tasks/signals.py
@@ -0,0 +1,70 @@
+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):
+ """Sets the current user (`instigator`) who is instigating a task
+ action / change. This is normally done from the view that handles the
+ change. When the change is saved, instigator details are logged."""
+ _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/static/tasks/scss/_tasks.scss b/tasks/static/tasks/scss/_tasks.scss
new file mode 100644
index 000000000..c86541eec
--- /dev/null
+++ b/tasks/static/tasks/scss/_tasks.scss
@@ -0,0 +1,38 @@
+// Source: https://design-system.service.gov.uk/components/task-list/
+
+.govuk-task-list {
+ @include govuk-font($size: 19);
+ margin-top: 0;
+ @include govuk-responsive-margin(6, "bottom");
+ padding: 0;
+ list-style-type: none;
+}
+
+.govuk-task-list__item {
+ display: table;
+ position: relative;
+ width: 100%;
+ margin-bottom: 0;
+ padding-top: govuk-spacing(2);
+ padding-bottom: govuk-spacing(2);
+ border-bottom: 1px solid $govuk-border-colour;
+}
+
+.govuk-task-list__item:first-child {
+ border-top: 1px solid $govuk-border-colour;
+}
+
+.govuk-task-list__item--with-link:hover {
+ background: govuk-colour("light-grey");
+}
+
+.govuk-task-list__name-and-hint {
+ display: table-cell;
+ vertical-align: top;
+ @include govuk-text-colour;
+}
+
+.govuk-task-list__hint {
+ margin-top: govuk-spacing(1);
+ color: $govuk-secondary-text-colour;
+}
diff --git a/tasks/tests/conftest.py b/tasks/tests/conftest.py
index 47808d0c2..c56a17eff 100644
--- a/tasks/tests/conftest.py
+++ b/tasks/tests/conftest.py
@@ -1,8 +1,18 @@
import pytest
+from common.tests.factories import CategoryFactory
+from common.tests.factories import ProgressStateFactory
+from common.tests.factories import SubTaskFactory
+from common.tests.factories import TaskAssigneeFactory
from common.tests.factories import TaskFactory
-from common.tests.factories import UserAssignmentFactory
-from tasks.models import UserAssignment
+from tasks.models import Task
+from tasks.models import TaskAssignee
+from tasks.models import TaskItem
+from tasks.models import TaskItemTemplate
+from tasks.models import TaskTemplate
+from tasks.models import TaskWorkflow
+from tasks.models import TaskWorkflowTemplate
+from tasks.tests import factories
@pytest.fixture()
@@ -11,19 +21,136 @@ def task():
@pytest.fixture()
-def user_assignment():
- return UserAssignmentFactory.create()
+def subtask():
+ return SubTaskFactory.create()
@pytest.fixture()
-def workbasket_worker_assignment():
- return UserAssignmentFactory.create(
- assignment_type=UserAssignment.AssignmentType.WORKBASKET_WORKER,
+def category():
+ return CategoryFactory.create()
+
+
+@pytest.fixture()
+def progress_state():
+ return ProgressStateFactory.create()
+
+
+@pytest.fixture()
+def task_assignee():
+ return TaskAssigneeFactory.create()
+
+
+@pytest.fixture()
+def workbasket_worker_assignee():
+ return TaskAssigneeFactory.create(
+ assignment_type=TaskAssignee.AssignmentType.WORKBASKET_WORKER,
+ )
+
+
+@pytest.fixture()
+def workbasket_reviewer_assignee():
+ return TaskAssigneeFactory.create(
+ assignment_type=TaskAssignee.AssignmentType.WORKBASKET_REVIEWER,
+ )
+
+
+@pytest.fixture()
+def task_workflow_template() -> TaskWorkflowTemplate:
+ """Return an empty TaskWorkflowTemplate instance (containing no items)."""
+ return factories.TaskWorkflowTemplateFactory.create()
+
+
+@pytest.fixture()
+def task_workflow_template_single_task_template_item(
+ task_workflow_template,
+) -> TaskWorkflowTemplate:
+ """Return a TaskWorkflowTemplate instance containing a single
+ TaskTemplateItem instance."""
+
+ task_template = factories.TaskTemplateFactory.create()
+ factories.TaskItemTemplateFactory.create(
+ workflow_template=task_workflow_template,
+ task_template=task_template,
+ )
+
+ assert task_workflow_template.get_items().count() == 1
+
+ return task_workflow_template
+
+
+@pytest.fixture()
+def task_workflow_template_three_task_template_items(
+ task_workflow_template,
+) -> TaskWorkflowTemplate:
+ """Return a TaskWorkflowTemplate instance containing three TaskItemTemplate
+ and related TaskTemplates."""
+
+ task_item_templates = []
+ for _ in range(3):
+ task_template = factories.TaskTemplateFactory.create()
+ task_item_template = factories.TaskItemTemplateFactory.create(
+ workflow_template=task_workflow_template,
+ task_template=task_template,
+ )
+ task_item_templates.append(task_item_template)
+
+ assert task_workflow_template.get_items().count() == 3
+ assert (
+ TaskItemTemplate.objects.filter(
+ workflow_template=task_workflow_template,
+ ).count()
+ == 3
)
+ assert (
+ TaskTemplate.objects.filter(
+ taskitemtemplate__in=task_item_templates,
+ ).count()
+ == 3
+ )
+
+ return task_workflow_template
+
+
+@pytest.fixture()
+def task_workflow() -> TaskWorkflow:
+ """Return an empty TaskWorkflow instance (containing no items)."""
+ return factories.TaskWorkflowFactory.create()
@pytest.fixture()
-def workbasket_reviewer_assignment():
- return UserAssignmentFactory.create(
- assignment_type=UserAssignment.AssignmentType.WORKBASKET_REVIEWER,
+def task_workflow_single_task_item(task_workflow) -> TaskWorkflow:
+ """Return a TaskWorkflow instance containing a single TaskItem instance with
+ associated Task instance."""
+
+ task_item = factories.TaskItemFactory.create(
+ workflow=task_workflow,
)
+
+ assert task_workflow.get_items().count() == 1
+ assert task_workflow.get_items().get() == task_item
+
+ return task_workflow
+
+
+@pytest.fixture()
+def task_workflow_three_task_items(
+ task_workflow,
+) -> TaskWorkflow:
+ """Return a TaskWorkflow instance containing three TaskItems and related
+ Tasks."""
+
+ expected_count = 3
+
+ task_items = factories.TaskItemFactory.create_batch(
+ expected_count,
+ workflow=task_workflow,
+ )
+
+ assert task_workflow.get_items().count() == expected_count
+ assert TaskItem.objects.filter(workflow=task_workflow).count() == expected_count
+ assert (
+ Task.objects.filter(taskitem__in=[item.pk for item in task_items]).count()
+ == expected_count
+ )
+
+ return task_workflow
diff --git a/tasks/tests/factories.py b/tasks/tests/factories.py
new file mode 100644
index 000000000..9d97e84d0
--- /dev/null
+++ b/tasks/tests/factories.py
@@ -0,0 +1,63 @@
+import factory
+from factory import SubFactory
+from factory.django import DjangoModelFactory
+
+from common.tests.factories import CategoryFactory
+from common.tests.factories import TaskFactory
+from common.tests.factories import UserFactory
+from tasks.models import TaskItem
+from tasks.models import TaskItemTemplate
+from tasks.models import TaskTemplate
+from tasks.models import TaskWorkflow
+from tasks.models import TaskWorkflowTemplate
+
+
+class TaskWorkflowTemplateFactory(DjangoModelFactory):
+ """Factory to create TaskWorkflowTemplate instances."""
+
+ title = factory.Faker("sentence")
+ description = factory.Faker("sentence")
+ creator = factory.SubFactory(UserFactory)
+
+ class Meta:
+ model = TaskWorkflowTemplate
+
+
+class TaskTemplateFactory(DjangoModelFactory):
+ """Factory to create TaskTemplate instances."""
+
+ title = factory.Faker("sentence")
+ description = factory.Faker("sentence")
+ category = factory.SubFactory(CategoryFactory)
+
+ class Meta:
+ model = TaskTemplate
+
+
+class TaskItemTemplateFactory(DjangoModelFactory):
+ """Factory to create TaskItemTemplate instances."""
+
+ class Meta:
+ model = TaskItemTemplate
+
+ workflow_template = SubFactory(TaskWorkflowTemplateFactory)
+ task_template = SubFactory(TaskTemplateFactory)
+
+
+class TaskWorkflowFactory(DjangoModelFactory):
+ """Factory to create TaskWorkflow instances."""
+
+ class Meta:
+ model = TaskWorkflow
+
+ summary_task = SubFactory(TaskFactory)
+
+
+class TaskItemFactory(DjangoModelFactory):
+ """Factory to create TaskItem instances."""
+
+ class Meta:
+ model = TaskItem
+
+ workflow = SubFactory(TaskWorkflowFactory)
+ task = SubFactory(TaskFactory)
diff --git a/tasks/tests/test_forms.py b/tasks/tests/test_forms.py
new file mode 100644
index 000000000..eb717e9ac
--- /dev/null
+++ b/tasks/tests/test_forms.py
@@ -0,0 +1,100 @@
+from datetime import date
+
+import pytest
+
+from common.tests.factories import ProgressStateFactory
+from common.tests.factories import TaskFactory
+from tasks import forms
+from tasks.models import ProgressState
+
+pytestmark = pytest.mark.django_db
+
+
+def test_create_subtask_assigns_correct_parent_task(valid_user):
+ """Tests that SubtaskCreateForm assigns the correct parent on form.save."""
+ parent_task_instance = TaskFactory.create()
+ progress_state = ProgressStateFactory.create(
+ name=ProgressState.State.IN_PROGRESS,
+ )
+
+ subtask_form_data = {
+ "progress_state": progress_state.pk,
+ "title": "subtask test title",
+ "description": "subtask test description",
+ }
+ form = forms.SubTaskCreateForm(data=subtask_form_data)
+ new_subtask = form.save(parent_task_instance, user=valid_user)
+
+ assert new_subtask.parent_task.pk == parent_task_instance.pk
+
+
+def test_workflow_create_form_valid_data(task_workflow_template):
+ """Tests that `TaskWorkflowCreateForm` returns expected cleaned_data given
+ valid form data."""
+
+ form_data = {
+ "ticket_name": "Test ticket 1",
+ "description": "Ticket created with all fields",
+ "work_type": task_workflow_template,
+ "entry_into_force_date_0": 12,
+ "entry_into_force_date_1": 12,
+ "entry_into_force_date_2": 2026,
+ "policy_contact": "Fake Contact Name",
+ }
+
+ form = forms.TaskWorkflowCreateForm(form_data)
+ assert form.is_valid()
+
+
+@pytest.mark.parametrize(
+ ("form_data", "field", "error_message"),
+ [
+ ({"ticket_name": ""}, "ticket_name", "Enter a title for the ticket"),
+ (
+ {"work_type": ""},
+ "work_type",
+ "Choose a work type",
+ ),
+ (
+ {
+ "workflow_template": "invalidchoice",
+ },
+ "work_type",
+ "Choose a work type",
+ ),
+ ],
+ ids=(
+ "missing_title",
+ "missing_work_type",
+ "invalid_work_type",
+ ),
+)
+def test_workflow_create_form_invalid_data(form_data, field, error_message):
+ """Tests that `TaskWorkflowCreateForm` raises expected form errors given
+ invalid form data."""
+
+ form = forms.TaskWorkflowCreateForm(form_data)
+ assert not form.is_valid()
+ assert error_message in form.errors[field]
+
+
+def test_workflow_update_form_save(task_workflow):
+ """Tests that the details of `TaskWorkflow.summary_task` are updated when
+ calling form.save()."""
+ form_data = {
+ "title": "Updated title",
+ "description": "Updated description",
+ "eif_date_0": date.today().day,
+ "eif_date_1": date.today().month,
+ "eif_date_2": date.today().year,
+ "policy_contact": "Policy contact",
+ }
+
+ form = forms.TaskWorkflowUpdateForm(data=form_data, instance=task_workflow)
+ assert form.is_valid()
+
+ workflow = form.save()
+ assert workflow.eif_date == date.today()
+ assert workflow.policy_contact == form_data["policy_contact"]
+ assert workflow.summary_task.title == form_data["title"]
+ assert workflow.summary_task.description == form_data["description"]
diff --git a/tasks/tests/test_models.py b/tasks/tests/test_models.py
index cb7259218..ecf4c0fb9 100644
--- a/tasks/tests/test_models.py
+++ b/tasks/tests/test_models.py
@@ -1,58 +1,228 @@
import pytest
-
-from tasks.models import UserAssignment
+from django.db.utils import IntegrityError
+
+from common.tests.factories import CategoryFactory
+from common.tests.factories import ProgressStateFactory
+from common.tests.factories import SubTaskFactory
+from common.tests.factories import TaskFactory
+from tasks.models import ProgressState
+from tasks.models import Task
+from tasks.models import TaskAssignee
+from tasks.models import TaskLog
+from tasks.tests.factories import TaskWorkflowFactory
pytestmark = pytest.mark.django_db
-def test_user_assignment_unassign_user_classmethod(user_assignment):
- user = user_assignment.user
- task = user_assignment.task
+def test_task_category_uniqueness():
+ name = "Most favoured nation"
+ CategoryFactory.create(name=name)
+ with pytest.raises(IntegrityError):
+ CategoryFactory.create(name=name)
+
+
+def test_task_progress_state_uniqueness():
+ name = "Blocked"
+ ProgressState.objects.create(name=name)
+ with pytest.raises(IntegrityError):
+ ProgressState.objects.create(name=name)
- assert UserAssignment.unassign_user(user=user, task=task)
+
+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, instigator=user)
# User has already been unassigned
- assert not UserAssignment.unassign_user(user=user, task=task)
+ assert not TaskAssignee.unassign_user(user=user, task=task, instigator=user)
-def test_user_assignment_assigned_queryset(
- user_assignment,
+def test_task_assignee_assigned_queryset(
+ task_assignee,
):
- assert UserAssignment.objects.assigned().count() == 1
+ assert TaskAssignee.objects.assigned().count() == 1
- user = user_assignment.user
- task = user_assignment.task
- UserAssignment.unassign_user(user=user, task=task)
+ user = task_assignee.user
+ task = task_assignee.task
+ TaskAssignee.unassign_user(user=user, task=task, instigator=user)
- assert not UserAssignment.objects.assigned()
+ assert not TaskAssignee.objects.assigned()
-def test_user_assignment_unassigned_queryset(
- user_assignment,
+def test_task_assignee_unassigned_queryset(
+ task_assignee,
):
- assert not UserAssignment.objects.unassigned()
+ assert not TaskAssignee.objects.unassigned()
- user = user_assignment.user
- task = user_assignment.task
- UserAssignment.unassign_user(user=user, task=task)
+ user = task_assignee.user
+ task = task_assignee.task
+ TaskAssignee.unassign_user(user=user, task=task, instigator=user)
- assert UserAssignment.objects.unassigned().count() == 1
+ assert TaskAssignee.objects.unassigned().count() == 1
-def test_user_assignment_workbasket_workers_queryset(
- workbasket_worker_assignment,
- workbasket_reviewer_assignment,
+def test_task_assignee_workbasket_workers_queryset(
+ workbasket_worker_assignee,
+ workbasket_reviewer_assignee,
):
- workbasket_workers = UserAssignment.objects.workbasket_workers()
+ workbasket_workers = TaskAssignee.objects.workbasket_workers()
assert workbasket_workers.count() == 1
- assert workbasket_worker_assignment in workbasket_workers
+ assert workbasket_worker_assignee in workbasket_workers
-def test_user_assignment_workbasket_reviewers_queryset(
- workbasket_worker_assignment,
- workbasket_reviewer_assignment,
+def test_task_assignee_workbasket_reviewers_queryset(
+ workbasket_worker_assignee,
+ workbasket_reviewer_assignee,
):
- workbasket_reviewers = UserAssignment.objects.workbasket_reviewers()
+ workbasket_reviewers = TaskAssignee.objects.workbasket_reviewers()
assert workbasket_reviewers.count() == 1
- assert workbasket_reviewer_assignment in workbasket_reviewers
+ assert workbasket_reviewer_assignee in workbasket_reviewers
+
+
+def test_non_workflow_queryset(task, task_workflow_single_task_item):
+ """Test correct behaviour of TaskQueryset.non_workflow()."""
+
+ SubTaskFactory(parent_task=task)
+ SubTaskFactory(parent_task=task_workflow_single_task_item.get_tasks().get())
+
+ non_workflow_tasks = Task.objects.non_workflow()
+
+ # 1 x standalone task + 1 summary task + 1 x workflow task + 2 x subtasks
+ assert Task.objects.count() == 5
+ assert non_workflow_tasks.get() == task
+
+
+def test_workflow_summary_queryset(task, task_workflow_single_task_item):
+ """Test correct behaviour of TaskQueryset.workflow_summary()."""
+
+ """Return a queryset of TaskWorkflow summary Task instances, i.e. those
+ with a non-null related_name=taskworkflow."""
+
+ SubTaskFactory(parent_task=task)
+ SubTaskFactory(parent_task=task_workflow_single_task_item.get_tasks().get())
+
+ workflow_summary_tasks = Task.objects.workflow_summary()
+
+ # 1 x standalone task + 1 summary task + 1 x workflow task + 2 x subtasks
+ assert Task.objects.count() == 5
+ assert workflow_summary_tasks.get() == task_workflow_single_task_item.summary_task
+
+
+def test_top_level_task_queryset(task, task_workflow_single_task_item):
+ """Test correct behaviour of TaskQueryset.top_level()."""
+
+ SubTaskFactory(parent_task=task)
+ SubTaskFactory(parent_task=task_workflow_single_task_item.get_tasks().get())
+
+ top_level_tasks = Task.objects.top_level()
+
+ # 1 x standalone task + 1 summary task + 1 x workflow task + 2 x subtasks
+ assert Task.objects.count() == 5
+ assert top_level_tasks.count() == 2
+ assert task_workflow_single_task_item.summary_task in top_level_tasks
+ assert task in top_level_tasks
+ assert task_workflow_single_task_item.get_tasks().get() not in top_level_tasks
+
+
+def test_create_task_log_task_assigned():
+ task = TaskFactory.create()
+ instigator = task.creator
+ action = TaskLog.AuditActionType.TASK_ASSIGNED
+ task_log = TaskLog.objects.create(
+ task=task,
+ action=action,
+ instigator=instigator,
+ assignee=instigator,
+ )
+
+ assert task_log.task == task
+ assert task_log.instigator == instigator
+ assert task_log.action == action
+ assert task_log.description == f"{instigator} assigned {instigator}"
+
+
+def test_create_task_log_task_unassigned():
+ task = TaskFactory.create()
+ instigator = task.creator
+ action = TaskLog.AuditActionType.TASK_UNASSIGNED
+ task_log = TaskLog.objects.create(
+ task=task,
+ action=action,
+ instigator=instigator,
+ assignee=instigator,
+ )
+
+ assert task_log.task == task
+ assert task_log.instigator == instigator
+ assert task_log.action == action
+ assert task_log.description == f"{instigator} unassigned {instigator}"
+
+
+def test_create_task_log_progress_state_updated():
+ task = TaskFactory.create()
+ instigator = task.creator
+ action = TaskLog.AuditActionType.PROGRESS_STATE_UPDATED
+ progress_state = ProgressStateFactory.create()
+ task_log = TaskLog.objects.create(
+ task=task,
+ action=action,
+ instigator=instigator,
+ progress_state=progress_state,
+ )
+
+ assert task_log.task == task
+ assert task_log.instigator == instigator
+ assert task_log.action == action
+ assert (
+ task_log.description == f"{instigator} changed the status to {progress_state}"
+ )
+
+
+def test_create_task_log_invalid_audit_action():
+ task = TaskFactory.create()
+ instigator = task.creator
+ action = "INVALID_AUDIT_ACTION"
+
+ with pytest.raises(ValueError) as error:
+ TaskLog.objects.create(task=task, action=action, instigator=instigator)
+ assert f"The action '{action}' is an invalid TaskLog.AuditActionType value." in str(
+ error,
+ )
+
+
+def test_create_task_log_missing_kwargs():
+ task = TaskFactory.create()
+ instigator = task.creator
+ action = TaskLog.AuditActionType.TASK_ASSIGNED
+
+ with pytest.raises(ValueError) as error:
+ TaskLog.objects.create(task=task, action=action, instigator=instigator)
+ assert f"Missing 'assignee' in kwargs for action '{action}'." in str(
+ error,
+ )
+
+
+@pytest.mark.parametrize(
+ ("task_factory"),
+ [TaskFactory, SubTaskFactory],
+ ids=("task test", "subtask test"),
+)
+def test_task_is_subtask_property(task_factory):
+ task = task_factory.create()
+
+ assert bool(task.parent_task) == task.is_subtask
+
+
+@pytest.mark.parametrize(
+ ("create_task_fn"),
+ (
+ lambda: TaskFactory.create(),
+ lambda: TaskWorkflowFactory.create().summary_task,
+ ),
+ ids=("standalone task test", "summary task test"),
+)
+def test_task_is_summary_task_property(create_task_fn):
+ task = create_task_fn()
+ assert bool(hasattr(task, "taskworkflow")) == task.is_summary_task
diff --git a/tasks/tests/test_queue/__init__.py b/tasks/tests/test_queue/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tasks/tests/test_queue/models.py b/tasks/tests/test_queue/models.py
new file mode 100644
index 000000000..1e53ca973
--- /dev/null
+++ b/tasks/tests/test_queue/models.py
@@ -0,0 +1,24 @@
+from django.db import models
+
+from tasks.models import Queue
+from tasks.models import QueueItem
+
+
+class TestQueue(Queue):
+ """Concrete subclass of Queue."""
+
+ class Meta:
+ abstract = False
+
+
+class TestQueueItem(QueueItem):
+ """Concrete subclass of QueueItem."""
+
+ class Meta:
+ abstract = False
+
+ queue = models.ForeignKey(
+ TestQueue,
+ related_name="queue_items",
+ on_delete=models.CASCADE,
+ )
diff --git a/tasks/tests/test_queue_models.py b/tasks/tests/test_queue_models.py
new file mode 100644
index 000000000..0021fe2d3
--- /dev/null
+++ b/tasks/tests/test_queue_models.py
@@ -0,0 +1,395 @@
+import threading
+from functools import wraps
+
+import pytest
+from django.db import OperationalError
+from django.db.models import CASCADE
+from django.db.models import ForeignKey
+from django.db.models import QuerySet
+from factory import SubFactory
+from factory.django import DjangoModelFactory
+
+from tasks.models import QueueItem
+from tasks.models import RequiredFieldError
+from tasks.tests.test_queue.models import TestQueue
+from tasks.tests.test_queue.models import TestQueueItem
+
+pytestmark = pytest.mark.django_db
+
+
+class TestQueueFactory(DjangoModelFactory):
+ """Factory for TestQueue."""
+
+ class Meta:
+ abstract = False
+ model = TestQueue
+
+
+class TestQueueItemFactory(DjangoModelFactory):
+ """Factory for TestQueueItem."""
+
+ class Meta:
+ model = TestQueueItem
+ abstract = False
+
+ queue = SubFactory(TestQueueFactory)
+
+
+@pytest.fixture()
+def queue() -> TestQueue:
+ """Return an instance of TestQueue that contains no TestQueueItems."""
+ queue = TestQueueFactory.create()
+
+ assert not queue.get_items().exists()
+
+ return queue
+
+
+@pytest.fixture()
+def single_item_queue(queue) -> TestQueue:
+ """Return an instance of TestQueue containing a single TestQueueItem."""
+ TestQueueItemFactory.create(queue=queue)
+
+ assert queue.get_items().count() == 1
+
+ return queue
+
+
+@pytest.fixture()
+def three_item_queue(queue) -> TestQueue:
+ """Return an instance of TestQueue containing three TestQueueItem
+ instances."""
+ TestQueueItemFactory.create(queue=queue)
+ TestQueueItemFactory.create(queue=queue)
+ TestQueueItemFactory.create(queue=queue)
+
+ assert queue.get_items().count() == 3
+
+ return queue
+
+
+def test_queueitem_metaclass_validation_missing_queue_field():
+ """Tests that a concrete sublass of `QueueItem` must provide a queue field
+ on the model."""
+ with pytest.raises(RequiredFieldError) as error:
+
+ class TestQueueItemSubclass(QueueItem):
+ class Meta:
+ abstract = False
+
+ TestQueueItemSubclass()
+
+ assert (
+ "must have a 'queue' ForeignKey field. The name of the field must match the value given to the 'queue_field' attribute on the model."
+ in str(error.value)
+ )
+
+
+def test_queueitem_metaclass_validation_mismatched_queue_field():
+ """Tests that the `QueueItem.queue_field` attribute on a concrete subclass
+ must match its queue ForeignKey field."""
+ with pytest.raises(RequiredFieldError) as error:
+
+ class TestQueueItemSubclass(QueueItem):
+ class Meta:
+ abstract = False
+
+ queue_field = "test_queue"
+ queue = ForeignKey(TestQueue, on_delete=CASCADE)
+
+ TestQueueItemSubclass()
+
+ assert (
+ "The name of the field must match the value given to the 'queue_field' attribute on the model."
+ in str(error.value)
+ )
+
+
+def test_queueitem_metaclass_validation_invalid_model():
+ """Tests that a concrete subclass of `QueueItem` must have a ForeignKey
+ field to a subclass of `Queue`."""
+ with pytest.raises(RequiredFieldError) as error:
+
+ class TestQueueItemSubclass(QueueItem):
+ class Meta:
+ abstract = False
+
+ queue_field = "test_queue"
+ test_queue = ForeignKey(QueueItem, on_delete=CASCADE)
+
+ TestQueueItemSubclass()
+
+ assert "must be a ForeignKey field to a subclass of 'Queue' model" in str(
+ error.value,
+ )
+
+
+def test_empty_queue(queue):
+ assert queue.max_position == 0
+ assert queue.get_first() == None
+ assert queue.get_item(1) == None
+ assert queue.get_last() == None
+
+
+def test_non_empty_queue(queue):
+ first_item = TestQueueItemFactory.create(queue=queue)
+ second_item = TestQueueItemFactory.create(queue=queue)
+ third_item = TestQueueItemFactory.create(queue=queue)
+
+ assert first_item.position == 1
+ assert second_item.position == 2
+ assert third_item.position == 3
+ assert {first_item, second_item, third_item} == set(queue.get_items())
+ assert queue.max_position == 3
+ assert queue.get_items().count() == 3
+ assert queue.get_first() == first_item
+ assert queue.get_last() == third_item
+ assert queue.get_item(2) == second_item
+
+
+def test_item_delete(three_item_queue):
+ three_item_queue.get_first().delete()
+
+ assert three_item_queue.get_items().count() == 2
+
+ three_item_queue.get_item(1).delete()
+ three_item_queue.get_last().delete()
+
+ assert three_item_queue.get_items().count() == 0
+
+
+def test_item_promote(three_item_queue):
+ item = three_item_queue.get_last()
+
+ assert item.position == 3
+
+ item = item.promote()
+ item = item.promote()
+
+ assert item.position == 1
+
+ item = item.promote()
+
+ assert item.position == 1
+
+
+def test_item_demote(three_item_queue):
+ item = three_item_queue.get_first()
+
+ assert item.position == 1
+
+ item = item.demote()
+ item = item.demote()
+
+ assert item.position == 3
+
+ item = item.demote()
+
+ assert item.position == 3
+
+
+def test_item_demote_to_last(three_item_queue):
+ item = three_item_queue.get_first()
+
+ assert item.position == 1
+
+ item = item.demote_to_last()
+
+ assert item.position == 3
+
+ item = item.demote_to_last()
+
+ assert item.position == 3
+
+
+def test_item_promote_to_first(three_item_queue):
+ item = three_item_queue.get_last()
+
+ assert item.position == 3
+
+ item = item.promote_to_first()
+
+ assert item.position == 1
+
+ item = item.promote_to_first()
+
+ assert item.position == 1
+
+
+@pytest.mark.django_db(transaction=True)
+class TestQueueRaceConditions:
+ """Tests that concurrent requests to reorder queue items don't result in
+ duplicate or non-consecutive positions."""
+
+ NUM_THREADS: int = 2
+ """The number of threads each test uses."""
+
+ THREAD_TIMEOUT: int = 5
+ """The duration in seconds to wait for a thread to complete before timing
+ out."""
+
+ NUM_QUEUE_ITEMS: int = 5
+ """The number of queue items to create for each test."""
+
+ @pytest.fixture(autouse=True)
+ def setup(self, queue):
+ """Initialises a barrier to synchronise threads and creates queue items
+ anew for each test."""
+
+ self.unexpected_exceptions: list[Exception] = []
+
+ self.barrier: threading.Barrier = threading.Barrier(
+ parties=self.NUM_THREADS,
+ timeout=self.THREAD_TIMEOUT,
+ )
+
+ self.queue: TestQueue = queue
+ self.queue_items: QuerySet[TestQueueItem] = TestQueueItemFactory.create_batch(
+ self.NUM_QUEUE_ITEMS,
+ queue=queue,
+ )
+
+ def assert_no_unexpected_exceptions(self):
+ """Asserts that no threads raised an unexpected exception."""
+ assert (
+ not self.unexpected_exceptions
+ ), f"Unexpected exception(s) raised: {self.unexpected_exceptions}"
+
+ def assert_expected_positions(self):
+ """Asserts that queue item positions remain both unique and in
+ consecutive sequence."""
+ positions = list(
+ TestQueueItem.objects.filter(
+ queue=self.queue,
+ )
+ .order_by("position")
+ .values_list("position", flat=True),
+ )
+
+ assert len(set(positions)) == len(positions), "Duplicate positions found!"
+
+ assert positions == list(
+ range(min(positions), max(positions) + 1),
+ ), "Non-consecutive positions found!"
+
+ def synchronised(func):
+ """
+ Decorator that ensures all threads wait until they can call their target
+ function in a synchronised fashion.
+
+ Any unexpected exceptions raised during the execution of the decorated
+ function are stored for the individual test to re-raise.
+ """
+
+ @wraps(func)
+ def wrapper(self, *args, **kwargs):
+ try:
+ self.barrier.wait()
+ func(self, *args, **kwargs)
+ except OperationalError:
+ # A conflicting lock is already acquired
+ pass
+ except Exception as error:
+ self.unexpected_exceptions.append(error)
+
+ return wrapper
+
+ @synchronised
+ def synchronised_call(
+ self,
+ method_name: str,
+ queue_item: TestQueueItem,
+ ):
+ """
+ Thread-synchronised wrapper for the following `QueueItem` instance
+ methods:
+
+ - delete
+ - promote
+ - demote
+ - promote_to_first
+ - demote_to_last
+ """
+ getattr(queue_item, method_name)()
+
+ @synchronised
+ def synchronised_create_queue_item(self):
+ """Thread-synchronised wrapper to create a new queue item instance."""
+ TestQueueItemFactory.create(queue=self.queue)
+
+ def execute_threads(self, threads: list[threading.Thread]):
+ """Starts a list of threads and waits for them to complete or
+ timeout."""
+ for thread in threads:
+ thread.start()
+
+ for thread in threads:
+ thread.join(timeout=self.THREAD_TIMEOUT)
+ if thread.is_alive():
+ raise RuntimeError(f"Thread {thread.name} timed out.")
+
+ def test_demote_and_promote_queue_items(self):
+ """Demotes and promotes the same queue item."""
+ thread1 = threading.Thread(
+ target=self.synchronised_call,
+ kwargs={
+ "method_name": "demote",
+ "queue_item": self.queue_items[2],
+ },
+ name="DemoteItemThread1",
+ )
+ thread2 = threading.Thread(
+ target=self.synchronised_call,
+ kwargs={
+ "method_name": "promote",
+ "queue_item": self.queue_items[2],
+ },
+ name="PromoteItemThread2",
+ )
+
+ self.execute_threads([thread1, thread2])
+ self.assert_no_unexpected_exceptions()
+ self.assert_expected_positions()
+
+ def test_delete_and_create_queue_items(self):
+ """Deletes the first item while creating a new one."""
+ thread1 = threading.Thread(
+ target=self.synchronised_call,
+ kwargs={
+ "method_name": "delete",
+ "queue_item": self.queue_items[0],
+ },
+ name="DeleteItemThread1",
+ )
+ thread2 = threading.Thread(
+ target=self.synchronised_create_queue_item,
+ name="CreateItemThread2",
+ )
+
+ self.execute_threads([thread1, thread2])
+ self.assert_no_unexpected_exceptions()
+ self.assert_expected_positions()
+
+ def test_promote_and_promote_to_first_queue_items(self):
+ """Promotes to first the last-placed item while promoting the one before
+ it."""
+ thread1 = threading.Thread(
+ target=self.synchronised_call,
+ kwargs={
+ "method_name": "promote_to_first",
+ "queue_item": self.queue_items[4],
+ },
+ name="PromoteItemToFirstThread1",
+ )
+ thread2 = threading.Thread(
+ target=self.synchronised_call,
+ kwargs={
+ "method_name": "promote",
+ "queue_item": self.queue_items[3],
+ },
+ name="PromoteItemThread2",
+ )
+
+ self.execute_threads([thread1, thread2])
+ self.assert_no_unexpected_exceptions()
+ self.assert_expected_positions()
diff --git a/tasks/tests/test_views.py b/tasks/tests/test_views.py
new file mode 100644
index 000000000..5adde8201
--- /dev/null
+++ b/tasks/tests/test_views.py
@@ -0,0 +1,839 @@
+from datetime import date
+
+import factory
+import pytest
+from bs4 import BeautifulSoup
+from django.urls import reverse
+
+import settings
+from common.tests.factories import ProgressStateFactory
+from common.tests.factories import SubTaskFactory
+from common.tests.factories import TaskFactory
+from common.util import format_date
+from tasks.models import ProgressState
+from tasks.models import Task
+from tasks.models import TaskItem
+from tasks.models import TaskItemTemplate
+from tasks.models import TaskLog
+from tasks.models import TaskTemplate
+from tasks.models import TaskWorkflow
+from tasks.models import TaskWorkflowTemplate
+from tasks.tests.factories import TaskItemTemplateFactory
+from tasks.tests.factories import TaskWorkflowTemplateFactory
+
+pytestmark = pytest.mark.django_db
+
+
+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,
+ )
+
+
+@pytest.mark.parametrize(
+ ("object_type", "success_url"),
+ [
+ ("Task", "workflow:task-ui-confirm-create"),
+ ("Subtask", "workflow:subtask-ui-confirm-create"),
+ ],
+ ids=("task test", "subtask test"),
+)
+def test_confirm_create_template_shows_task_or_subtask(
+ valid_user_client,
+ object_type,
+ success_url,
+):
+ """Test the confirm create template distinguishes between subtask or task's
+ creation."""
+
+ parent_task_instance = TaskFactory.create(
+ progress_state__name=ProgressState.State.TO_DO,
+ )
+
+ url = reverse(
+ success_url,
+ kwargs={
+ "pk": parent_task_instance.pk,
+ },
+ )
+ response = valid_user_client.get(url)
+
+ assert response.status_code == 200
+
+ page = BeautifulSoup(response.content.decode(response.charset), "html.parser")
+ expected_h1_text = f"{object_type}: {parent_task_instance.title}"
+
+ assert expected_h1_text in page.find("h1").text
+
+
+@pytest.mark.parametrize(
+ ("task_factory", "update_url"),
+ [
+ (TaskFactory, "workflow:task-ui-update"),
+ (SubTaskFactory, "workflow:subtask-ui-update"),
+ ],
+ ids=("task test", "subtask test"),
+)
+def test_update_link_changes_for_task_and_subtask(
+ superuser_client,
+ task_factory,
+ update_url,
+):
+ task = task_factory.create()
+
+ url = reverse(
+ "workflow:task-ui-detail",
+ kwargs={
+ "pk": task.pk,
+ },
+ )
+ response = superuser_client.get(url)
+ assert response.status_code == 200
+
+ page = BeautifulSoup(response.content.decode(response.charset), "html.parser")
+
+ update_link = reverse(
+ update_url,
+ kwargs={
+ "pk": task.pk,
+ },
+ )
+ assert page.find("a", href=update_link)
+
+
+@pytest.mark.parametrize(
+ ("task_factory"),
+ [TaskFactory, SubTaskFactory],
+ ids=("task test", "subtask test"),
+)
+def test_create_subtask_button_shows_only_for_non_parent_tasks(
+ superuser_client,
+ task_factory,
+):
+ task = task_factory.create()
+
+ url = reverse(
+ "workflow:task-ui-detail",
+ kwargs={
+ "pk": task.pk,
+ },
+ )
+ response = superuser_client.get(url)
+ assert response.status_code == 200
+
+ page = BeautifulSoup(response.content.decode(response.charset), "html.parser")
+
+ create_subtask_url = reverse(
+ "workflow:subtask-ui-create",
+ kwargs={"parent_task_pk": task.pk},
+ )
+
+ assert bool(page.find("a", href=create_subtask_url)) != task.is_subtask
+
+
+def test_create_subtask_form_errors_when_parent_is_subtask(valid_user_client):
+ """Tests that the SubtaskCreateForm errors when a form is submitted that has
+ a subtask as a parent."""
+
+ subtask_parent = SubTaskFactory.create()
+ progress_state = ProgressStateFactory.create()
+
+ subtask_form_data = {
+ "progress_state": progress_state.pk,
+ "title": "subtask test title",
+ "description": "subtask test description",
+ }
+
+ url = reverse(
+ "workflow:subtask-ui-create",
+ kwargs={
+ "parent_task_pk": subtask_parent.pk,
+ },
+ )
+
+ response = valid_user_client.post(url, subtask_form_data)
+
+ assert response.status_code == 200
+ assert not response.context_data["form"].is_valid()
+ soup = BeautifulSoup(str(response.content), "html.parser")
+ assert (
+ "You cannot make a subtask from a subtask."
+ in soup.find("div", class_="govuk-error-summary").text
+ )
+
+
+@pytest.mark.parametrize(
+ ("client_type", "expected_status_code_get", "expected_status_code_post"),
+ [
+ ("valid_user_client", 200, 302),
+ ("client_with_current_workbasket_no_permissions", 403, 403),
+ ],
+)
+def test_delete_subtask_missing_user_permissions(
+ client_type,
+ expected_status_code_get,
+ expected_status_code_post,
+ request,
+):
+ """Tests that attempting to delete a subtask fails for users without the
+ necessary permissions."""
+ client_type = request.getfixturevalue(client_type)
+ subtask_instance = SubTaskFactory.create(
+ progress_state__name=ProgressState.State.TO_DO,
+ )
+
+ url = reverse(
+ "workflow:subtask-ui-delete",
+ kwargs={"pk": subtask_instance.pk},
+ )
+
+ get_response = client_type.get(url)
+ assert get_response.status_code == expected_status_code_get
+
+ response = client_type.post(url)
+ assert response.status_code == expected_status_code_post
+
+
+def test_workflow_template_detail_view_displays_task_templates(valid_user_client):
+ task_item_template = TaskItemTemplateFactory.create()
+ workflow_template = task_item_template.workflow_template
+
+ url = reverse(
+ "workflow:task-workflow-template-ui-detail",
+ kwargs={"pk": workflow_template.pk},
+ )
+ response = valid_user_client.get(url)
+ assert response.status_code == 200
+
+ page = BeautifulSoup(response.content.decode(response.charset), "html.parser")
+
+ assert f"Ticket template {workflow_template.id}" in page.find("h1").text
+ assert page.find("p", text=workflow_template.description)
+ assert page.find("dd", text=workflow_template.creator.get_displayname())
+ assert page.find("dd", text=format_date(workflow_template.created_at))
+
+ template_rows = page.select(".govuk-table__body > .govuk-table__row")
+ assert len(template_rows) == workflow_template.get_task_templates().count()
+
+
+@pytest.mark.parametrize(
+ ("action", "item_position", "expected_item_order"),
+ [
+ ("promote", 1, [1, 2, 3]),
+ ("promote", 2, [2, 1, 3]),
+ ("demote", 2, [1, 3, 2]),
+ ("demote", 3, [1, 2, 3]),
+ ("promote_to_first", 3, [3, 1, 2]),
+ ("demote_to_last", 1, [2, 3, 1]),
+ ],
+)
+def test_workflow_template_detail_view_reorder_items(
+ action,
+ item_position,
+ expected_item_order,
+ valid_user_client,
+ task_workflow_template_three_task_template_items,
+):
+ """Tests that `TaskWorkflowTemplateDetailView` handles POST requests to
+ promote or demote task templates."""
+
+ def convert_to_index(position: int) -> int:
+ """Converts a 1-based item position to a 0-based index for items array
+ access."""
+ return position - 1
+
+ items = list(task_workflow_template_three_task_template_items.get_task_templates())
+ item_to_move = items[convert_to_index(item_position)]
+
+ url = reverse(
+ "workflow:task-workflow-template-ui-detail",
+ kwargs={"pk": task_workflow_template_three_task_template_items.pk},
+ )
+ form_data = {
+ action: item_to_move.id,
+ }
+
+ response = valid_user_client.post(url, form_data)
+ assert response.status_code == 302
+
+ reordered_items = (
+ task_workflow_template_three_task_template_items.get_task_templates()
+ )
+ for i, reordered_item in enumerate(reordered_items):
+ expected_position = convert_to_index(expected_item_order[i])
+ expected_item = items[expected_position]
+ assert reordered_item.id == expected_item.id
+
+
+def test_workflow_template_create_view(valid_user_client):
+ """Tests that a new workflow template can be created and that the
+ corresponding confirmation view returns a HTTP 200 response."""
+
+ assert not TaskWorkflowTemplate.objects.exists()
+
+ create_url = reverse("workflow:task-workflow-template-ui-create")
+ form_data = {
+ "title": "Test workflow template",
+ "description": "Test description",
+ }
+ create_response = valid_user_client.post(create_url, form_data)
+
+ created_workflow_template = TaskWorkflowTemplate.objects.get(
+ title=form_data["title"],
+ description=form_data["description"],
+ )
+ confirmation_url = reverse(
+ "workflow:task-workflow-template-ui-confirm-create",
+ kwargs={"pk": created_workflow_template.pk},
+ )
+ assert create_response.status_code == 302
+ assert create_response.url == confirmation_url
+
+ confirmation_response = valid_user_client.get(confirmation_url)
+
+ soup = BeautifulSoup(str(confirmation_response.content), "html.parser")
+
+ assert confirmation_response.status_code == 200
+ assert (
+ created_workflow_template.title in soup.select("h1.govuk-panel__title")[0].text
+ )
+
+
+def test_workflow_template_update_view(
+ valid_user_client,
+ task_workflow_template,
+):
+ """Tests that a workflow template can be updated and that the corresponding
+ confirmation view returns a HTTP 200 response."""
+
+ update_url = reverse(
+ "workflow:task-workflow-template-ui-update",
+ kwargs={"pk": task_workflow_template.pk},
+ )
+ form_data = {
+ "title": "Updated test title",
+ "description": "Updated test title",
+ }
+
+ update_response = valid_user_client.post(update_url, form_data)
+ assert update_response.status_code == 302
+
+ task_workflow_template.refresh_from_db()
+ assert task_workflow_template.title == form_data["title"]
+ assert task_workflow_template.description == form_data["description"]
+
+ confirmation_url = reverse(
+ "workflow:task-workflow-template-ui-confirm-update",
+ kwargs={"pk": task_workflow_template.pk},
+ )
+ assert update_response.url == confirmation_url
+
+ confirmation_response = valid_user_client.get(confirmation_url)
+ assert confirmation_response.status_code == 200
+
+ soup = BeautifulSoup(str(confirmation_response.content), "html.parser")
+ assert (
+ str(task_workflow_template.id) in soup.select("h1.govuk-panel__title")[0].text
+ )
+
+
+def test_workflow_template_delete_view(
+ valid_user_client,
+ task_workflow_template_single_task_template_item,
+):
+ """Tests that a workflow template can be deleted (along with related
+ TaskItemTemplate and TaskTemplate objects) and that the corresponding
+ confirmation view returns a HTTP 200 response."""
+
+ task_workflow_template_pk = task_workflow_template_single_task_template_item.pk
+ task_template_pk = (
+ task_workflow_template_single_task_template_item.get_task_templates().get().pk
+ )
+
+ delete_url = task_workflow_template_single_task_template_item.get_url("delete")
+ delete_response = valid_user_client.post(delete_url)
+ assert delete_response.status_code == 302
+
+ assert not TaskWorkflowTemplate.objects.filter(
+ pk=task_workflow_template_pk,
+ ).exists()
+ assert not TaskItemTemplate.objects.filter(
+ workflow_template_id=task_workflow_template_pk,
+ ).exists()
+ assert not TaskTemplate.objects.filter(pk=task_template_pk).exists()
+
+ confirmation_url = reverse(
+ "workflow:task-workflow-template-ui-confirm-delete",
+ kwargs={"pk": task_workflow_template_pk},
+ )
+ assert delete_response.url == confirmation_url
+
+ confirmation_response = valid_user_client.get(confirmation_url)
+ assert confirmation_response.status_code == 200
+
+ soup = BeautifulSoup(str(confirmation_response.content), "html.parser")
+ assert (
+ f"Workflow template ID: {task_workflow_template_pk}"
+ in soup.select(".govuk-panel__title")[0].text
+ )
+
+
+def test_create_task_template_view(valid_user_client, task_workflow_template):
+ """Test the view for creating new TaskTemplates and the confirmation view
+ that a successful creation redirects to."""
+
+ assert task_workflow_template.get_task_templates().count() == 0
+
+ create_url = reverse(
+ "workflow:task-template-ui-create",
+ kwargs={"workflow_template_pk": task_workflow_template.pk},
+ )
+ form_data = {
+ "title": factory.Faker("sentence"),
+ "description": factory.Faker("sentence"),
+ }
+ create_response = valid_user_client.post(create_url, form_data)
+ created_task_template = task_workflow_template.get_task_templates().get()
+ confirmation_url = reverse(
+ "workflow:task-template-ui-confirm-create",
+ kwargs={"pk": created_task_template.pk},
+ )
+
+ assert create_response.status_code == 302
+ assert task_workflow_template.get_task_templates().count() == 1
+ assert create_response.url == confirmation_url
+
+ confirmation_response = valid_user_client.get(confirmation_url)
+
+ soup = BeautifulSoup(str(confirmation_response.content), "html.parser")
+
+ assert confirmation_response.status_code == 200
+ assert created_task_template.title in soup.select("h1.govuk-panel__title")[0].text
+
+
+def test_task_template_detail_view(
+ valid_user_client,
+ task_workflow_template_single_task_template_item,
+):
+ task_template = (
+ task_workflow_template_single_task_template_item.get_task_templates().get()
+ )
+ url = reverse("workflow:task-template-ui-detail", kwargs={"pk": task_template.pk})
+ response = valid_user_client.get(url)
+
+ soup = BeautifulSoup(str(response.content), "html.parser")
+
+ assert response.status_code == 200
+ assert (
+ task_template.title
+ in soup.select("div.govuk-summary-list__row:nth-child(2) > dd:nth-child(2)")[
+ 0
+ ].text
+ )
+
+
+def test_update_task_template_view(
+ valid_user_client,
+ task_workflow_template_single_task_template_item,
+):
+ """Test the view for updating TaskTemplates and the confirmation view that a
+ successful update redirects to."""
+
+ assert (
+ task_workflow_template_single_task_template_item.get_task_templates().count()
+ == 1
+ )
+
+ task_template = (
+ task_workflow_template_single_task_template_item.get_task_templates().get()
+ )
+ update_url = reverse(
+ "workflow:task-template-ui-update",
+ kwargs={"pk": task_template.pk},
+ )
+ appended_text = "updated"
+ form_data = {
+ "title": f"{task_template.title} {appended_text}",
+ "description": f"{task_template.description} {appended_text}",
+ }
+ update_response = valid_user_client.post(update_url, form_data)
+ updated_task_template = (
+ task_workflow_template_single_task_template_item.get_task_templates().get()
+ )
+ confirmation_url = reverse(
+ "workflow:task-template-ui-confirm-update",
+ kwargs={"pk": updated_task_template.pk},
+ )
+
+ assert update_response.status_code == 302
+ assert update_response.url == confirmation_url
+ assert (
+ task_workflow_template_single_task_template_item.get_task_templates().count()
+ == 1
+ )
+ assert updated_task_template.title.endswith(appended_text)
+ assert updated_task_template.description.endswith(appended_text)
+
+ confirmation_response = valid_user_client.get(confirmation_url)
+
+ soup = BeautifulSoup(str(confirmation_response.content), "html.parser")
+
+ assert confirmation_response.status_code == 200
+ assert updated_task_template.title in soup.select("h1.govuk-panel__title")[0].text
+
+
+def test_delete_task_template_view(
+ valid_user_client,
+ task_workflow_template_single_task_template_item,
+):
+ """Test the view for deleting TaskTemplates and the confirmation view that a
+ successful deletion redirects to."""
+
+ assert (
+ task_workflow_template_single_task_template_item.get_task_templates().count()
+ == 1
+ )
+ assert task_workflow_template_single_task_template_item.get_items().count() == 1
+
+ task_template_pk = (
+ task_workflow_template_single_task_template_item.get_task_templates().get().pk
+ )
+ task_item_template_pk = (
+ task_workflow_template_single_task_template_item.get_items().get().pk
+ )
+ delete_url = reverse(
+ "workflow:task-template-ui-delete",
+ kwargs={
+ "workflow_template_pk": task_workflow_template_single_task_template_item.pk,
+ "pk": task_template_pk,
+ },
+ )
+
+ delete_response = valid_user_client.post(delete_url)
+ task_workflow_template_after = TaskWorkflowTemplate.objects.get(
+ pk=task_workflow_template_single_task_template_item.pk,
+ )
+
+ confirmation_url = reverse(
+ "workflow:task-template-ui-confirm-delete",
+ kwargs={
+ "workflow_template_pk": task_workflow_template_single_task_template_item.pk,
+ "pk": task_template_pk,
+ },
+ )
+
+ assert delete_response.status_code == 302
+ assert delete_response.url == confirmation_url
+ assert task_workflow_template_after.get_task_templates().count() == 0
+ assert not TaskTemplate.objects.filter(pk=task_template_pk)
+ assert not TaskItemTemplate.objects.filter(pk=task_item_template_pk)
+
+ confirmation_response = valid_user_client.get(confirmation_url)
+
+ soup = BeautifulSoup(str(confirmation_response.content), "html.parser")
+
+ assert confirmation_response.status_code == 200
+ assert (
+ f"Task template ID: {task_template_pk}"
+ in soup.select(".govuk-panel__title")[0].text
+ )
+
+
+def test_workflow_template_list_view(valid_user_client, valid_user):
+ """Test that valid user receives a 200 on GET for TaskWorkflowList view and
+ values display in table."""
+
+ template_instance = TaskWorkflowTemplateFactory.create(creator=valid_user)
+ response = valid_user_client.get(reverse("workflow:task-workflow-template-ui-list"))
+
+ assert response.status_code == 200
+
+ soup = BeautifulSoup(str(response.content), "html.parser")
+ row_text = [td.text for td in soup.select("table tr:nth-child(1) > td")]
+
+ assert str(template_instance.id) in row_text
+ assert template_instance.title in row_text
+ assert template_instance.description in row_text
+ assert template_instance.creator.get_displayname() in row_text
+ assert (
+ f"{template_instance.updated_at.strftime(settings.DATETIME_FORMAT)}" in row_text
+ )
+ assert (
+ f"{template_instance.created_at.strftime(settings.DATETIME_FORMAT)}" in row_text
+ )
+
+
+def test_workflow_detail_view_displays_tasks(
+ valid_user_client,
+ task_workflow_single_task_item,
+):
+ workflow = task_workflow_single_task_item
+ workflow.policy_contact = "Policy contact"
+ workflow.eif_date = date.today()
+ workflow.save()
+ workbasket = workflow.summary_task.workbasket
+
+ url = reverse(
+ "workflow:task-workflow-ui-detail",
+ kwargs={"pk": workflow.pk},
+ )
+ response = valid_user_client.get(url)
+ assert response.status_code == 200
+
+ page = BeautifulSoup(response.content.decode(response.charset), "html.parser")
+
+ assert f"Ticket {workflow.id}" in page.find("h1").text
+ assert page.find("p", text=workflow.description)
+ assert page.find("a", text=f"{workbasket.pk} - {workbasket.status}")
+ assert page.find("dd", text=format_date(workflow.summary_task.created_at))
+ assert page.find("dd", text=format_date(workflow.eif_date))
+ assert page.find("dd", text=workflow.policy_contact)
+
+ step_rows = page.select(".govuk-table__body > .govuk-table__row")
+ assert len(step_rows) == workflow.get_tasks().count()
+
+
+def test_workflow_create_view(
+ valid_user,
+ valid_user_client,
+ task_workflow_template_single_task_template_item,
+):
+ """Tests that a new workflow can be created and that the corresponding
+ confirmation view returns a HTTP 200 response."""
+
+ assert not TaskWorkflow.objects.exists()
+
+ form_data = {
+ "ticket_name": "Test workflow 1",
+ "description": "Workflow created",
+ "work_type": task_workflow_template_single_task_template_item.pk,
+ }
+
+ create_url = reverse("workflow:task-workflow-ui-create")
+ create_response = valid_user_client.post(create_url, form_data)
+ assert create_response.status_code == 302
+
+ created_workflow = TaskWorkflow.objects.get(
+ summary_task__title=form_data["ticket_name"],
+ summary_task__description=form_data["description"],
+ summary_task__creator=valid_user,
+ )
+
+ assert (
+ created_workflow.get_tasks().count()
+ == task_workflow_template_single_task_template_item.get_task_templates().count()
+ )
+
+ confirmation_url = reverse(
+ "workflow:task-workflow-ui-detail",
+ kwargs={"pk": created_workflow.pk},
+ )
+ assert create_response.url == confirmation_url
+
+ confirmation_response = valid_user_client.get(confirmation_url)
+ assert confirmation_response.status_code == 200
+
+ soup = BeautifulSoup(str(confirmation_response.content), "html.parser")
+ assert str(created_workflow) in soup.select("h1")[0].text
+
+
+def test_workflow_update_view(
+ valid_user_client,
+ task_workflow,
+):
+ """Tests that a workflow can be updated and that the corresponding
+ confirmation view returns a HTTP 200 response."""
+
+ form_data = {
+ "title": "Updated title",
+ "description": "Updated description",
+ }
+ update_url = task_workflow.get_url("edit")
+
+ update_response = valid_user_client.post(update_url, form_data)
+ assert update_response.status_code == 302
+
+ task_workflow.refresh_from_db()
+ assert task_workflow.summary_task.title == form_data["title"]
+ assert task_workflow.summary_task.description == form_data["description"]
+
+ confirmation_url = reverse(
+ "workflow:task-workflow-ui-confirm-update",
+ kwargs={"pk": task_workflow.pk},
+ )
+ assert update_response.url == confirmation_url
+
+ confirmation_response = valid_user_client.get(confirmation_url)
+ assert confirmation_response.status_code == 200
+
+ soup = BeautifulSoup(str(confirmation_response.content), "html.parser")
+ assert str(task_workflow.id) in soup.select("h1.govuk-panel__title")[0].text
+
+
+def test_workflow_delete_view(
+ valid_user_client,
+ task_workflow_single_task_item,
+):
+ """Tests that a workflow can be deleted (along with related TaskItem and
+ Task objects) and that the corresponding confirmation view returns a HTTP
+ 200 response."""
+
+ workflow_pk = task_workflow_single_task_item.pk
+ summary_task_pk = task_workflow_single_task_item.summary_task.pk
+ task_pk = task_workflow_single_task_item.get_tasks().get().pk
+
+ delete_url = task_workflow_single_task_item.get_url("delete")
+ delete_response = valid_user_client.post(delete_url)
+ assert delete_response.status_code == 302
+
+ assert not TaskWorkflow.objects.filter(
+ pk=workflow_pk,
+ ).exists()
+ assert not TaskItem.objects.filter(
+ workflow_id=workflow_pk,
+ ).exists()
+ assert not Task.objects.filter(pk__in=[summary_task_pk, task_pk]).exists()
+
+ confirmation_url = reverse(
+ "workflow:task-workflow-ui-confirm-delete",
+ kwargs={"pk": workflow_pk},
+ )
+ assert delete_response.url == confirmation_url
+
+ confirmation_response = valid_user_client.get(confirmation_url)
+ assert confirmation_response.status_code == 200
+
+ soup = BeautifulSoup(str(confirmation_response.content), "html.parser")
+ assert f"Ticket ID: {workflow_pk}" in soup.select(".govuk-panel__title")[0].text
+
+
+def test_workflow_list_view(valid_user_client, task_workflow):
+ response = valid_user_client.get(reverse("workflow:task-workflow-ui-list"))
+
+ assert response.status_code == 200
+
+ page = BeautifulSoup(response.content.decode(response.charset), "html.parser")
+ table = page.select("table")[0]
+
+ assert len(table.select("tbody tr")) == 1
+ assert table.select("tr:nth-child(1) > td:nth-child(1) > a:nth-child(1)")[
+ 0
+ ].text == str(task_workflow.pk)
+
+
+def test_task_and_workflow_list_view(valid_user_client, task, task_workflow):
+ response = valid_user_client.get(reverse("workflow:task-and-workflow-ui-list"))
+
+ assert response.status_code == 200
+
+ page = BeautifulSoup(response.content.decode(response.charset), "html.parser")
+ table = page.select("table")[0]
+
+ assert len(table.select("tbody tr")) == 2
+ assert table.select("tr:nth-child(1) > td:nth-child(1) > a:nth-child(1)")[
+ 0
+ ].text == str(task.pk)
+ assert table.select("tr:nth-child(2) > td:nth-child(1) > a:nth-child(1)")[
+ 0
+ ].text == str(task_workflow.summary_task.pk)
+
+
+def test_create_workflow_task_view(valid_user_client, task_workflow):
+ """Test the view for creating new Tasks for an existing workflow and the
+ confirmation view that a successful creation redirects to."""
+
+ assert task_workflow.get_tasks().count() == 0
+
+ progress_state = ProgressStateFactory.create()
+
+ create_url = reverse(
+ "workflow:task-workflow-task-ui-create",
+ kwargs={"task_workflow_pk": task_workflow.pk},
+ )
+
+ form_data = {
+ "title": factory.Faker("sentence"),
+ "description": factory.Faker("sentence"),
+ "progress_state": progress_state.pk,
+ }
+ create_response = valid_user_client.post(create_url, form_data)
+
+ assert task_workflow.get_tasks().count() == 1
+ assert create_response.status_code == 302
+
+ created_workflow_task = task_workflow.get_tasks().get()
+ confirmation_url = reverse(
+ "workflow:task-workflow-task-ui-confirm-create",
+ kwargs={"pk": created_workflow_task.pk},
+ )
+ assert create_response.url == confirmation_url
+
+ confirmation_response = valid_user_client.get(confirmation_url)
+ assert confirmation_response.status_code == 200
+
+ soup = BeautifulSoup(
+ confirmation_response.content.decode(confirmation_response.charset),
+ "html.parser",
+ )
+ assert created_workflow_task.title in soup.select("h1.govuk-panel__title")[0].text
+
+
+def test_workflow_delete_view_deletes_related_tasks(
+ valid_user_client,
+ task_workflow_single_task_item,
+):
+ """Tests that a workflow can be deleted (along with related Task and
+ TaskItem objects) and that the corresponding confirmation view returns a
+ HTTP 200 response."""
+
+ task_workflow_pk = task_workflow_single_task_item.pk
+ task_pk = task_workflow_single_task_item.get_tasks().get().pk
+
+ delete_url = task_workflow_single_task_item.get_url("delete")
+ delete_response = valid_user_client.post(delete_url)
+ assert delete_response.status_code == 302
+
+ assert not TaskWorkflow.objects.filter(
+ pk=task_workflow_pk,
+ ).exists()
+ assert not TaskItem.objects.filter(
+ workflow_id=task_workflow_pk,
+ ).exists()
+ assert not Task.objects.filter(pk=task_pk).exists()
+
+ confirmation_url = reverse(
+ "workflow:task-workflow-ui-confirm-delete",
+ kwargs={"pk": task_workflow_pk},
+ )
+ assert delete_response.url == confirmation_url
+
+ confirmation_response = valid_user_client.get(confirmation_url)
+ assert confirmation_response.status_code == 200
+
+ soup = BeautifulSoup(str(confirmation_response.content), "html.parser")
+ assert (
+ f"Ticket ID: {task_workflow_pk}" in soup.select(".govuk-panel__title")[0].text
+ )
diff --git a/tasks/tests/test_workflow_models.py b/tasks/tests/test_workflow_models.py
new file mode 100644
index 000000000..dab1dfec9
--- /dev/null
+++ b/tasks/tests/test_workflow_models.py
@@ -0,0 +1,116 @@
+import datetime
+
+import pytest
+from django.core.exceptions import ObjectDoesNotExist
+
+from tasks.models import TaskItemTemplate
+from tasks.models import TaskTemplate
+from tasks.tests import factories
+
+pytestmark = pytest.mark.django_db
+
+
+def test_create_task_workflow_from_task_workflow_template(
+ valid_user,
+ task_workflow_template_three_task_template_items,
+):
+ """Test creation of TaskWorkflow instances from TaskWorkflowTemplates using
+ its `create_task_workflow()` method."""
+
+ ticket_name = "Workflow title"
+ description = "Workflow description"
+ creator = valid_user
+ eif_date = datetime.date(2026, 12, 12)
+ policy_contact = "Policy Contact"
+
+ task_workflow = (
+ task_workflow_template_three_task_template_items.create_task_workflow(
+ title=ticket_name,
+ description=description,
+ creator=creator,
+ eif_date=eif_date,
+ policy_contact=policy_contact,
+ )
+ )
+
+ # Test that workflow values are valid.
+ assert (
+ task_workflow.creator_template
+ == task_workflow_template_three_task_template_items
+ )
+ assert task_workflow.summary_task.title == ticket_name
+ assert task_workflow.summary_task.description == description
+ assert task_workflow.summary_task.creator == creator
+ assert task_workflow.get_items().count() == 3
+ assert task_workflow.eif_date == eif_date
+ assert task_workflow.policy_contact == policy_contact
+
+ # Validate that item positions are equivalent.
+ zipped_items = zip(
+ task_workflow_template_three_task_template_items.get_items(),
+ task_workflow.get_items(),
+ )
+ for item_template, item in zipped_items:
+ assert item_template.position == item.position
+
+ # Validate that object values are equivalent.
+ zipped_objs = zip(
+ task_workflow_template_three_task_template_items.get_task_templates(),
+ task_workflow.get_tasks(),
+ )
+ for task_template, task in zipped_objs:
+ assert task_template.title == task.title
+ assert task_template.description == task.description
+ assert task_template.category == task.category
+
+
+def test_delete_task_item_template():
+ task_item_template = factories.TaskItemTemplateFactory.create()
+ task_item_template_id = task_item_template.id
+ task_template_id = task_item_template.task_template.id
+
+ assert TaskItemTemplate.objects.get(id=task_item_template_id)
+ assert TaskTemplate.objects.get(id=task_template_id)
+
+ task_item_template.delete()
+
+ with pytest.raises(ObjectDoesNotExist):
+ TaskItemTemplate.objects.get(id=task_item_template_id)
+ assert TaskTemplate.objects.get(id=task_template_id)
+
+
+def test_delete_task_template():
+ task_item_template = factories.TaskItemTemplateFactory.create()
+ task_item_template_id = task_item_template.id
+ task_template = task_item_template.task_template
+ task_template_id = task_item_template.task_template.id
+
+ assert TaskItemTemplate.objects.get(id=task_item_template_id)
+ assert TaskTemplate.objects.get(id=task_template_id)
+
+ task_template.delete()
+
+ with pytest.raises(ObjectDoesNotExist):
+ TaskItemTemplate.objects.get(id=task_item_template_id)
+ with pytest.raises(ObjectDoesNotExist):
+ assert TaskTemplate.objects.get(id=task_template_id)
+
+
+def test_delete_task_workflow_template(
+ task_workflow_template_three_task_template_items,
+):
+ task_item_template_ids = [
+ item.id for item in task_workflow_template_three_task_template_items.get_items()
+ ]
+ task_template_ids = [
+ task.id
+ for task in task_workflow_template_three_task_template_items.get_task_templates()
+ ]
+
+ assert len(task_item_template_ids) == 3
+ assert len(task_template_ids) == 3
+
+ task_workflow_template_three_task_template_items.delete()
+
+ assert not TaskItemTemplate.objects.filter(id__in=task_item_template_ids).exists()
+ assert TaskTemplate.objects.filter(id__in=task_template_ids).count() == 3
diff --git a/tasks/urls.py b/tasks/urls.py
new file mode 100644
index 000000000..9acadb182
--- /dev/null
+++ b/tasks/urls.py
@@ -0,0 +1,227 @@
+from django.urls import include
+from django.urls import path
+
+from tasks import views
+
+app_name = "workflow"
+
+task_ui_patterns = [
+ # Task urls
+ 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",
+ ),
+ path(
+ "/assign-users/",
+ views.TaskAssignUsersView.as_view(),
+ name="task-ui-assign-users",
+ ),
+ path(
+ f"/unassign-users/",
+ views.TaskUnassignUsersView.as_view(),
+ name="task-ui-unassign-users",
+ ),
+ # Subtask urls
+ path(
+ "/subtasks/create/",
+ views.SubTaskCreateView.as_view(),
+ name="subtask-ui-create",
+ ),
+ path(
+ "subtasks//delete",
+ views.SubTaskDeleteView.as_view(),
+ name="subtask-ui-delete",
+ ),
+ path(
+ "subtasks//confirm-delete",
+ views.SubTaskConfirmDeleteView.as_view(),
+ name="subtask-ui-confirm-delete",
+ ),
+ path(
+ "subtasks//confirm-create/",
+ views.SubTaskConfirmCreateView.as_view(),
+ name="subtask-ui-confirm-create",
+ ),
+ path(
+ "subtasks//delete",
+ views.SubTaskDeleteView.as_view(),
+ name="subtask-ui-delete",
+ ),
+ path(
+ "subtasks//confirm-delete",
+ views.SubTaskConfirmDeleteView.as_view(),
+ name="subtask-ui-confirm-delete",
+ ),
+ path(
+ "subtasks//update/",
+ views.SubTaskUpdateView.as_view(),
+ name="subtask-ui-update",
+ ),
+ path(
+ "subtasks/confirm-update//",
+ views.SubTaskConfirmUpdateView.as_view(),
+ name="subtask-ui-confirm-update",
+ ),
+]
+
+workflow_ui_patterns = [
+ path(
+ "",
+ views.TaskWorkflowListView.as_view(),
+ name="task-workflow-ui-list",
+ ),
+ path(
+ "/",
+ views.TaskWorkflowDetailView.as_view(),
+ name="task-workflow-ui-detail",
+ ),
+ path(
+ "create/",
+ views.TaskWorkflowCreateView.as_view(),
+ name="task-workflow-ui-create",
+ ),
+ path(
+ "/confirm-create/",
+ views.TaskWorkflowConfirmCreateView.as_view(),
+ name="task-workflow-ui-confirm-create",
+ ),
+ path(
+ "/update/",
+ views.TaskWorkflowUpdateView.as_view(),
+ name="task-workflow-ui-update",
+ ),
+ path(
+ "/confirm-update/",
+ views.TaskWorkflowConfirmUpdateView.as_view(),
+ name="task-workflow-ui-confirm-update",
+ ),
+ path(
+ "/delete/",
+ views.TaskWorkflowDeleteView.as_view(),
+ name="task-workflow-ui-delete",
+ ),
+ path(
+ "/confirm-delete/",
+ views.TaskWorkflowConfirmDeleteView.as_view(),
+ name="task-workflow-ui-confirm-delete",
+ ),
+ path(
+ "/task/create/",
+ views.TaskWorkflowTaskCreateView.as_view(),
+ name="task-workflow-task-ui-create",
+ ),
+ path(
+ "task//confirm-create/",
+ views.TaskWorkflowTaskConfirmCreateView.as_view(),
+ name="task-workflow-task-ui-confirm-create",
+ ),
+]
+
+task_and_workflow_ui_patterns = [
+ path(
+ "",
+ views.TaskAndWorkflowListView.as_view(),
+ name="task-and-workflow-ui-list",
+ ),
+]
+
+workflow_template_ui_patterns = [
+ path(
+ "",
+ views.TaskWorkflowTemplateListView.as_view(),
+ name="task-workflow-template-ui-list",
+ ),
+ path(
+ "/",
+ views.TaskWorkflowTemplateDetailView.as_view(),
+ name="task-workflow-template-ui-detail",
+ ),
+ path(
+ "create/",
+ views.TaskWorkflowTemplateCreateView.as_view(),
+ name="task-workflow-template-ui-create",
+ ),
+ path(
+ "/confirm-create/",
+ views.TaskWorkflowTemplateConfirmCreateView.as_view(),
+ name="task-workflow-template-ui-confirm-create",
+ ),
+ path(
+ "/update/",
+ views.TaskWorkflowTemplateUpdateView.as_view(),
+ name="task-workflow-template-ui-update",
+ ),
+ path(
+ "/confirm-update/",
+ views.TaskWorkflowTemplateConfirmUpdateView.as_view(),
+ name="task-workflow-template-ui-confirm-update",
+ ),
+ path(
+ "/delete/",
+ views.TaskWorkflowTemplateDeleteView.as_view(),
+ name="task-workflow-template-ui-delete",
+ ),
+ path(
+ "/confirm-delete/",
+ views.TaskWorkflowTemplateConfirmDeleteView.as_view(),
+ name="task-workflow-template-ui-confirm-delete",
+ ),
+ path(
+ "task-templates//",
+ views.TaskTemplateDetailView.as_view(),
+ name="task-template-ui-detail",
+ ),
+ path(
+ "/task-templates/create/",
+ views.TaskTemplateCreateView.as_view(),
+ name="task-template-ui-create",
+ ),
+ path(
+ "task-templates/confirm-create//",
+ views.TaskTemplateConfirmCreateView.as_view(),
+ name="task-template-ui-confirm-create",
+ ),
+ path(
+ "task-templates//update/",
+ views.TaskTemplateUpdateView.as_view(),
+ name="task-template-ui-update",
+ ),
+ path(
+ "task-templates/confirm-update//",
+ views.TaskTemplateConfirmUpdateView.as_view(),
+ name="task-template-ui-confirm-update",
+ ),
+ path(
+ "/task-templates//delete/",
+ views.TaskTemplateDeleteView.as_view(),
+ name="task-template-ui-delete",
+ ),
+ path(
+ "/task-templates//confirm-delete/",
+ views.TaskTemplateConfirmDeleteView.as_view(),
+ name="task-template-ui-confirm-delete",
+ ),
+]
+
+urlpatterns = [
+ path("tasks/", include(task_ui_patterns)),
+ path("workflows/", include(workflow_ui_patterns)),
+ path("tasks-and-workflows/", include(task_and_workflow_ui_patterns)),
+ path("workflow-templates/", include(workflow_template_ui_patterns)),
+]
diff --git a/tasks/views.py b/tasks/views.py
new file mode 100644
index 000000000..71dd09e25
--- /dev/null
+++ b/tasks/views.py
@@ -0,0 +1,998 @@
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.db import OperationalError
+from django.db import transaction
+from django.http import HttpResponseRedirect
+from django.shortcuts import get_object_or_404
+from django.urls import reverse
+from django.utils.functional import cached_property
+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 FormView
+from django.views.generic.edit import UpdateView
+
+from common.views import SortingMixin
+from common.views import WithPaginationListView
+from tasks.filters import TaskAndWorkflowFilter
+from tasks.filters import TaskFilter
+from tasks.filters import TaskWorkflowFilter
+from tasks.filters import WorkflowTemplateFilter
+from tasks.forms import AssignUsersForm
+from tasks.forms import SubTaskCreateForm
+from tasks.forms import TaskCreateForm
+from tasks.forms import TaskDeleteForm
+from tasks.forms import TaskTemplateCreateForm
+from tasks.forms import TaskTemplateDeleteForm
+from tasks.forms import TaskTemplateUpdateForm
+from tasks.forms import TaskUpdateForm
+from tasks.forms import TaskWorkflowCreateForm
+from tasks.forms import TaskWorkflowDeleteForm
+from tasks.forms import TaskWorkflowTemplateCreateForm
+from tasks.forms import TaskWorkflowTemplateDeleteForm
+from tasks.forms import TaskWorkflowTemplateUpdateForm
+from tasks.forms import TaskWorkflowUpdateForm
+from tasks.forms import UnassignUsersForm
+from tasks.models import Queue
+from tasks.models import QueueItem
+from tasks.models import Task
+from tasks.models import TaskAssignee
+from tasks.models import TaskItem
+from tasks.models import TaskItemTemplate
+from tasks.models import TaskTemplate
+from tasks.models import TaskWorkflow
+from tasks.models import TaskWorkflowTemplate
+from tasks.signals import set_current_instigator
+
+User = get_user_model()
+
+
+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
+
+ def get_context_data(self, *, object_list=None, **kwargs):
+ context = super().get_context_data(object_list=object_list, **kwargs)
+ return context
+
+
+class TaskDetailView(PermissionRequiredMixin, DetailView):
+ model = Task
+ template_name = "tasks/detail.jinja"
+ permission_required = "tasks.view_task"
+
+ def get_context_data(self, **kwargs) -> dict:
+ context = super().get_context_data(**kwargs)
+
+ # TODO: Factor out queries and place in TaskAssigeeQuerySet.
+ current_assignees = TaskAssignee.objects.filter(
+ task=self.get_object(),
+ # TODO:
+ # Using all task assignees is temporary for illustration as it
+ # doesn't align with the new approach of assigning users to tasks
+ # rather than assigning users to workbaskets (the old approach,
+ # uses tasks as an intermediary joining object).
+ # assignment_type=TaskAssignee.AssignmentType.GENERAL,
+ ).assigned()
+
+ context["current_assignees"] = [
+ {"pk": assignee.pk, "name": assignee.user.get_full_name()}
+ for assignee in current_assignees.order_by(
+ "user__first_name",
+ "user__last_name",
+ )
+ ]
+ context["assignable_users"] = [
+ {"pk": user.pk, "name": user.get_full_name()}
+ for user in User.objects.active_tms().exclude(
+ pk__in=current_assignees.values_list("user__pk", flat=True),
+ )
+ ]
+
+ return context
+
+
+class TaskCreateView(PermissionRequiredMixin, CreateView):
+ model = Task
+ template_name = "tasks/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"
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["verbose_name"] = "task"
+ return context
+
+
+class TaskUpdateView(PermissionRequiredMixin, UpdateView):
+ model = Task
+ template_name = "tasks/edit.jinja"
+ permission_required = "tasks.change_task"
+ form_class = TaskUpdateForm
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["page_title"] = "Edit task details"
+ return context
+
+ 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"
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["page_title"] = "Task updated"
+ context["object_type"] = "Task"
+ return context
+
+
+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_context_data(self, **kwargs):
+ context_data = super().get_context_data(**kwargs)
+ context_data["verbose_name"] = "task"
+ return context_data
+
+ 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"]
+ context_data["verbose_name"] = "task"
+ return context_data
+
+
+class TaskAssignUsersView(PermissionRequiredMixin, FormView):
+ permission_required = "tasks.add_taskassignee"
+ template_name = "tasks/assign_users.jinja"
+ form_class = AssignUsersForm
+
+ @property
+ def task(self):
+ return Task.objects.get(pk=self.kwargs["pk"])
+
+ def get_context_data(self, *args, **kwargs):
+ context = super().get_context_data(*args, **kwargs)
+ context["page_title"] = "Assign users to task"
+ return context
+
+ def form_valid(self, form):
+ form.assign_users(task=self.task, user_instigator=self.request.user)
+ return HttpResponseRedirect(self.get_success_url())
+
+ def get_success_url(self):
+ return reverse(
+ "workflow:task-ui-detail",
+ kwargs={"pk": self.kwargs["pk"]},
+ )
+
+
+class TaskUnassignUsersView(PermissionRequiredMixin, FormView):
+ permission_required = "tasks.change_taskassignee"
+ template_name = "tasks/assign_users.jinja"
+ form_class = UnassignUsersForm
+
+ @property
+ def task(self):
+ return Task.objects.get(pk=self.kwargs["pk"])
+
+ def get_context_data(self, *args, **kwargs):
+ context = super().get_context_data(*args, **kwargs)
+ context["page_title"] = "Unassign users from task"
+ return context
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs["task"] = self.task
+ return kwargs
+
+ def form_valid(self, form):
+ form.unassign_users(self.request.user)
+ return HttpResponseRedirect(self.get_success_url())
+
+ def get_success_url(self):
+ return reverse(
+ "workflow:task-ui-detail",
+ kwargs={"pk": self.kwargs["pk"]},
+ )
+
+
+class SubTaskCreateView(PermissionRequiredMixin, CreateView):
+ model = Task
+ template_name = "tasks/create.jinja"
+ permission_required = "tasks.add_task"
+ form_class = SubTaskCreateForm
+
+ @property
+ def parent_task(self) -> Task:
+ return Task.objects.get(pk=self.kwargs["parent_task_pk"])
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["page_title"] = (
+ f"Create a subtask for task {self.kwargs['parent_task_pk']}"
+ )
+ context["parent_task"] = self.parent_task
+ return context
+
+ def form_valid(self, form):
+ if self.parent_task.parent_task:
+ form.add_error(
+ None,
+ "You cannot make a subtask from a subtask.",
+ )
+ return self.form_invalid(form)
+ else:
+ self.object = form.save(self.parent_task, user=self.request.user)
+ return HttpResponseRedirect(self.get_success_url())
+
+ def get_success_url(self):
+ return reverse(
+ "workflow:subtask-ui-confirm-create",
+ kwargs={"pk": self.object.pk},
+ )
+
+
+class SubTaskConfirmCreateView(DetailView):
+ model = Task
+ template_name = "tasks/confirm_create.jinja"
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["verbose_name"] = "subtask"
+ return context
+
+
+class SubTaskUpdateView(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_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["page_title"] = f"Edit subtask {self.object.pk}"
+ return context
+
+ def get_success_url(self):
+ return reverse(
+ "workflow:subtask-ui-confirm-update",
+ kwargs={"pk": self.object.pk},
+ )
+
+
+class SubTaskConfirmUpdateView(PermissionRequiredMixin, DetailView):
+ model = Task
+ template_name = "tasks/confirm_update.jinja"
+ permission_required = "tasks.change_task"
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["page_title"] = "Subtask updated"
+ context["object_type"] = "Subtask"
+ return context
+
+
+class SubTaskDeleteView(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_context_data(self, **kwargs):
+ context_data = super().get_context_data(**kwargs)
+ context_data["verbose_name"] = "subtask"
+ return context_data
+
+ def get_success_url(self):
+ return reverse(
+ "workflow:subtask-ui-confirm-delete",
+ kwargs={"pk": self.object.pk},
+ )
+
+
+class SubTaskConfirmDeleteView(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["verbose_name"] = "subtask"
+ context_data["deleted_pk"] = self.kwargs["pk"]
+ return context_data
+
+
+class TaskWorkflowListView(
+ PermissionRequiredMixin,
+ SortingMixin,
+ WithPaginationListView,
+):
+ model = Task
+ template_name = "tasks/workflows/list.jinja"
+ permission_required = "tasks.view_task"
+ paginate_by = settings.DEFAULT_PAGINATOR_PER_PAGE_MAX
+ filterset_class = TaskWorkflowFilter
+ 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
+
+ def get_context_data(self, *, object_list=None, **kwargs):
+ context = super().get_context_data(object_list=object_list, **kwargs)
+ return context
+
+
+class TaskAndWorkflowListView(
+ PermissionRequiredMixin,
+ SortingMixin,
+ WithPaginationListView,
+):
+ model = Task
+ template_name = "tasks/workflows/task-and-workflow-list.jinja"
+ permission_required = "tasks.view_task"
+ paginate_by = settings.DEFAULT_PAGINATOR_PER_PAGE_MAX
+ filterset_class = TaskAndWorkflowFilter
+ 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
+
+ def get_context_data(self, *, object_list=None, **kwargs):
+ context = super().get_context_data(object_list=object_list, **kwargs)
+ return context
+
+
+class TaskWorkflowTemplateListView(
+ PermissionRequiredMixin,
+ SortingMixin,
+ WithPaginationListView,
+):
+ model = TaskWorkflowTemplate
+ template_name = "tasks/workflows/template_list.jinja"
+ permission_required = "tasks.view_taskworkflowtemplate"
+ paginate_by = 20
+ filterset_class = WorkflowTemplateFilter
+ sort_by_fields = ["created_at", "updated_at"]
+
+ def get_context_data(self, **kwargs) -> dict:
+ context_data = super().get_context_data(**kwargs)
+ context_data["datetime_format"] = settings.DATETIME_FORMAT
+ return context_data
+
+ def get_queryset(self):
+ queryset = TaskWorkflowTemplate.objects.all()
+ ordering = self.get_ordering()
+ if ordering:
+ ordering = (ordering,)
+ queryset = queryset.order_by(*ordering)
+ return queryset
+
+
+class QueuedItemManagementMixin:
+ """A view mixin providing helper functions to manage queued items."""
+
+ queued_item_model: type[QueueItem] = None
+ """The model responsible for managing members of a queue."""
+
+ item_lookup_field: str = ""
+ """The lookup field of the instance managed by a queued item."""
+
+ queue_field: str = ""
+ """The name of the ForeignKey field relating a queued item to a queue."""
+
+ @cached_property
+ def queue(self) -> type[Queue]:
+ """The queue instance that is the object of the view."""
+ return self.get_object()
+
+ def promote(self, lookup_id: int) -> None:
+ queued_item = get_object_or_404(
+ self.queued_item_model,
+ **{
+ self.item_lookup_field: lookup_id,
+ self.queue_field: self.queue,
+ },
+ )
+ try:
+ queued_item.promote()
+ except OperationalError:
+ pass
+
+ def demote(self, lookup_id: int) -> None:
+ queued_item = get_object_or_404(
+ self.queued_item_model,
+ **{
+ self.item_lookup_field: lookup_id,
+ self.queue_field: self.queue,
+ },
+ )
+ try:
+ queued_item.demote()
+ except OperationalError:
+ pass
+
+ def promote_to_first(self, lookup_id: int) -> None:
+ queued_item = get_object_or_404(
+ self.queued_item_model,
+ **{
+ self.item_lookup_field: lookup_id,
+ self.queue_field: self.queue,
+ },
+ )
+ try:
+ queued_item.promote_to_first()
+ except OperationalError:
+ pass
+
+ def demote_to_last(self, lookup_id: int) -> None:
+ queued_item = get_object_or_404(
+ self.queued_item_model,
+ **{
+ self.item_lookup_field: lookup_id,
+ self.queue_field: self.queue,
+ },
+ )
+ try:
+ queued_item.demote_to_last()
+ except OperationalError:
+ pass
+
+
+class TaskWorkflowDetailView(
+ PermissionRequiredMixin,
+ DetailView,
+):
+ template_name = "tasks/workflows/detail.jinja"
+ permission_required = "tasks.view_taskworkflow"
+ model = TaskWorkflow
+
+ @property
+ def view_url(self) -> str:
+ return reverse(
+ "workflow:task-workflow-ui-detail",
+ kwargs={"pk": self.queue.pk},
+ )
+
+ def get_context_data(self, **kwargs):
+ context_data = super().get_context_data(**kwargs)
+ context_data.update(
+ {
+ "object_list": self.get_object().get_tasks(),
+ "verbose_name": "ticket",
+ "list_include": "tasks/includes/task_list.jinja",
+ },
+ )
+ return context_data
+
+
+class TaskWorkflowCreateView(PermissionRequiredMixin, FormView):
+ # Feb 2025 - Workflows will now be called Tickets in the UI only.
+ permission_required = "tasks.add_taskworkflow"
+ template_name = "tasks/workflows/create.jinja"
+ form_class = TaskWorkflowCreateForm
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["verbose_name"] = "ticket"
+ context["list_url"] = "#NOT-IMPLEMENTED"
+ return context
+
+ def form_valid(self, form):
+ data = {
+ "title": form.cleaned_data["ticket_name"],
+ "description": form.cleaned_data["description"],
+ "creator": self.request.user,
+ "eif_date": form.cleaned_data["entry_into_force_date"],
+ "policy_contact": form.cleaned_data["policy_contact"],
+ }
+ template = form.cleaned_data["work_type"]
+ self.object = template.create_task_workflow(**data)
+
+ return super().form_valid(form)
+
+ def get_success_url(self):
+ return reverse(
+ "workflow:task-workflow-ui-detail",
+ kwargs={"pk": self.object.pk},
+ )
+
+
+class TaskWorkflowConfirmCreateView(PermissionRequiredMixin, DetailView):
+ model = TaskWorkflow
+ template_name = "tasks/workflows/confirm_create.jinja"
+ permission_required = "tasks.add_taskworkflow"
+
+
+class TaskWorkflowUpdateView(PermissionRequiredMixin, UpdateView):
+ model = TaskWorkflow
+ template_name = "tasks/workflows/edit.jinja"
+ permission_required = "tasks.change_taskworkflow"
+ form_class = TaskWorkflowUpdateForm
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["verbose_name"] = "ticket"
+ return context
+
+ def get_success_url(self):
+ return reverse(
+ "workflow:task-workflow-ui-confirm-update",
+ kwargs={"pk": self.object.pk},
+ )
+
+
+class TaskWorkflowConfirmUpdateView(PermissionRequiredMixin, DetailView):
+ model = TaskWorkflow
+ template_name = "tasks/workflows/confirm_update.jinja"
+ permission_required = "tasks.change_taskworkflow"
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["verbose_name"] = "ticket"
+ return context
+
+
+class TaskWorkflowDeleteView(PermissionRequiredMixin, DeleteView):
+ model = TaskWorkflow
+ template_name = "tasks/workflows/delete.jinja"
+ permission_required = "tasks.delete_taskworkflow"
+ form_class = TaskWorkflowDeleteForm
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs["instance"] = self.object
+ return kwargs
+
+ def get_context_data(self, **kwargs):
+ context_data = super().get_context_data(**kwargs)
+ context_data["verbose_name"] = "ticket"
+ return context_data
+
+ @transaction.atomic
+ def form_valid(self, form):
+ summary_task = self.object.summary_task
+ self.object.get_tasks().delete()
+ result = super().form_valid(form)
+ summary_task.delete()
+ return result
+
+ def get_success_url(self):
+ return reverse(
+ "workflow:task-workflow-ui-confirm-delete",
+ kwargs={"pk": self.object.pk},
+ )
+
+
+class TaskWorkflowConfirmDeleteView(PermissionRequiredMixin, TemplateView):
+ model = TaskWorkflow
+ template_name = "tasks/workflows/confirm_delete.jinja"
+ permission_required = "tasks.change_taskworkflow"
+
+ def get_context_data(self, **kwargs):
+ context_data = super().get_context_data(**kwargs)
+ context_data.update(
+ {
+ "verbose_name": "ticket",
+ "deleted_pk": self.kwargs["pk"],
+ "create_url": reverse("workflow:task-workflow-ui-create"),
+ "list_url": reverse("workflow:task-workflow-ui-list"),
+ },
+ )
+ return context_data
+
+
+class TaskWorkflowTaskCreateView(PermissionRequiredMixin, CreateView):
+ model = Task
+ template_name = "layouts/create.jinja"
+ permission_required = "tasks.add_task"
+ form_class = TaskCreateForm
+
+ def get_task_workflow(self):
+ """Get the associated TaskWorkflow via its pk in the URL."""
+ return TaskWorkflow.objects.get(
+ pk=self.kwargs["task_workflow_pk"],
+ )
+
+ def get_context_data(self, **kwargs) -> dict:
+ context = super().get_context_data(**kwargs)
+
+ context["page_title"] = "Create a task"
+ return context
+
+ def form_valid(self, form) -> HttpResponseRedirect:
+ with transaction.atomic():
+ self.object = form.save(user=self.request.user)
+ TaskItem.objects.create(
+ workflow=self.get_task_workflow(),
+ task=self.object,
+ )
+ return HttpResponseRedirect(self.get_success_url())
+
+ def get_success_url(self):
+ return reverse(
+ "workflow:task-workflow-task-ui-confirm-create",
+ kwargs={"pk": self.object.pk},
+ )
+
+
+class TaskWorkflowTaskConfirmCreateView(PermissionRequiredMixin, DetailView):
+ model = Task
+ template_name = "tasks/confirm_create.jinja"
+ permission_required = "tasks.add_task"
+
+ def get_context_data(self, **kwargs) -> dict:
+ context = super().get_context_data(**kwargs)
+ context["verbose_name"] = "task"
+ return context
+
+
+class TaskWorkflowTemplateDetailView(
+ PermissionRequiredMixin,
+ QueuedItemManagementMixin,
+ DetailView,
+):
+ template_name = "tasks/workflows/detail.jinja"
+ permission_required = "tasks.view_taskworkflowtemplate"
+ model = TaskWorkflowTemplate
+ queued_item_model = TaskItemTemplate
+ item_lookup_field = "task_template_id"
+ queue_field = queued_item_model.queue_field
+
+ @property
+ def view_url(self) -> str:
+ return reverse(
+ "workflow:task-workflow-template-ui-detail",
+ kwargs={"pk": self.queue.pk},
+ )
+
+ def get_context_data(self, **kwargs):
+ context_data = super().get_context_data(**kwargs)
+ context_data.update(
+ {
+ "object_list": self.queue.get_task_templates(),
+ "verbose_name": "ticket template",
+ "list_include": "tasks/includes/task_queue.jinja",
+ },
+ )
+ return context_data
+
+ def post(self, request, *args, **kwargs):
+ if "promote" in request.POST:
+ self.promote(request.POST.get("promote"))
+ elif "demote" in request.POST:
+ self.demote(request.POST.get("demote"))
+ elif "promote_to_first" in request.POST:
+ self.promote_to_first(request.POST.get("promote_to_first"))
+ elif "demote_to_last" in request.POST:
+ self.demote_to_last(request.POST.get("demote_to_last"))
+
+ return HttpResponseRedirect(self.view_url)
+
+
+class TaskWorkflowTemplateCreateView(PermissionRequiredMixin, CreateView):
+ model = TaskWorkflowTemplate
+ permission_required = "tasks.add_taskworkflowtemplate"
+ template_name = "tasks/workflows/create.jinja"
+ form_class = TaskWorkflowTemplateCreateForm
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["verbose_name"] = "workflow template"
+ context["list_url"] = reverse("workflow:task-workflow-template-ui-list")
+ 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-workflow-template-ui-confirm-create",
+ kwargs={"pk": self.object.pk},
+ )
+
+
+class TaskWorkflowTemplateConfirmCreateView(PermissionRequiredMixin, DetailView):
+ model = TaskWorkflowTemplate
+ template_name = "tasks/workflows/confirm_create.jinja"
+ permission_required = "tasks.add_taskworkflowtemplate"
+
+
+class TaskWorkflowTemplateUpdateView(PermissionRequiredMixin, UpdateView):
+ model = TaskWorkflowTemplate
+ template_name = "tasks/workflows/edit.jinja"
+ permission_required = "tasks.change_taskworkflowtemplate"
+ form_class = TaskWorkflowTemplateUpdateForm
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["verbose_name"] = "ticket template"
+ return context
+
+ def get_success_url(self):
+ return reverse(
+ "workflow:task-workflow-template-ui-confirm-update",
+ kwargs={"pk": self.object.pk},
+ )
+
+
+class TaskWorkflowTemplateConfirmUpdateView(PermissionRequiredMixin, DetailView):
+ model = TaskWorkflowTemplate
+ template_name = "tasks/workflows/confirm_update.jinja"
+ permission_required = "tasks.change_taskworkflowtemplate"
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["verbose_name"] = "ticket template"
+ return context
+
+
+class TaskWorkflowTemplateDeleteView(PermissionRequiredMixin, DeleteView):
+ model = TaskWorkflowTemplate
+ template_name = "tasks/workflows/delete.jinja"
+ permission_required = "tasks.delete_taskworkflowtemplate"
+ form_class = TaskWorkflowTemplateDeleteForm
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs["instance"] = self.object
+ return kwargs
+
+ @transaction.atomic
+ def form_valid(self, form):
+ self.object.get_task_templates().delete()
+ return super().form_valid(form)
+
+ def get_success_url(self):
+ return reverse(
+ "workflow:task-workflow-template-ui-confirm-delete",
+ kwargs={"pk": self.object.pk},
+ )
+
+
+class TaskWorkflowTemplateConfirmDeleteView(PermissionRequiredMixin, TemplateView):
+ model = TaskWorkflowTemplate
+ template_name = "tasks/workflows/confirm_delete.jinja"
+ permission_required = "tasks.change_taskworkflowtemplate"
+
+ def get_context_data(self, **kwargs):
+ context_data = super().get_context_data(**kwargs)
+ context_data.update(
+ {
+ "verbose_name": "workflow template",
+ "deleted_pk": self.kwargs["pk"],
+ "create_url": reverse("workflow:task-workflow-template-ui-create"),
+ "list_url": reverse("workflow:task-workflow-template-ui-list"),
+ },
+ )
+ return context_data
+
+
+class TaskTemplateDetailView(PermissionRequiredMixin, DetailView):
+ model = TaskTemplate
+ template_name = "tasks/workflows/task_template_detail.jinja"
+ permission_required = "tasks.view_tasktemplate"
+
+ def get_context_data(self, **kwargs) -> dict:
+ context = super().get_context_data(**kwargs)
+
+ context["task_workflow_template"] = (
+ self.get_object().taskitemtemplate.workflow_template
+ )
+
+ return context
+
+
+class TaskTemplateCreateView(PermissionRequiredMixin, CreateView):
+ template_name = "tasks/workflows/task_template_save.jinja"
+ form_class = TaskTemplateCreateForm
+ permission_required = "tasks.add_tasktemplate"
+
+ def get_task_workflow_template(self) -> TaskWorkflowTemplate:
+ """Get the TaskWorkflowTemplate identified by the pk in the URL."""
+ return TaskWorkflowTemplate.objects.get(
+ pk=self.kwargs["workflow_template_pk"],
+ )
+
+ def get_context_data(self, **kwargs) -> dict:
+ context = super().get_context_data(**kwargs)
+
+ context["page_title"] = "Create a task template"
+ context["task_workflow_template"] = self.get_task_workflow_template()
+
+ return context
+
+ def form_valid(self, form) -> HttpResponseRedirect:
+ with transaction.atomic():
+ self.object = form.save()
+ TaskItemTemplate.objects.create(
+ workflow_template=self.get_task_workflow_template(),
+ task_template=self.object,
+ )
+
+ return HttpResponseRedirect(self.get_success_url(), self.object.pk)
+
+ def get_success_url(self) -> str:
+ return reverse(
+ "workflow:task-template-ui-confirm-create",
+ kwargs={"pk": self.object.pk},
+ )
+
+
+class TaskTemplateConfirmCreateView(PermissionRequiredMixin, DetailView):
+ model = TaskTemplate
+ template_name = "tasks/workflows/task_template_confirm_create.jinja"
+ permission_required = "tasks.add_tasktemplate"
+
+ def get_context_data(self, **kwargs) -> dict:
+ context = super().get_context_data(**kwargs)
+
+ context["task_workflow_template"] = (
+ self.get_object().taskitemtemplate.workflow_template
+ )
+
+ return context
+
+
+class TaskTemplateUpdateView(PermissionRequiredMixin, UpdateView):
+ model = TaskTemplate
+ template_name = "tasks/workflows/task_template_save.jinja"
+ permission_required = "tasks.change_tasktemplate"
+ form_class = TaskTemplateUpdateForm
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+
+ context["page_title"] = f"Update task template: {self.get_object().title}"
+ context["task_workflow_template"] = (
+ self.get_object().taskitemtemplate.workflow_template
+ )
+
+ return context
+
+ def get_success_url(self):
+ return reverse(
+ "workflow:task-template-ui-confirm-update",
+ kwargs={"pk": self.object.pk},
+ )
+
+
+class TaskTemplateConfirmUpdateView(PermissionRequiredMixin, DetailView):
+ model = TaskTemplate
+ template_name = "tasks/workflows/task_template_confirm_update.jinja"
+ permission_required = "tasks.add_tasktemplate"
+
+ def get_context_data(self, **kwargs) -> dict:
+ context = super().get_context_data(**kwargs)
+
+ context["task_workflow_template"] = (
+ self.get_object().taskitemtemplate.workflow_template
+ )
+
+ return context
+
+
+class TaskTemplateDeleteView(PermissionRequiredMixin, DeleteView):
+ model = TaskTemplate
+ template_name = "tasks/workflows/task_template_delete.jinja"
+ permission_required = "tasks.delete_tasktemplate"
+ form_class = TaskTemplateDeleteForm
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs["instance"] = self.object
+ return kwargs
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+
+ context["task_workflow_template"] = (
+ self.get_object().taskitemtemplate.workflow_template
+ )
+
+ return context
+
+ def get_success_url(self):
+ return reverse(
+ "workflow:task-template-ui-confirm-delete",
+ kwargs={
+ "workflow_template_pk": self.kwargs["workflow_template_pk"],
+ "pk": self.object.pk,
+ },
+ )
+
+
+class TaskTemplateConfirmDeleteView(PermissionRequiredMixin, TemplateView):
+ template_name = "tasks/workflows/task_template_confirm_delete.jinja"
+ permission_required = "tasks.delete_tasktemplate"
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+
+ context["deleted_pk"] = self.kwargs["pk"]
+ context["task_workflow_template"] = TaskWorkflowTemplate.objects.get(
+ pk=self.kwargs["workflow_template_pk"],
+ )
+
+ return context
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/webpack.config.js b/webpack.config.js
index 3198e4648..d865d02ee 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -84,6 +84,7 @@ module.exports = {
"regulations/static/regulations/scss",
"workbaskets/static/workbaskets/scss",
"reference_documents/static/reference_documents/scss",
+ "tasks/static/tasks/scss",
],
},
},
diff --git a/workbaskets/filters.py b/workbaskets/filters.py
new file mode 100644
index 000000000..0d3b08061
--- /dev/null
+++ b/workbaskets/filters.py
@@ -0,0 +1,69 @@
+from django.contrib.postgres.search import SearchRank
+from django.contrib.postgres.search import SearchVector
+from django.db.models import Case
+from django.db.models import FloatField
+from django.db.models import Q
+from django.db.models import Value
+from django.db.models import When
+
+from common.filters import TamatoFilterBackend
+from common.filters import TamatoFilterMixin
+
+
+class WorkBasketFilterMixin(TamatoFilterMixin):
+ search_fields = ("title", "reason")
+
+ def search_queryset(self, queryset, search_term):
+ """
+ Filters the queryset to results with `search_fields` (including PK)
+ containing or matching the `search_term`.
+
+ Results are ordered first by relevancy then by PK to favour newer
+ objects in the event of a tied rank value. Exact search term matches are
+ therefore prioritised over partial substring matches.
+
+ The search rank is normalised to penalise longer documents and those
+ with a high unique word count, improving relevance scoring.
+ """
+ NORMALISATION_LOG_LENGTH = 1
+ NORMALISATION_UNIQUE_WORDS = 8
+
+ search_term = self.get_search_term(search_term)
+ search_vector = SearchVector(*self.search_fields)
+ search_rank = SearchRank(
+ search_vector,
+ search_term,
+ normalization=Value(NORMALISATION_LOG_LENGTH).bitor(
+ Value(NORMALISATION_UNIQUE_WORDS),
+ ),
+ )
+
+ vector_queryset = queryset.annotate(search=search_vector)
+
+ exact_match_query = Q(search=search_term)
+ partial_match_query = Q(search__icontains=search_term)
+ query = exact_match_query | partial_match_query
+
+ try:
+ pk_query = Q(pk=int(search_term))
+ query |= pk_query
+ search_rank = Case(
+ When(pk=search_term, then=1),
+ default=search_rank,
+ output_field=FloatField(),
+ )
+ except ValueError:
+ # search_term cannot be converted to an integer
+ pass
+
+ return (
+ vector_queryset.annotate(
+ rank=search_rank,
+ )
+ .filter(query)
+ .order_by("-rank", "-pk")
+ )
+
+
+class WorkBasketAutoCompleteFilterBackEnd(TamatoFilterBackend, WorkBasketFilterMixin):
+ pass
diff --git a/workbaskets/forms.py b/workbaskets/forms.py
index 54730606f..5d17aaf43 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
from django.utils.timezone import make_aware
@@ -20,7 +21,8 @@
from common.validators import markdown_tags_allowlist
from tasks.models import Comment
from tasks.models import Task
-from tasks.models import UserAssignment
+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
@@ -209,7 +211,7 @@ class WorkBasketAssignUsersForm(forms.Form):
error_messages={"required": "Select one or more users to assign"},
)
assignment_type = forms.ChoiceField(
- choices=UserAssignment.AssignmentType.choices,
+ choices=TaskAssignee.AssignmentType.choices,
widget=forms.RadioSelect,
error_messages={"required": "Select an assignment type"},
)
@@ -249,18 +251,20 @@ def init_layout(self):
),
)
+ @transaction.atomic
def assign_users(self, task):
+ set_current_instigator(self.request.user)
+
assignment_type = self.cleaned_data["assignment_type"]
- objs = [
- UserAssignment(
+ assignees = [
+ TaskAssignee(
user=user,
- assigned_by=self.request.user,
assignment_type=assignment_type,
task=task,
)
for user in self.cleaned_data["users"]
- if not UserAssignment.objects.filter(
+ if not TaskAssignee.objects.filter(
user=user,
assignment_type=assignment_type,
task__workbasket=self.workbasket,
@@ -268,17 +272,15 @@ def assign_users(self, task):
.assigned()
.exists()
]
- user_assignments = UserAssignment.objects.bulk_create(objs)
-
- return user_assignments
+ return TaskAssignee.objects.bulk_create(assignees)
class WorkBasketUnassignUsersForm(forms.Form):
- assignments = forms.ModelMultipleChoiceField(
+ assignees = forms.ModelMultipleChoiceField(
label="Users",
help_text="Select users to unassign",
widget=forms.CheckboxSelectMultiple,
- queryset=UserAssignment.objects.all(),
+ queryset=TaskAssignee.objects.all(),
error_messages={"required": "Select one or more users to unassign"},
)
@@ -290,12 +292,12 @@ def __init__(self, *args, **kwargs):
self.init_layout()
def init_fields(self):
- self.fields["assignments"].queryset = self.workbasket.user_assignments.order_by(
+ self.fields["assignees"].queryset = self.workbasket.user_assignments.order_by(
"user__first_name",
"user__last_name",
)
- self.fields["assignments"].label_from_instance = (
+ self.fields["assignees"].label_from_instance = (
lambda obj: f"{obj.user.get_full_name()} ({obj.get_assignment_type_display().lower()})"
)
@@ -304,7 +306,7 @@ def init_layout(self):
self.helper.label_size = Size.SMALL
self.helper.legend_size = Size.SMALL
self.helper.layout = Layout(
- "assignments",
+ "assignees",
Submit(
"submit",
"Save",
@@ -313,16 +315,18 @@ def init_layout(self):
),
)
+ @transaction.atomic
def unassign_users(self):
- assignments = self.cleaned_data["assignments"]
- for assignment in assignments:
- assignment.unassigned_at = make_aware(datetime.now())
+ set_current_instigator(self.request.user)
+
+ assignees = self.cleaned_data["assignees"]
+ for assignee in assignees:
+ assignee.unassigned_at = make_aware(datetime.now())
- user_assignments = UserAssignment.objects.bulk_update(
- assignments,
+ return TaskAssignee.objects.bulk_update(
+ assignees,
fields=["unassigned_at"],
)
- return user_assignments
class WorkBasketCommentForm(forms.ModelForm):
diff --git a/workbaskets/jinja2/includes/workbaskets/auto_end_date_measures.jinja b/workbaskets/jinja2/includes/workbaskets/auto_end_date_measures.jinja
index 5b4df6b12..20c0f5e1b 100644
--- a/workbaskets/jinja2/includes/workbaskets/auto_end_date_measures.jinja
+++ b/workbaskets/jinja2/includes/workbaskets/auto_end_date_measures.jinja
@@ -33,18 +33,16 @@
]) or "" }}
{% endfor %}
- {% set base_url = url('workbaskets:workbasket-ui-auto-end-date-measures') %}
-
{% set commodity_code %}
- {{ create_sortable_anchor(request, "goods_nomenclature", "Commodity code", base_url) }}
+ {{ create_sortable_anchor(request, "Commodity code", sorting_urls["goods_nomenclature"], "#measures") }}
{% endset %}
{% set start_date %}
- {{ create_sortable_anchor(request, "start_date", "Measure start date", base_url) }}
+ {{ create_sortable_anchor(request, "Measure start date", sorting_urls["start_date"], "#measures") }}
{% endset %}
{% set measure_sid %}
- {{ create_sortable_anchor(request, "sid", "Measure SID", base_url) }}
+ {{ create_sortable_anchor(request, "Measure SID", sorting_urls["sid"], "#measures") }}
{% endset %}
{% if object_list %}
diff --git a/workbaskets/jinja2/workbaskets/changes.jinja b/workbaskets/jinja2/workbaskets/changes.jinja
index 96cbca35c..4fd4c401e 100644
--- a/workbaskets/jinja2/workbaskets/changes.jinja
+++ b/workbaskets/jinja2/workbaskets/changes.jinja
@@ -6,18 +6,16 @@
{% from "includes/workbaskets/navigation.jinja" import create_workbasket_detail_navigation with context %}
{% from "macros/checkbox_item.jinja" import checkbox_item %}
-{% set base_url = url("workbaskets:workbasket-ui-changes", args=[workbasket.pk]) ~ "?page=" ~ page_obj.number %}
-
{% set component %}
- {{ create_sortable_anchor(request, "component", "Item", base_url, True) }}
+ {{ create_sortable_anchor(request, "Item", sorting_urls["component"]) }}
{% endset %}
{% set action %}
- {{ create_sortable_anchor(request, "action", "Action", base_url, True) }}
+ {{ create_sortable_anchor(request, "Action", sorting_urls["action"]) }}
{% endset %}
{% set activity_date %}
- {{ create_sortable_anchor(request, "activity_date", "Activity date", base_url, True) }}
+ {{ create_sortable_anchor(request, "Activity date", sorting_urls["activity_date"]) }}
{% endset %}
{% set page_title %} Workbasket {{ workbasket.id }} - {{ workbasket.status }} {% endset %}
diff --git a/workbaskets/jinja2/workbaskets/checks/missing_measures.jinja b/workbaskets/jinja2/workbaskets/checks/missing_measures.jinja
index bf49983b7..0188cf5d6 100644
--- a/workbaskets/jinja2/workbaskets/checks/missing_measures.jinja
+++ b/workbaskets/jinja2/workbaskets/checks/missing_measures.jinja
@@ -121,10 +121,8 @@
- {% set base_url = url("workbaskets:workbasket-ui-missing-measures-check") %}
-
{% set commodity_code %}
- {{ create_sortable_anchor(request, "commodity", "Commodity code", base_url) }}
+ {{ create_sortable_anchor(request, "Commodity code", sorting_urls["commodity"]) }}
{% endset %}
{% set table_rows = [] %}
diff --git a/workbaskets/jinja2/workbaskets/summary-workbasket.jinja b/workbaskets/jinja2/workbaskets/summary-workbasket.jinja
index 8d54bf4f9..ddc37e738 100644
--- a/workbaskets/jinja2/workbaskets/summary-workbasket.jinja
+++ b/workbaskets/jinja2/workbaskets/summary-workbasket.jinja
@@ -15,8 +15,6 @@
{% set assign_users_link = url("workbaskets:workbasket-ui-assign-users", kwargs={"pk": workbasket.pk}) %}
{% set unassign_users_link = url("workbaskets:workbasket-ui-unassign-users", kwargs={"pk": workbasket.pk}) %}
-{% set base_url = url("workbaskets:current-workbasket") ~ "?page=" ~ page_obj.number %}
-
{% macro display_assigned_users(assigned_users, assignment_type) %}
{% if not assigned_users and assignment_type == "workers" %}
No users have been assigned to this workbasket yet.
@@ -156,6 +154,17 @@
+
+
Auto end-date measures
+
Automatically end-date or delete measures and footnote associations on commodities which have been ended in this workbasket.
+
+ Auto end-date measures
+
+
+
{% if workbasket.tasks.exists() and can_add_comment %}
Activity
@@ -164,21 +173,11 @@
{% if comments %}
- Sort by {{ create_sortable_anchor(request, "comments", sort_by_title, base_url, query_params="True") }}
+ Sort by {{ create_sortable_anchor(request, sort_by_label, sorting_urls["comments"]) }}
{% endif %}
{% endif %}
-
-
Auto end-date measures
-
Automatically end-date or delete measures and footnote associations on commodities which have been ended in this workbasket.
-
- Auto end-date measures
-
-
{% if comments and can_view_comment %}
diff --git a/workbaskets/jinja2/workbaskets/violations.jinja b/workbaskets/jinja2/workbaskets/violations.jinja
index b85e2a923..37c63cb85 100644
--- a/workbaskets/jinja2/workbaskets/violations.jinja
+++ b/workbaskets/jinja2/workbaskets/violations.jinja
@@ -33,18 +33,16 @@
]) or "" }}
{% endfor %}
-{% set base_url = url('workbaskets:workbasket-ui-violations' ) %}
-
{% set item %}
- {{ create_sortable_anchor(request, "model", "Item", base_url) }}
+ {{ create_sortable_anchor(request, "Item", sorting_urls["model"]) }}
{% endset %}
{% set violation %}
- {{ create_sortable_anchor(request, "check_name", "Violation", base_url) }}
+ {{ create_sortable_anchor(request, "Violation", sorting_urls["check_name"]) }}
{% endset %}
{% set activity_date %}
- {{ create_sortable_anchor(request, "date", "Activity date", base_url) }}
+ {{ create_sortable_anchor(request, "Activity date", sorting_urls["date"]) }}
{% endset %}
{{ govukTable({
diff --git a/workbaskets/models.py b/workbaskets/models.py
index 72183647f..7aa82c6ff 100644
--- a/workbaskets/models.py
+++ b/workbaskets/models.py
@@ -492,6 +492,10 @@ def commodity_measure_changes_hash(self):
hash.update(value)
return hash.hexdigest()
+ @property
+ def autocomplete_label(self):
+ return f"({self.pk}) {self.title} - {self.reason}"
+
def __str__(self):
return f"({self.pk}) [{self.status}]"
@@ -762,36 +766,43 @@ def unchecked_or_errored_transactions(self):
@property
def worker_assignments(self):
- from tasks.models import UserAssignment
+ """Returns a queryset of associated `TaskAssignee` instances filtered to
+ match `AssignmentType.WORKBASKET_WORKER`."""
+ from tasks.models import TaskAssignee
return (
- UserAssignment.objects.filter(task__workbasket=self)
+ TaskAssignee.objects.filter(task__workbasket=self)
.workbasket_workers()
.assigned()
)
@property
def reviewer_assignments(self):
- from tasks.models import UserAssignment
+ """Returns a queryset of associated `TaskAssignee` instances filtered to
+ match `AssignmentType.WORKBASKET_REVIEWER`."""
+ from tasks.models import TaskAssignee
return (
- UserAssignment.objects.filter(task__workbasket=self)
+ TaskAssignee.objects.filter(task__workbasket=self)
.workbasket_reviewers()
.assigned()
)
@property
def user_assignments(self):
+ """Returns a queryset of associated `TaskAssignee` instances."""
assignments = self.worker_assignments | self.reviewer_assignments
return assignments
@property
def assigned_workers(self):
+ """Returns a queryset of `User` instances assigned as workers."""
user_ids = self.worker_assignments.values_list("user_id", flat=True)
return User.objects.filter(id__in=user_ids)
@property
def assigned_reviewers(self):
+ """Returns a queryset of `User` instances assigned as reviewers."""
user_ids = self.reviewer_assignments.values_list("user_id", flat=True)
return User.objects.filter(id__in=user_ids)
diff --git a/workbaskets/tests/test_filters.py b/workbaskets/tests/test_filters.py
new file mode 100644
index 000000000..78913e832
--- /dev/null
+++ b/workbaskets/tests/test_filters.py
@@ -0,0 +1,32 @@
+import pytest
+
+from common.tests.factories import WorkBasketFactory
+from workbaskets.filters import WorkBasketAutoCompleteFilterBackEnd
+from workbaskets.models import WorkBasket
+
+pytestmark = pytest.mark.django_db
+
+
+def test_workbasket_autocomplete_filter_backend():
+ """Tests that WorkBasketAutoCompleteFilterBackEnd filters workbaskets by
+ exact and partial match of search term."""
+ workbasket1 = WorkBasketFactory.create(
+ pk=1111,
+ title="wb1",
+ reason="samedescription",
+ )
+ workbasket2 = WorkBasketFactory.create(
+ pk=2222,
+ title="wb2",
+ reason="samedescription",
+ )
+ queryset = WorkBasket.objects.all()
+
+ filter = WorkBasketAutoCompleteFilterBackEnd()
+ results = filter.search_queryset(queryset=queryset, search_term=str(workbasket1.pk))
+ assert workbasket1 in results
+ assert workbasket2 not in results
+
+ results = filter.search_queryset(queryset=queryset, search_term="same")
+ assert workbasket1 in results
+ assert workbasket2 in results
diff --git a/workbaskets/tests/test_forms.py b/workbaskets/tests/test_forms.py
index 4fb80a28a..4fca07d67 100644
--- a/workbaskets/tests/test_forms.py
+++ b/workbaskets/tests/test_forms.py
@@ -1,7 +1,7 @@
import pytest
from common.tests import factories
-from tasks.models import UserAssignment
+from tasks.models import TaskAssignee
from workbaskets import forms
from workbaskets.validators import tops_jira_number_validator
@@ -129,7 +129,7 @@ def test_workbasket_assign_users_form_assigns_users(rf, valid_user, user_workbas
users = factories.UserFactory.create_batch(2, is_superuser=True)
data = {
"users": users,
- "assignment_type": UserAssignment.AssignmentType.WORKBASKET_WORKER,
+ "assignment_type": TaskAssignee.AssignmentType.WORKBASKET_WORKER,
}
form = forms.WorkBasketAssignUsersForm(
@@ -142,7 +142,7 @@ def test_workbasket_assign_users_form_assigns_users(rf, valid_user, user_workbas
task = factories.TaskFactory.create(workbasket=user_workbasket)
form.assign_users(task=task)
for user in users:
- assert UserAssignment.objects.get(user=user, task=task, assigned_by=valid_user)
+ assert TaskAssignee.objects.get(user=user, task=task)
def test_workbasket_assign_users_form_required_fields(rf, valid_user, user_workbasket):
@@ -166,13 +166,13 @@ def test_workbasket_unassign_users_form_unassigns_users(
):
request = rf.request()
request.user = valid_user
- assignments = factories.UserAssignmentFactory.create_batch(
+ assignees = factories.TaskAssigneeFactory.create_batch(
2,
- assignment_type=UserAssignment.AssignmentType.WORKBASKET_REVIEWER,
+ assignment_type=TaskAssignee.AssignmentType.WORKBASKET_REVIEWER,
task__workbasket=user_workbasket,
)
data = {
- "assignments": assignments,
+ "assignees": assignees,
}
form = forms.WorkBasketUnassignUsersForm(
@@ -183,9 +183,9 @@ def test_workbasket_unassign_users_form_unassigns_users(
assert form.is_valid()
form.unassign_users()
- for assignment in assignments:
- assignment.refresh_from_db()
- assert not assignment.is_assigned
+ for assignee in assignees:
+ assignee.refresh_from_db()
+ assert not assignee.is_assigned
def test_workbasket_unassign_users_form_required_fields(
@@ -202,7 +202,7 @@ def test_workbasket_unassign_users_form_required_fields(
data={},
)
assert not form.is_valid()
- assert f"Select one or more users to unassign" in form.errors["assignments"]
+ assert f"Select one or more users to unassign" in form.errors["assignees"]
def test_workbasket_comment_create_form(assigned_workbasket):
diff --git a/workbaskets/tests/test_models.py b/workbaskets/tests/test_models.py
index c789b5392..e80c99909 100644
--- a/workbaskets/tests/test_models.py
+++ b/workbaskets/tests/test_models.py
@@ -19,7 +19,7 @@
from common.tests.factories import WorkBasketFactory
from common.tests.util import assert_transaction_order
from common.validators import UpdateType
-from tasks.models import UserAssignment
+from tasks.models import TaskAssignee
from workbaskets import tasks
from workbaskets.models import REVISION_ONLY
from workbaskets.models import SEED_FIRST
@@ -367,12 +367,12 @@ def test_queue(valid_user, unapproved_checked_transaction):
approver and shifting transaction from DRAFT to REVISION partition."""
wb = unapproved_checked_transaction.workbasket
task = factories.TaskFactory.create(workbasket=wb)
- factories.UserAssignmentFactory.create(
- assignment_type=UserAssignment.AssignmentType.WORKBASKET_WORKER,
+ factories.TaskAssigneeFactory.create(
+ assignment_type=TaskAssignee.AssignmentType.WORKBASKET_WORKER,
task=task,
)
- factories.UserAssignmentFactory.create(
- assignment_type=UserAssignment.AssignmentType.WORKBASKET_REVIEWER,
+ factories.TaskAssigneeFactory.create(
+ assignment_type=TaskAssignee.AssignmentType.WORKBASKET_REVIEWER,
task=task,
)
wb.queue(valid_user.pk, settings.TRANSACTION_SCHEMA)
@@ -453,38 +453,38 @@ def test_unassigned_workbasket_cannot_be_queued():
with pytest.raises(TransitionNotAllowed):
workbasket.queue(user=worker.id, scheme_name=settings.TRANSACTION_SCHEMA)
- factories.UserAssignmentFactory.create(
+ factories.TaskAssigneeFactory.create(
user=worker,
- assignment_type=UserAssignment.AssignmentType.WORKBASKET_WORKER,
+ assignment_type=TaskAssignee.AssignmentType.WORKBASKET_WORKER,
task=task,
)
- factories.UserAssignmentFactory.create(
- assignment_type=UserAssignment.AssignmentType.WORKBASKET_REVIEWER,
+ factories.TaskAssigneeFactory.create(
+ assignment_type=TaskAssignee.AssignmentType.WORKBASKET_REVIEWER,
task=task,
)
assert workbasket.is_fully_assigned()
- UserAssignment.unassign_user(user=worker, task=task)
+ TaskAssignee.unassign_user(user=worker, task=task, instigator=worker)
assert not workbasket.is_fully_assigned()
def test_workbasket_user_assignments_queryset():
workbasket = factories.WorkBasketFactory.create()
- worker_assignment = factories.UserAssignmentFactory.create(
- assignment_type=UserAssignment.AssignmentType.WORKBASKET_WORKER,
+ worker_assignment = factories.TaskAssigneeFactory.create(
+ assignment_type=TaskAssignee.AssignmentType.WORKBASKET_WORKER,
task__workbasket=workbasket,
)
- reviewer_assignment = factories.UserAssignmentFactory.create(
- assignment_type=UserAssignment.AssignmentType.WORKBASKET_REVIEWER,
+ reviewer_assignment = factories.TaskAssigneeFactory.create(
+ assignment_type=TaskAssignee.AssignmentType.WORKBASKET_REVIEWER,
task__workbasket=workbasket,
)
# Inactive assignment
- factories.UserAssignmentFactory.create(
+ factories.TaskAssigneeFactory.create(
unassigned_at=make_aware(datetime.now()),
task__workbasket=workbasket,
)
# Unrelated assignment
- factories.UserAssignmentFactory.create()
+ factories.TaskAssigneeFactory.create()
workbasket.refresh_from_db()
queryset = workbasket.user_assignments
diff --git a/workbaskets/tests/test_views.py b/workbaskets/tests/test_views.py
index 53c311f59..af2184b42 100644
--- a/workbaskets/tests/test_views.py
+++ b/workbaskets/tests/test_views.py
@@ -34,7 +34,8 @@
from importer.models import ImportBatchStatus
from measures.models import Measure
from tasks.models import Comment
-from tasks.models import UserAssignment
+from tasks.models import TaskAssignee
+from tasks.models import TaskLog
from workbaskets import models
from workbaskets.tasks import call_end_measures
from workbaskets.tasks import check_workbasket_sync
@@ -44,6 +45,24 @@
pytestmark = pytest.mark.django_db
+def test_workbasket_autocomplete_api_endpoint(valid_user_api_client):
+ """Tests that workbasket autocomplete API endpoint allows searching for
+ workbaskets."""
+ factories.WorkBasketFactory.create(reason="irrelevant_workbasket")
+ workbasket = factories.WorkBasketFactory.create(reason="test")
+
+ autocomplete_api_url = reverse("workbaskets:workbasket-autocomplete-list")
+ response = valid_user_api_client.get(
+ path=autocomplete_api_url,
+ data={"search": "test"},
+ )
+
+ assert response.status_code == 200
+ assert response.data["count"] == 1
+ assert response.data["results"][0]["value"] == workbasket.pk
+ assert response.data["results"][0]["label"] == workbasket.autocomplete_label
+
+
def test_workbasket_create_form_creates_workbasket_object(
valid_user_api_client,
):
@@ -295,13 +314,13 @@ def test_workbasket_assignments_appear(valid_user_client):
# Fully assign the workbasket
task = factories.TaskFactory.create(workbasket=workbasket)
- worker_assignment = factories.UserAssignmentFactory.create(
- assignment_type=UserAssignment.AssignmentType.WORKBASKET_WORKER,
+ worker_assignment = factories.TaskAssigneeFactory.create(
+ assignment_type=TaskAssignee.AssignmentType.WORKBASKET_WORKER,
task=task,
user=worker,
)
- reviewer_assignment = factories.UserAssignmentFactory.create(
- assignment_type=UserAssignment.AssignmentType.WORKBASKET_REVIEWER,
+ reviewer_assignment = factories.TaskAssigneeFactory.create(
+ assignment_type=TaskAssignee.AssignmentType.WORKBASKET_REVIEWER,
task=task,
user=reviewer,
)
@@ -342,12 +361,12 @@ def test_select_workbasket_filtering(
task = factories.TaskFactory.create(workbasket=fully_assigned_workbasket)
- factories.UserAssignmentFactory.create(
- assignment_type=UserAssignment.AssignmentType.WORKBASKET_WORKER,
+ factories.TaskAssigneeFactory.create(
+ assignment_type=TaskAssignee.AssignmentType.WORKBASKET_WORKER,
task=task,
)
- factories.UserAssignmentFactory.create(
- assignment_type=UserAssignment.AssignmentType.WORKBASKET_REVIEWER,
+ factories.TaskAssigneeFactory.create(
+ assignment_type=TaskAssignee.AssignmentType.WORKBASKET_REVIEWER,
task=task,
)
# Create an unassigned workbasket
@@ -357,15 +376,15 @@ def test_select_workbasket_filtering(
reviewer_task = factories.TaskFactory.create(
workbasket=reviewer_assigned_workbasket,
)
- factories.UserAssignmentFactory.create(
- assignment_type=UserAssignment.AssignmentType.WORKBASKET_REVIEWER,
+ factories.TaskAssigneeFactory.create(
+ assignment_type=TaskAssignee.AssignmentType.WORKBASKET_REVIEWER,
task=reviewer_task,
)
# Create a workbasket with only a worker assigned
worker_assigned_workbasket = factories.WorkBasketFactory.create(id=4)
worker_task = factories.TaskFactory.create(workbasket=worker_assigned_workbasket)
- factories.UserAssignmentFactory.create(
- assignment_type=UserAssignment.AssignmentType.WORKBASKET_WORKER,
+ factories.TaskAssigneeFactory.create(
+ assignment_type=TaskAssignee.AssignmentType.WORKBASKET_WORKER,
task=worker_task,
)
@@ -2327,12 +2346,12 @@ def test_disabled_packaging_for_unassigned_workbasket(
# Assign the workbasket so it can now be packaged
task = factories.TaskFactory.create(workbasket=user_empty_workbasket)
- factories.UserAssignmentFactory.create(
- assignment_type=UserAssignment.AssignmentType.WORKBASKET_WORKER,
+ factories.TaskAssigneeFactory.create(
+ assignment_type=TaskAssignee.AssignmentType.WORKBASKET_WORKER,
task=task,
)
- factories.UserAssignmentFactory.create(
- assignment_type=UserAssignment.AssignmentType.WORKBASKET_REVIEWER,
+ factories.TaskAssigneeFactory.create(
+ assignment_type=TaskAssignee.AssignmentType.WORKBASKET_REVIEWER,
task=task,
)
@@ -2342,18 +2361,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_userassignment"),
- )
- 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 response.content.decode(response.charset)
+
+ 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):
@@ -2368,18 +2405,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_userassignment"),
- )
- 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 response.content.decode(response.charset)
+
+ 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/api.py b/workbaskets/views/api.py
index 4af575a24..9ce8cc81f 100644
--- a/workbaskets/views/api.py
+++ b/workbaskets/views/api.py
@@ -1,7 +1,13 @@
+from rest_framework import permissions
from rest_framework import renderers
+from rest_framework import status
from rest_framework import viewsets
+from rest_framework.decorators import action
+from rest_framework.response import Response
from common.renderers import TaricXMLRenderer
+from common.serializers import AutoCompleteSerializer
+from workbaskets.filters import WorkBasketAutoCompleteFilterBackEnd
from workbaskets.models import WorkBasket
from workbaskets.serializers import WorkBasketSerializer
@@ -17,9 +23,42 @@ class WorkBasketViewSet(viewsets.ModelViewSet):
renderers.BrowsableAPIRenderer,
TaricXMLRenderer,
]
+ permission_classes = [
+ permissions.IsAuthenticated,
+ permissions.DjangoModelPermissions,
+ ]
search_fields = ["title"]
def get_template_names(self, *args, **kwargs):
if self.detail:
return ["workbaskets/taric/workbasket_detail.xml"]
return ["workbaskets/taric/workbasket_list.xml"]
+
+ @action(
+ detail=False,
+ methods=["GET"],
+ url_path="autocomplete",
+ url_name="autocomplete-list",
+ )
+ def autocomplete(self, request):
+ """
+ Read-only API endpoint that allows users to search for workbaskets by
+ ID, title or reason (i.e description) using an autocomplete form field.
+
+ It returns a paginated JSON array of workbaskets that match the search
+ query.
+ """
+ filter_backend = WorkBasketAutoCompleteFilterBackEnd()
+ queryset = filter_backend.filter_queryset(
+ request,
+ WorkBasket.objects.all(),
+ self,
+ )
+
+ page = self.paginate_queryset(queryset)
+ if page is not None:
+ serializer = AutoCompleteSerializer(page, many=True)
+ return self.get_paginated_response(serializer.data)
+
+ serializer = AutoCompleteSerializer(queryset, many=True)
+ return Response(serializer.data, status=status.HTTP_200_OK)
diff --git a/workbaskets/views/ui.py b/workbaskets/views/ui.py
index cc4a94473..06e2a948b 100644
--- a/workbaskets/views/ui.py
+++ b/workbaskets/views/ui.py
@@ -3,7 +3,6 @@
from datetime import date
from functools import cached_property
from itertools import chain
-from typing import Tuple
import boto3
import django_filters
@@ -77,7 +76,7 @@
from regulations.models import Regulation
from tasks.models import Comment
from tasks.models import Task
-from tasks.models import UserAssignment
+from tasks.models import TaskAssignee
from workbaskets import forms
from workbaskets.models import DataRow
from workbaskets.models import DataUpload
@@ -116,12 +115,12 @@ class WorkBasketAssignmentFilter(FilterSet):
def assignment_filter(self, queryset, name, value):
active_workers = (
- UserAssignment.objects.workbasket_workers()
+ TaskAssignee.objects.workbasket_workers()
.assigned()
.values_list("task__workbasket_id")
)
active_reviewers = (
- UserAssignment.objects.workbasket_reviewers()
+ TaskAssignee.objects.workbasket_reviewers()
.assigned()
.values_list("task__workbasket_id")
)
@@ -390,9 +389,11 @@ class EditWorkbasketView(PermissionRequiredMixin, TemplateView):
@method_decorator(require_current_workbasket, name="dispatch")
-class CurrentWorkBasket(FormView):
+class CurrentWorkBasket(SortingMixin, FormView):
template_name = "workbaskets/summary-workbasket.jinja"
form_class = forms.WorkBasketCommentCreateForm
+ sort_by_fields = ["comments"]
+ custom_sorting = {"comments": "created_at"}
@property
def workbasket(self) -> WorkBasket:
@@ -400,27 +401,28 @@ def workbasket(self) -> WorkBasket:
@cached_property
def comments(self):
+ comments = Comment.objects.filter(task__workbasket=self.workbasket)
ordering = self.get_comments_ordering()[0]
- return Comment.objects.filter(task__workbasket=self.workbasket).order_by(
- ordering,
- )
+ if ordering:
+ comments = comments.order_by(ordering)
+ return comments
@cached_property
def paginator(self):
return Paginator(self.comments, per_page=20)
- def get_comments_ordering(self) -> Tuple[str, str]:
- """Returns the ordering for `self.comments` based on `ordered` GET param
- together with the title to use for the sort by filter (which will be the
- opposite of the applied ordering)."""
- ordered = self.request.GET.get("ordered")
- if ordered == "desc":
+ def get_comments_ordering(self) -> tuple[str, str]:
+ """Reverses the ordering value returned by `super().get_ordering()` to
+ list newest comments first by default and includes a custom label to use
+ as the sorting anchor's title."""
+ ordering = super().get_ordering()
+ if ordering and ordering.startswith("-"):
ordering = "created_at"
- new_sort_by_title = "Newest first"
+ sort_by_label = "Newest first"
else:
ordering = "-created_at"
- new_sort_by_title = "Oldest first"
- return ordering, new_sort_by_title
+ sort_by_label = "Oldest first"
+ return ordering, sort_by_label
def form_valid(self, form):
form.save(user=self.request.user, workbasket=self.workbasket)
@@ -479,7 +481,7 @@ def get_context_data(self, **kwargs):
"can_add_comment": can_add_comment,
"can_view_comment": can_view_comment,
"comments": page.object_list,
- "sort_by_title": self.get_comments_ordering()[1],
+ "sort_by_label": self.get_comments_ordering()[1],
"paginator": self.paginator,
"page_obj": page,
"page_links": page_links,
@@ -1701,7 +1703,7 @@ class NoActiveWorkBasket(TemplateView):
class WorkBasketAssignUsersView(PermissionRequiredMixin, FormView):
- permission_required = "tasks.add_userassignment"
+ permission_required = "tasks.add_taskassignee"
template_name = "workbaskets/assign_users.jinja"
form_class = forms.WorkBasketAssignUsersForm
@@ -1731,6 +1733,7 @@ def form_valid(self, form):
defaults={
"title": self.workbasket.title,
"description": self.workbasket.reason,
+ "creator": self.request.user,
},
)
form.assign_users(task=task)
@@ -1741,7 +1744,7 @@ def get_success_url(self):
class WorkBasketUnassignUsersView(PermissionRequiredMixin, FormView):
- permission_required = "tasks.change_userassignment"
+ permission_required = "tasks.change_taskassignee"
template_name = "workbaskets/assign_users.jinja"
form_class = forms.WorkBasketUnassignUsersForm
@@ -1764,7 +1767,6 @@ def get_form_kwargs(self):
)
return kwargs
- @atomic
def form_valid(self, form):
form.unassign_users()
return redirect(self.get_success_url())