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 + + + +
+
+ Title +
+
+ {{ object.title }} +
+
+ Change title +
+
+ +
+
+ Description +
+
+ {{ object.description }} +
+
+ Change description +
+
+ +
+
+ Category +
+
+ {{ object.category or "-" }} +
+
+ Change category +
+
+ +
+
+ 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 -%} + +
+ + + {{ govukTable({ + "head": [ + {"text": ""}, + {"text": "Position"}, + {"text": "Name"}, + {"text": "Order"}, + {"text": "Remove"}, + ], + "rows": table_rows, + }) }} +
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

+
+ {{ crispy(filter.form) }} +
+
+ +
+ {% 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:

+ + {% 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

    + +
    + {{ crispy(filter.form) }} +
    +
    + +
    + {% 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:

    + + {% 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

    + +
    + {{ crispy(filter.form) }} +
    +
    + +
    + {% 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:

    + + {% 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 }}
    +
    +
    + +
    +
    Title
    +
    {{ object.title }}
    +
    + Change title +
    +
    + +
    +
    Description
    +
    {{ object.description }}
    +
    + Change description +
    +
    +
    + +
    + {{ 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

    +
    + {{ crispy(filter.form) }} +
    +
    + +
    + {% 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:

    + + {% 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())