diff --git a/measures/conditions.py b/measures/conditions.py index e8a3b0c32..e63d8988a 100644 --- a/measures/conditions.py +++ b/measures/conditions.py @@ -34,10 +34,19 @@ def show_step_duties(wizard): return MeasureEditSteps.DUTIES.value in cleaned_data.get("fields_to_edit") +def show_step_geographical_area_exclusions(wizard): + cleaned_data = wizard.get_cleaned_data_for_step(START) + if cleaned_data: + return MeasureEditSteps.GEOGRAPHICAL_AREA_EXCLUSIONS.value in cleaned_data.get( + "fields_to_edit", + ) + + measure_edit_condition_dict = { MeasureEditSteps.START_DATE: show_step_start_date, MeasureEditSteps.END_DATE: show_step_end_date, MeasureEditSteps.QUOTA_ORDER_NUMBER: show_step_quota_order_number, MeasureEditSteps.REGULATION: show_step_regulation, MeasureEditSteps.DUTIES: show_step_duties, + MeasureEditSteps.GEOGRAPHICAL_AREA_EXCLUSIONS: show_step_geographical_area_exclusions, } diff --git a/measures/constants.py b/measures/constants.py index fa05e96a0..afaf7318b 100644 --- a/measures/constants.py +++ b/measures/constants.py @@ -9,3 +9,7 @@ class MeasureEditSteps(models.TextChoices): QUOTA_ORDER_NUMBER = ("quota_order_number", "Quota order number") REGULATION = ("regulation", "Regulation") DUTIES = ("duties", "Duties") + GEOGRAPHICAL_AREA_EXCLUSIONS = ( + "geographical_area_exclusions", + "Geographical area exclusions", + ) diff --git a/measures/forms.py b/measures/forms.py index 1468561bb..c3dc0778f 100644 --- a/measures/forms.py +++ b/measures/forms.py @@ -1634,3 +1634,42 @@ def clean(self): validate_duties(duties, measure.valid_between.lower) return cleaned_data + + +class MeasureGeographicalAreaExclusionsForm(forms.Form): + excluded_area = forms.ModelChoiceField( + label="", + queryset=GeographicalArea.objects.all(), + help_text="Select a geographical area to be excluded", + required=False, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields["excluded_area"].queryset = ( + GeographicalArea.objects.current() + .with_latest_description() + .as_at_today_and_beyond() + .order_by("description") + ) + + self.fields[ + "excluded_area" + ].label_from_instance = lambda obj: f"{obj.area_id} - {obj.description}" + + self.helper = FormHelper(self) + self.helper.form_tag = False + self.helper.legend_size = Size.SMALL + self.helper.layout = Layout( + Fieldset( + "excluded_area", + ), + ) + + +class MeasureGeographicalAreaExclusionsFormSet(FormSet): + """Allows editing the geographical area exclusions of multiple measures in + `MeasureEditWizard`.""" + + form = MeasureGeographicalAreaExclusionsForm diff --git a/measures/jinja2/measures/edit-multiple-formset.jinja b/measures/jinja2/measures/edit-multiple-formset.jinja new file mode 100644 index 000000000..797cdbfb3 --- /dev/null +++ b/measures/jinja2/measures/edit-multiple-formset.jinja @@ -0,0 +1,58 @@ +{% extends "measures/edit-wizard-step.jinja" %} + +{% from "components/fieldset/macro.njk" import govukFieldset %} +{% from "components/error-summary/macro.njk" import govukErrorSummary %} + +{% block form %} + {% set formset = form %} + {{ crispy(formset.management_form, no_form_tags) }} + + {% if formset.non_form_errors() %} + {% set error_list = [] %} + {% for error in formset.non_form_errors() %} + {{ error_list.append({ + "html": '

' ~ error ~ '

', + }) or "" }} + {% endfor %} + + {{ govukErrorSummary({ + "titleText": "There is a problem", + "errorList": error_list + }) }} + {% endif %} + + {% for form in formset %} + {% if form.non_field_errors() %} + {% set error_list = [] %} + {% for error in form.non_field_errors() %} + {{ error_list.append({ + "html": '

' ~ error ~ '

', + }) or "" }} + {% endfor %} + + {{ govukErrorSummary({ + "titleText": "There is a problem", + "errorList": error_list + }) }} + {% endif %} + {{ crispy(form) }} + {% endfor %} + + {% if formset.data[formset.prefix ~ "-ADD"] %} + {{ crispy(formset.empty_form)|replace("__prefix__", formset.forms|length)|safe }} + {% endif %} + +
+ {{ govukButton({"text": "Save"}) }} + + {% if formset.total_form_count() + 1 < formset.max_num %} + {{ govukButton({ + "text": "Add another", + "attributes": {"id": "add-new"}, + "classes": "govuk-button--secondary", + "value": "1", + "name": formset.prefix ~ "-ADD", + }) }} + {% endif %} +
+{% endblock %} diff --git a/measures/tests/test_forms.py b/measures/tests/test_forms.py index 85081fd57..a518f1e71 100644 --- a/measures/tests/test_forms.py +++ b/measures/tests/test_forms.py @@ -1582,3 +1582,29 @@ def test_measure_review_form_validates_components_applicability_exclusivity( "A duty cannot be specified on both commodities and conditions" in form.errors["__all__"] ) + + +def test_measure_geographical_area_exclusions_form_valid_choice(): + """Tests that `MeasureGeographicalAreaExclusionsForm` is valid when an + available geographical area is selected.""" + geo_area = factories.GeographicalAreaFactory.create() + data = { + "excluded_area": geo_area.pk, + } + with override_current_transaction(geo_area.transaction): + form = forms.MeasureGeographicalAreaExclusionsForm(data) + assert form.is_valid() + assert form.cleaned_data["excluded_area"] == geo_area + + +def test_measure_geographical_area_exclusions_form_invalid_choice(): + """Tests that `MeasureGeographicalAreaExclusionsForm` raises an raises an + invalid choice error when an unavailable geographical area is selected.""" + data = { + "excluded_area": "geo_area", + } + form = forms.MeasureGeographicalAreaExclusionsForm(data) + assert not form.is_valid() + assert form.errors["excluded_area"] == [ + "Select a valid choice. That choice is not one of the available choices.", + ] diff --git a/measures/tests/test_views.py b/measures/tests/test_views.py index e09a28733..99469d3ed 100644 --- a/measures/tests/test_views.py +++ b/measures/tests/test_views.py @@ -47,6 +47,17 @@ pytestmark = pytest.mark.django_db +@pytest.fixture() +def mocked_diff_components(): + """Mocks `diff_components()` inside `update_measure_components()` in + `MeasureEditWizard` to prevent parsing errors where test measures lack a + duty sentence.""" + with patch( + "measures.views.MeasureEditWizard.update_measure_components", + ) as update_measure_components: + yield update_measure_components + + def test_measure_footnotes_update_get_delete_key(): footnote_key = "form-0-footnote" expected = "form-0-DELETE" @@ -1718,6 +1729,7 @@ def test_measuretype_api_list_view(valid_user_client): def test_multiple_measure_start_and_end_date_edit_functionality( valid_user_client, session_workbasket, + mocked_diff_components, ): """Tests that MeasureEditWizard takes a list of measures, and sets their update type to update, updates their end dates and start dates, and clears @@ -1841,6 +1853,7 @@ def test_multiple_measure_edit_single_form_functionality( data, valid_user_client, session_workbasket, + mocked_diff_components, ): """Tests that MeasureEditWizard takes a list of measures, and sets their update type to update, updates their end dates and start dates, and clears @@ -1913,6 +1926,7 @@ def test_multiple_measure_edit_single_form_functionality( def test_multiple_measure_edit_only_regulation( valid_user_client, session_workbasket, + mocked_diff_components, ): """Tests the regulation step in MeasureEditWizard.""" measure_1 = factories.MeasureFactory.create() @@ -2147,6 +2161,7 @@ def test_measure_list_selected_measures_list(valid_user_client): def test_multiple_measure_edit_only_quota_order_number( valid_user_client, session_workbasket, + mocked_diff_components, ): """Tests the regulation step in MeasureEditWizard.""" measure_1 = factories.MeasureFactory.create() @@ -2298,6 +2313,7 @@ def test_multiple_measure_edit_only_duties( def test_multiple_measure_edit_preserves_footnote_associations( valid_user_client, session_workbasket, + mocked_diff_components, ): """Tests that footnote associations are preserved in MeasureEditWizard.""" @@ -2375,6 +2391,84 @@ def test_multiple_measure_edit_preserves_footnote_associations( assert footnote in expected_footnotes +def test_multiple_measure_edit_geographical_area_exclusions( + valid_user_client, + session_workbasket, + mocked_diff_components, +): + """Tests that the geographical area exclusions of multiple measures can be + edited in `MeasureEditWizard`.""" + measure_1 = factories.MeasureFactory.create(with_exclusion=True) + measure_2 = factories.MeasureFactory.create() + new_excluded_area = factories.CountryFactory.create() + + url = reverse("measure-ui-edit-multiple") + session = valid_user_client.session + session.update( + { + "workbasket": { + "id": session_workbasket.pk, + }, + "MULTIPLE_MEASURE_SELECTIONS": { + measure_1.pk: 1, + measure_2.pk: 1, + }, + }, + ) + session.save() + + STEP_KEY = "measure_edit_wizard-current_step" + + wizard_data = [ + { + "data": { + STEP_KEY: START, + "start-fields_to_edit": [MeasureEditSteps.GEOGRAPHICAL_AREA_EXCLUSIONS], + }, + "next_step": MeasureEditSteps.GEOGRAPHICAL_AREA_EXCLUSIONS, + }, + { + "data": { + STEP_KEY: MeasureEditSteps.GEOGRAPHICAL_AREA_EXCLUSIONS, + "form-0-excluded_area": new_excluded_area.pk, + }, + "next_step": "complete", + }, + ] + for step_data in wizard_data: + url = reverse( + "measure-ui-edit-multiple", + kwargs={"step": step_data["data"][STEP_KEY]}, + ) + response = valid_user_client.get(url) + assert response.status_code == 200 + + response = valid_user_client.post(url, step_data["data"]) + assert response.status_code == 302 + + assert response.url == reverse( + "measure-ui-edit-multiple", + kwargs={"step": step_data["next_step"]}, + ) + + complete_response = valid_user_client.get(response.url) + assert complete_response.status_code == 302 + assert valid_user_client.session["MULTIPLE_MEASURE_SELECTIONS"] == {} + + workbasket_measures = Measure.objects.filter( + transaction__workbasket=session_workbasket, + ) + assert workbasket_measures + + with override_current_transaction(Transaction.objects.last()): + for measure in workbasket_measures: + assert measure.update_type == UpdateType.UPDATE + assert ( + measure.exclusions.current().first().excluded_geographical_area + == new_excluded_area + ) + + def test_measure_list_redirects_to_search_with_no_params(valid_user_client): response = valid_user_client.get(reverse("measure-ui-list")) assert response.status_code == 302 diff --git a/measures/views.py b/measures/views.py index ff13f6daa..ff7423c8f 100644 --- a/measures/views.py +++ b/measures/views.py @@ -3,6 +3,7 @@ from itertools import groupby from operator import attrgetter from typing import Any +from typing import List from typing import Type from urllib.parse import urlencode @@ -34,6 +35,8 @@ from common.views import TamatoListView from common.views import TrackedModelDetailMixin from common.views import TrackedModelDetailView +from geo_areas.models import GeographicalArea +from geo_areas.utils import get_all_members_of_geo_groups from measures import forms from measures.constants import START from measures.constants import MeasureEditSteps @@ -44,6 +47,7 @@ from measures.models import Measure from measures.models import MeasureActionPair from measures.models import MeasureConditionComponent +from measures.models import MeasureExcludedGeographicalArea from measures.models import MeasureType from measures.pagination import MeasurePaginator from measures.parsers import DutySentenceParser @@ -267,10 +271,15 @@ class MeasureEditWizard( (MeasureEditSteps.QUOTA_ORDER_NUMBER, forms.MeasureQuotaOrderNumberForm), (MeasureEditSteps.REGULATION, forms.MeasureRegulationForm), (MeasureEditSteps.DUTIES, forms.MeasureDutiesForm), + ( + MeasureEditSteps.GEOGRAPHICAL_AREA_EXCLUSIONS, + forms.MeasureGeographicalAreaExclusionsFormSet, + ), ] templates = { START: "measures/edit-multiple-start.jinja", + MeasureEditSteps.GEOGRAPHICAL_AREA_EXCLUSIONS: "measures/edit-multiple-formset.jinja", } step_metadata = { @@ -293,6 +302,9 @@ class MeasureEditWizard( MeasureEditSteps.DUTIES: { "title": "Edit the duties", }, + MeasureEditSteps.GEOGRAPHICAL_AREA_EXCLUSIONS: { + "title": "Edit the geographical area exclusions", + }, } def get_template_names(self): @@ -313,7 +325,11 @@ def get_context_data(self, form, **kwargs): def get_form_kwargs(self, step): kwargs = {} - if step not in [START, MeasureEditSteps.QUOTA_ORDER_NUMBER]: + if step not in [ + START, + MeasureEditSteps.QUOTA_ORDER_NUMBER, + MeasureEditSteps.GEOGRAPHICAL_AREA_EXCLUSIONS, + ]: kwargs["selected_measures"] = self.get_queryset() if step == MeasureEditSteps.DUTIES: @@ -328,6 +344,103 @@ def get_form_kwargs(self, step): return kwargs + def update_measure_components( + self, + measure: Measure, + duties: str, + workbasket: WorkBasket, + ): + """Updates the measure components associated to the measure.""" + diff_components( + instance=measure, + duty_sentence=duties if duties else measure.duty_sentence, + start_date=measure.valid_between.lower, + workbasket=workbasket, + transaction=workbasket.current_transaction, + ) + + def update_measure_condition_components( + self, + measure: Measure, + workbasket: WorkBasket, + ): + """Updates the measure condition components associated to the + measure.""" + conditions = measure.conditions.current() + for condition in conditions: + condition.new_version( + dependent_measure=measure, + workbasket=workbasket, + ) + + def update_measure_excluded_geographical_areas( + self, + edited: bool, + measure: Measure, + exclusions: List[GeographicalArea], + workbasket: WorkBasket, + ): + """Updates the excluded geographical areas associated to the measure.""" + existing_exclusions = measure.exclusions.current() + + # Update any exclusions to new measure version + if not edited: + for exclusion in existing_exclusions: + exclusion.new_version( + modified_measure=measure, + workbasket=workbasket, + ) + return + + new_excluded_areas = get_all_members_of_geo_groups( + validity=measure.valid_between, + geo_areas=exclusions, + ) + + for geo_area in new_excluded_areas: + existing_exclusion = existing_exclusions.filter( + excluded_geographical_area=geo_area, + ).first() + if existing_exclusion: + existing_exclusion.new_version( + modified_measure=measure, + workbasket=workbasket, + ) + else: + MeasureExcludedGeographicalArea.objects.create( + modified_measure=measure, + excluded_geographical_area=geo_area, + update_type=UpdateType.CREATE, + transaction=workbasket.new_transaction(), + ) + + removed_excluded_areas = { + e.excluded_geographical_area for e in existing_exclusions + }.difference(set(exclusions)) + + exclusions_to_remove = [ + existing_exclusions.get(excluded_geographical_area__id=geo_area.id) + for geo_area in removed_excluded_areas + ] + + for exclusion in exclusions_to_remove: + exclusion.new_version( + update_type=UpdateType.DELETE, + modified_measure=measure, + workbasket=workbasket, + ) + + def update_measure_footnote_associations(self, measure, workbasket): + """Updates the footnotes associated to the measure.""" + footnote_associations = FootnoteAssociationMeasure.objects.current().filter( + footnoted_measure__sid=measure.sid, + ) + for fa in footnote_associations: + fa.new_version( + footnoted_measure=measure, + workbasket=workbasket, + ) + def done(self, form_list, **kwargs): cleaned_data = self.get_all_cleaned_data() selected_measures = self.get_queryset() @@ -349,6 +462,10 @@ def done(self, form_list, **kwargs): new_quota_order_number = cleaned_data.get("order_number", None) new_generating_regulation = cleaned_data.get("generating_regulation", None) new_duties = cleaned_data.get("duties", None) + new_exclusions = [ + e["excluded_area"] + for e in cleaned_data.get("formset-geographical_area_exclusions", []) + ] for measure in selected_measures: new_measure = measure.new_version( workbasket=workbasket, @@ -366,24 +483,27 @@ def done(self, form_list, **kwargs): if new_generating_regulation else measure.generating_regulation, ) - if new_duties: - diff_components( - instance=new_measure, - duty_sentence=new_duties, - start_date=new_measure.valid_between.lower, - workbasket=workbasket, - transaction=workbasket.current_transaction, - ) - footnote_associations = FootnoteAssociationMeasure.objects.current().filter( - footnoted_measure=measure, + self.update_measure_components( + measure=new_measure, + duties=new_duties, + workbasket=workbasket, ) - for fa in footnote_associations: - fa.new_version( - workbasket=workbasket, - update_type=UpdateType.UPDATE, - footnoted_measure=new_measure, - ) - self.session_store.clear() + self.update_measure_condition_components( + measure=new_measure, + workbasket=workbasket, + ) + self.update_measure_excluded_geographical_areas( + edited="geographical_area_exclusions" + in cleaned_data.get("fields_to_edit", []), + measure=new_measure, + exclusions=new_exclusions, + workbasket=workbasket, + ) + self.update_measure_footnote_associations( + measure=new_measure, + workbasket=workbasket, + ) + self.session_store.clear() return redirect(reverse("workbaskets:review-workbasket"))