diff --git a/common/tests/util.py b/common/tests/util.py index 28ede02a8..f0b3c6a83 100644 --- a/common/tests/util.py +++ b/common/tests/util.py @@ -706,6 +706,7 @@ class Dates: "normal_first_half": (relativedelta(), relativedelta(days=+14)), "starts_1_month_ago_to_delta": (relativedelta(months=-1), relativedelta()), "starts_delta_to_1_month_ahead": (relativedelta(), relativedelta(months=1)), + "starts_delta_to_2_months_ahead": (relativedelta(), relativedelta(months=2)), "starts_1_month_ago_to_1_month_ahead": ( relativedelta(months=-1), relativedelta(months=1), diff --git a/geo_areas/views.py b/geo_areas/views.py index f29fb6baa..cd17b212e 100644 --- a/geo_areas/views.py +++ b/geo_areas/views.py @@ -141,7 +141,7 @@ def get_context_data(self, *args, **kwargs): class GeoAreaDetailMeasures(SortingMixin, WithPaginationListMixin, ListView): """Displays a paginated list of measures for a geo area as a simulated tab - on regulation detail view.""" + on geo area detail view.""" model = Measure template_name = "includes/geo_areas/tabs/measures.jinja" diff --git a/settings/common.py b/settings/common.py index 51ef14097..041d45eba 100644 --- a/settings/common.py +++ b/settings/common.py @@ -671,6 +671,9 @@ "measures.tasks.bulk_edit_measures": { "queue": "bulk-create", }, + "workbaskets.tasks.call_end_measures": { + "queue": "bulk-create", + }, re.compile(r"(reference_documents)\.tasks\..*"): { "queue": "standard", }, diff --git a/workbaskets/jinja2/workbaskets/auto_end_date_measures.jinja b/workbaskets/jinja2/workbaskets/auto_end_date_measures.jinja new file mode 100644 index 000000000..8e6f24aa9 --- /dev/null +++ b/workbaskets/jinja2/workbaskets/auto_end_date_measures.jinja @@ -0,0 +1,88 @@ +{% extends "layouts/layout.jinja" %} +{% from "components/create_sortable_anchor.jinja" import create_sortable_anchor %} +{% from "components/table/macro.njk" import govukTable %} +{% from "components/button/macro.njk" import govukButton %} + + +{% set page_title %} + Workbasket {{ workbasket.id if workbasket else request.user.current_workbasket.id }} - Auto end-date measures +{% endset %} + +{% block content %} +

{{ page_title }}

+ + {% set table_rows = [] %} + {% for measure in object_list %} + {% set measure_link %} + {{ measure.sid }} + {% endset %} + + {% set commodity_link %} + {{ measure.goods_nomenclature.item_id }} + {% endset %} + + {% set action %} + {% if measure.valid_between.lower > today %}To be deleted{% else %}To be end-dated{% endif %} + {% endset %} + + {{ table_rows.append([ + {"text": measure_link}, + {"html": commodity_link if measure.goods_nomenclature else "-"}, + {"text": "{:%d %b %Y}".format(measure.goods_nomenclature.version_at(workbasket.transactions.last()).valid_between.upper) if measure.goods_nomenclature.version_at(workbasket.transactions.last()).valid_between.upper else "-" }, + {"text": "{:%d %b %Y}".format(measure.valid_between.lower) }, + {"text": "{:%d %b %Y}".format(measure.effective_end_date) if measure.effective_end_date else '-' }, + {"text": action}, + ]) 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) }} + {% endset %} + + {% set start_date %} + {{ create_sortable_anchor(request, "start_date", "Measure start date", base_url) }} + {% endset %} + + {% set measure_sid %} + {{ create_sortable_anchor(request, "sid", "Measure SID", base_url) }} + {% endset %} + + {% if object_list %} +

The following measures are linked to commodity codes that have been end-dated in your workbasket. Click submit to automatically end-date or delete these measures.

+ {{ govukTable({ + "head": [ + {"text": measure_sid}, + {"text": commodity_code}, + {"text": "Commodity end date"}, + {"text": start_date}, + {"text": 'Measure end date'}, + {"text": "Action"}, + ], + "rows": table_rows + }) }} + +
+ + + {{ govukButton({ + 'text': "Submit", + 'preventDoubleClick': true, + "name": "action", + "value": "auto-end-date-measures", +}) }} +
+ + + + {% else %} +

No measures to end-date have been found.

+

There are either no active measures related to commodity codes updated in this workbasket or the measures already have end-dates in line with their respective commodities.

+ {% endif %} + {% include "includes/common/pagination.jinja" %} + + +{% endblock %} \ No newline at end of file diff --git a/workbaskets/jinja2/workbaskets/confirm_auto_end_date_measures.jinja b/workbaskets/jinja2/workbaskets/confirm_auto_end_date_measures.jinja new file mode 100644 index 000000000..6586f99dc --- /dev/null +++ b/workbaskets/jinja2/workbaskets/confirm_auto_end_date_measures.jinja @@ -0,0 +1,34 @@ +{% extends "layouts/layout.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 = "Measures submitted to be ended" %} + +{% block breadcrumb %} + {{ govukBreadcrumbs({ + "items": [ + {"text": "Home", "href": url("home")}, + {"text": "Workbasket " ~ request.user.current_workbasket.id, "href": url("workbaskets:edit-workbasket")}, + {"text": "Workbasket " ~ request.user.current_workbasket.id ~ " auto end date measures"}, + {"text": page_title} + ] + }) }} +{% endblock %} + +{% set measure_count = request.session['count_ended_measures'] if request.session['count_ended_measures'] else '0'%} + +{% block content %} +
+
+ {{ govukPanel({ + "titleText": measure_count ~ " measure" ~ measure_count|pluralize ~ " will be ended", + "text": "You have successfully submitted " ~ measure_count ~ " measure" ~ measure_count|pluralize ~ " to be end-dated or deleted and added to workbasket " ~ request.user.current_workbasket.id ~ " when complete.", + "classes": "govuk-!-margin-bottom-7" + }) }} +

Next steps

+ Continue to workbasket +
+
+{% endblock %} diff --git a/workbaskets/jinja2/workbaskets/summary-workbasket.jinja b/workbaskets/jinja2/workbaskets/summary-workbasket.jinja index 0a7be4454..77f3aad2b 100644 --- a/workbaskets/jinja2/workbaskets/summary-workbasket.jinja +++ b/workbaskets/jinja2/workbaskets/summary-workbasket.jinja @@ -169,6 +169,16 @@ {% endif %} {% endif %} +
+
Auto end-date measures
+
Automatically end-date measures on commodities which have been ended in this workbasket.
+
+ Auto end-date measures +
+
{% if comments and can_view_comment %} diff --git a/workbaskets/models.py b/workbaskets/models.py index 674265df9..d7f58a8d8 100644 --- a/workbaskets/models.py +++ b/workbaskets/models.py @@ -14,9 +14,14 @@ from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ValidationError from django.db import models +from django.db.models import Case +from django.db.models import DateField +from django.db.models import F from django.db.models import Max from django.db.models import QuerySet from django.db.models import Subquery +from django.db.models import Value +from django.db.models import When from django_fsm import FSMField from django_fsm import transition @@ -687,6 +692,49 @@ def assigned_reviewers(self): user_ids = self.reviewer_assignments.values_list("user_id", flat=True) return User.objects.filter(id__in=user_ids) + def get_measures_to_end_date(self) -> QuerySet: + """ + Returns a queryset of measures on end-dated commodities in the + workbasket along with those commodities' end-dates. + + It filters out measures which have already ended. + """ + + from commodities.models.orm import GoodsNomenclature + + end_dated_commodities = GoodsNomenclature.objects.current().filter( + transaction__workbasket=self, + valid_between__upper_inf=False, + ) + commodity_dict = { + commodity.sid: commodity.valid_between + for commodity in end_dated_commodities + } + measures_on_commodities = ( + Measure.objects.current() + .with_effective_valid_between() + .filter( + goods_nomenclature__sid__in=commodity_dict.keys(), + ) + ) + conditions = [ + When( + goods_nomenclature__sid=commodity_sid, + then=Value(commodity_valid_between), + ) + for commodity_sid, commodity_valid_between in commodity_dict.items() + ] + measures = measures_on_commodities.annotate( + commodity_valid_between=Case( + *conditions, + output_field=DateField(), + ), + ) + + return measures.exclude( + db_effective_valid_between__not_gt=F("commodity_valid_between"), + ) + class Meta: verbose_name = "workbasket" verbose_name_plural = "workbaskets" diff --git a/workbaskets/tasks.py b/workbaskets/tasks.py index 9482aee00..1a8a62bd7 100644 --- a/workbaskets/tasks.py +++ b/workbaskets/tasks.py @@ -1,12 +1,21 @@ +from datetime import date + from celery import group from celery import shared_task from celery.utils.log import get_task_logger +from django.db.models import F from django.db.transaction import atomic from checks.tasks import check_transaction from checks.tasks import check_transaction_sync +from commodities.models.orm import GoodsNomenclature from common.celery import app +from common.models.transactions import Transaction +from common.util import TaricDateRange +from common.validators import UpdateType +from measures.models.tracked_models import Measure from workbaskets.models import WorkBasket +from workbaskets.validators import WorkflowStatus # Celery logger adds the task id and status and outputs via the worker. logger = get_task_logger(__name__) @@ -64,3 +73,69 @@ def call_check_workbasket_sync(self, workbasket_id: int): workbasket: WorkBasket = WorkBasket.objects.get(pk=workbasket_id) workbasket.delete_checks() check_workbasket_sync(workbasket) + + +def promote_measure_to_top(promoted_measure, workbasket_transactions): + """Set the transaction order of `promoted_measure` to be first in the + workbasket, demoting the transactions that came before it.""" + + top_transaction = workbasket_transactions.first() + + if ( + not promoted_measure + or not top_transaction + or promoted_measure == top_transaction + ): + return + + current_position = promoted_measure.order + top_position = top_transaction.order + workbasket_transactions.filter(order__lt=current_position).update( + order=F("order") + 1, + ) + promoted_measure.order = top_position + promoted_measure.save(update_fields=["order"]) + + +@atomic +def end_measures(measures, workbasket): + """Iterate through measures on commodities, end-date those which have + already began and delete those which have not yet started.""" + for measure in measures: + workbasket_transactions = Transaction.objects.filter( + workbasket=workbasket, + workbasket__status=WorkflowStatus.EDITING, + ).order_by("order") + commodity = ( + GoodsNomenclature.objects.all() + .filter( + sid=measure.goods_nomenclature.sid, + transaction__workbasket=workbasket, + ) + .last() + ) + if measure.valid_between.lower > min( + date.today(), + commodity.valid_between.upper, + ): + new_measure_version = measure.new_version( + workbasket=workbasket, + update_type=UpdateType.DELETE, + ) + else: + new_measure_version = measure.new_version( + workbasket=workbasket, + update_type=UpdateType.UPDATE, + valid_between=TaricDateRange( + measure.valid_between.lower, + commodity.valid_between.upper, + ), + ) + promote_measure_to_top(new_measure_version.transaction, workbasket_transactions) + + +@app.task +def call_end_measures(measure_pks, workbasket_pk): + workbasket = WorkBasket.objects.all().get(pk=workbasket_pk) + measures = Measure.objects.all().filter(pk__in=measure_pks) + end_measures(measures, workbasket) diff --git a/workbaskets/tests/test_views.py b/workbaskets/tests/test_views.py index 4bdf04e6e..fbb28313e 100644 --- a/workbaskets/tests/test_views.py +++ b/workbaskets/tests/test_views.py @@ -18,6 +18,8 @@ from checks.tests.factories import TrackedModelCheckFactory from common.inspect_tap_tasks import CeleryTask from common.inspect_tap_tasks import TAPTasks +from common.models.trackedmodel import TrackedModel +from common.models.transactions import Transaction from common.models.utils import override_current_transaction from common.tests import factories from common.tests.util import date_post_data @@ -29,6 +31,7 @@ from tasks.models import Comment from tasks.models import UserAssignment from workbaskets import models +from workbaskets.tasks import call_end_measures from workbaskets.tasks import check_workbasket_sync from workbaskets.validators import WorkflowStatus from workbaskets.views import ui @@ -2645,6 +2648,159 @@ def test_remove_all_workbasket_changes_button_not_shown_to_users_without_permisi assert not remove_all_button +@pytest.fixture +def commodity_with_measures(date_ranges): + """Fixture used for texting the automatic end-dating of measures on an end- + dated commodity code.""" + commodity = factories.GoodsNomenclatureFactory.create( + valid_between=date_ranges.no_end, + ) + factories.MeasureFactory.create_batch( + 5, + goods_nomenclature=commodity, + ) # Open ended measures should be end-dated + factories.MeasureFactory.create_batch( + 4, + goods_nomenclature=commodity, + valid_between=date_ranges.starts_delta_to_2_months_ahead, + ) # Measures ending after the comm code should have their end-date aligned + factories.MeasureFactory.create_batch( + 3, + goods_nomenclature=commodity, + valid_between=date_ranges.starts_with_normal, + ) # Measures ending before the comm code should be ignored + factories.MeasureFactory.create_batch( + 2, + goods_nomenclature=commodity, + valid_between=date_ranges.future, + ) # Measures that have not started yet should be deleted + return commodity + + +def test_auto_end_measures_renders( + valid_user_client, + user_workbasket, + commodity_with_measures, + date_ranges, +): + """Test that the list of measures to be ended renders correctly.""" + commodity_with_measures.new_version( + workbasket=user_workbasket, + valid_between=date_ranges.normal, + ) + url = reverse( + "workbaskets:workbasket-ui-auto-end-date-measures", + ) + response = valid_user_client.get(url) + assert response.status_code == 200 + page = BeautifulSoup(str(response.content), "html.parser") + rows = page.find_all("tr", {"class": "govuk-table__row"}) + text = page.get_text() + assert text.count("To be end-dated") == 9 + assert text.count("To be deleted") == 2 + assert len(rows) == 12 + + +@patch("workbaskets.tasks.call_end_measures.apply_async") +def test_auto_end_measures_post( + call_check_end_measures, + valid_user_client, + commodity_with_measures, + user_workbasket, + date_ranges, +): + """Test that posting the auto end measures form results in the end_measures + Celery task being called and redirects to the confirmation page.""" + commodity_with_measures.new_version( + workbasket=user_workbasket, + valid_between=date_ranges.big, + ) + url = reverse( + "workbaskets:workbasket-ui-auto-end-date-measures", + ) + response = valid_user_client.post(url, {"action": "auto-end-date-measures"}) + confirmation_url = reverse( + "workbaskets:workbasket-ui-auto-end-date-measures-confirm", + kwargs={"pk": user_workbasket.pk}, + ) + assert response.status_code == 302 + assert response.url == confirmation_url + assert call_check_end_measures.called + + +def test_auto_end_measures(commodity_with_measures, user_workbasket, date_ranges): + """Test that the call_end_measures correctly ends measures and reorders them + in the workbasket.""" + new_commodity = commodity_with_measures.new_version( + workbasket=user_workbasket, + valid_between=date_ranges.normal, + ) + + with override_current_transaction(Transaction.objects.last()): + measures_to_end = user_workbasket.get_measures_to_end_date() + assert len(measures_to_end) == 11 + measure_pks = [measure.pk for measure in measures_to_end] + call_end_measures(measure_pks, user_workbasket.pk) + ended_measures = user_workbasket.measures + for measure in ended_measures[:9]: + assert measure.valid_between.upper == new_commodity.valid_between.upper + update_types = [measure.update_type for measure in ended_measures] + assert update_types.count(UpdateType.UPDATE) == 9 + assert update_types.count(UpdateType.DELETE) == 2 + first_11_items = ( + TrackedModel.objects.all() + .filter(transaction__workbasket=user_workbasket) + .order_by("transaction__order")[:10] + ) + for item in first_11_items: + assert isinstance(item, Measure) + + +def test_get_measures_to_end_date(user_workbasket, date_ranges): + """Test that the utility function correctly gathers measures, not including + those which already have an end date before the commodity's.""" + commodity = factories.GoodsNomenclatureFactory.create( + valid_between=date_ranges.no_end, + ) + + open_ended_measure = factories.MeasureFactory.create( + sid=11, + goods_nomenclature=commodity, + valid_between=date_ranges.no_end, + ) + measure_ended_before_commodity = factories.MeasureFactory.create( + sid=22, + goods_nomenclature=commodity, + valid_between=date_ranges.starts_with_normal, + ) + measure_ended_after_commodity_ends = factories.MeasureFactory.create( + sid=33, + goods_nomenclature=commodity, + valid_between=date_ranges.starts_delta_to_2_months_ahead, + ) + measure_to_start_after_commodity_ends = factories.MeasureFactory.create( + sid=44, + goods_nomenclature=commodity, + valid_between=date_ranges.future, + ) + + new_commodity = commodity.new_version( + workbasket=user_workbasket, + valid_between=date_ranges.normal, + ) + with override_current_transaction(Transaction.objects.last()): + measures = user_workbasket.get_measures_to_end_date() + assert all( + measure in measures + for measure in [ + open_ended_measure, + measure_ended_after_commodity_ends, + measure_to_start_after_commodity_ends, + ] + ) + assert measure_ended_before_commodity not in measures + + def test_reordering_transactions_bug(valid_user_client, user_workbasket): """Test that a user can reorder transactions, delete one and still be able to add new objects to the workbasket.""" diff --git a/workbaskets/urls.py b/workbaskets/urls.py index a4141d40c..8ac545a06 100644 --- a/workbaskets/urls.py +++ b/workbaskets/urls.py @@ -211,6 +211,16 @@ ui_views.WorkBasketCommentDelete.as_view(), name="workbasket-ui-comment-delete", ), + path( + f"current/auto-end-date-measures/", + ui_views.AutoEndDateMeasures.as_view(), + name="workbasket-ui-auto-end-date-measures", + ), + path( + f"/confirm-auto-end-date-measures/", + ui_views.AutoEndDateMeasuresConfirm.as_view(), + name="workbasket-ui-auto-end-date-measures-confirm", + ), ] urlpatterns = [ diff --git a/workbaskets/views/ui.py b/workbaskets/views/ui.py index f62fb86d1..b0373381e 100644 --- a/workbaskets/views/ui.py +++ b/workbaskets/views/ui.py @@ -72,6 +72,7 @@ from workbaskets.models import WorkBasket from workbaskets.session_store import SessionStore from workbaskets.tasks import call_check_workbasket_sync +from workbaskets.tasks import call_end_measures from workbaskets.validators import WorkflowStatus from workbaskets.views.decorators import require_current_workbasket from workbaskets.views.mixins import WithCurrentWorkBasket @@ -1779,3 +1780,57 @@ def status_tag_generator(self, task_status) -> dict: "text": task_status, "tag_class": "", } + + +@method_decorator(require_current_workbasket, name="dispatch") +class AutoEndDateMeasures(SortingMixin, WithPaginationListMixin, ListView): + model = Measure + paginate_by = 20 + template_name = "workbaskets/auto_end_date_measures.jinja" + sort_by_fields = ["start_date", "goods_nomenclature", "sid"] + custom_sorting = { + "start_date": "valid_between", + "goods_nomenclature": "goods_nomenclature__item_id", + "sid": "sid", + } + + @property + def workbasket(self) -> WorkBasket: + return WorkBasket.current(self.request) + + @property + def measures(self): + return self.workbasket.get_measures_to_end_date() + + def get_queryset(self): + ordering = self.get_ordering() + queryset = self.measures + if ordering: + if isinstance(ordering, str): + ordering = (ordering,) + queryset = queryset.order_by(*ordering) + return queryset + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["workbasket"] = self.workbasket + context["today"] = date.today() + return context + + def post(self, request, *args, **kwargs): + if request.POST.get("action", None) == "auto-end-date-measures": + self.end_measures() + self.request.session["count_ended_measures"] = len(self.measures) + return redirect( + "workbaskets:workbasket-ui-auto-end-date-measures-confirm", + self.workbasket.pk, + ) + + def end_measures(self): + measure_pks = [measure.pk for measure in self.measures] + call_end_measures.apply_async((measure_pks, self.workbasket.pk)) + + +class AutoEndDateMeasuresConfirm(DetailView): + template_name = "workbaskets/confirm_auto_end_date_measures.jinja" + model = WorkBasket