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