diff --git a/quotas/business_rules.py b/quotas/business_rules.py index 0092ecd5e..92f5c718f 100644 --- a/quotas/business_rules.py +++ b/quotas/business_rules.py @@ -293,7 +293,7 @@ def validate(self, quota_definition): class QuotaAssociationMustReferToANonDeletedSubQuota( PreventDeletingLinkedQuotaDefinitions, ): - """A Quota Association must refer to a non-deleted sub quota.""" + """A Quota Association must refer to a non-deleted sub-quota.""" sid_prefix = "sub_quota__" diff --git a/quotas/forms.py b/quotas/forms.py index 384e3f3d7..8ebc4ddc5 100644 --- a/quotas/forms.py +++ b/quotas/forms.py @@ -1,3 +1,5 @@ +from datetime import date + from crispy_forms_gds.helper import FormHelper from crispy_forms_gds.layout import HTML from crispy_forms_gds.layout import Accordion @@ -1110,6 +1112,9 @@ def clean(self): class SubQuotaDefinitionsUpdatesForm( ValidityPeriodForm, ): + """Form used to edit duplicated sub-quota definitions and associations as + part of the sub-quota create journey.""" + class Meta: model = models.QuotaDefinition fields = [ @@ -1162,6 +1167,7 @@ class Meta: ) measurement_unit = forms.ModelChoiceField( + label="Measurement unit", queryset=MeasurementUnit.objects.current().order_by("code"), error_messages={"required": "Select the measurement unit"}, ) @@ -1223,7 +1229,7 @@ def clean(self): main_definition_valid_between=original_definition.valid_between, ): raise ValidationError( - "QA2: Validity period for sub quota must be within the " + "QA2: Validity period for sub-quota must be within the " "validity period of the main quota", ) @@ -1337,3 +1343,55 @@ def init_layout(self, request): ), ), ) + + +class SubQuotaDefinitionAssociationUpdateForm(SubQuotaDefinitionsUpdatesForm): + """Form used to update sub-quota definitions and associations as part of the + edit sub-quotas journey.""" + + def __init__(self, *args, **kwargs): + self.request = kwargs.pop("request", None) + self.workbasket = self.request.user.current_workbasket + sub_quota_definition_sid = kwargs.pop("sid") + ValidityPeriodForm.__init__(self, *args, **kwargs) + self.sub_quota = models.QuotaDefinition.objects.current().get( + sid=sub_quota_definition_sid, + ) + self.init_fields() + self.set_initial_data() + self.init_layout(self.request) + + def set_initial_data(self): + association = models.QuotaAssociation.objects.current().get( + sub_quota__sid=self.sub_quota.sid, + ) + self.original_definition = association.main_quota + fields = self.fields + fields["relationship_type"].initial = association.sub_quota_relation_type + fields["coefficient"].initial = association.coefficient + fields["measurement_unit"].initial = self.sub_quota.measurement_unit + fields["initial_volume"].initial = self.sub_quota.initial_volume + fields["volume"].initial = self.sub_quota.volume + fields["start_date"].initial = self.sub_quota.valid_between.lower + fields["end_date"].initial = self.sub_quota.valid_between.upper + + def init_fields(self): + super().init_fields() + if self.sub_quota.valid_between.lower <= date.today(): + self.fields["coefficient"].disabled = True + self.fields["relationship_type"].disabled = True + self.fields["start_date"].disabled = True + self.fields["initial_volume"].disabled = True + self.fields["volume"].disabled = True + self.fields["measurement_unit"].disabled = True + + +class QuotaAssociationEdit(forms.ModelForm): + class Meta: + model = models.QuotaAssociation + fields = [ + "sub_quota_relation_type", + "coefficient", + "main_quota", + "sub_quota", + ] diff --git a/quotas/jinja2/quota-definitions/sub-quota-definitions-confirm-update.jinja b/quotas/jinja2/quota-definitions/sub-quota-definitions-confirm-update.jinja new file mode 100644 index 000000000..1bb6ad04d --- /dev/null +++ b/quotas/jinja2/quota-definitions/sub-quota-definitions-confirm-update.jinja @@ -0,0 +1,42 @@ +{% extends "common/confirm_update.jinja" %} +{% from "components/breadcrumbs.jinja" import breadcrumbs %} + + +{% set page_title = "Sub-quota definition and association updated" %} + +{% block breadcrumb %} + {{ breadcrumbs(request, [ + {"text": "Find and edit quotas", "href": url("quota-ui-list")}, + {"text": "Quota " ~ association.main_quota.order_number, "href": association.main_quota.order_number.get_url()}, + {"text": "Quota " ~ association.main_quota.order_number ~ " - Data", "href": url("quota_definition-ui-list", kwargs={"sid":association.main_quota.order_number.sid})}, + {"text": page_title}, + ]) + }} +{% endblock %} + + +{% block panel %} + {{ govukPanel({ + "titleText": "Sub-" ~ object._meta.verbose_name ~ ": " ~ object|string, + "text": "Sub-" ~ object._meta.verbose_name ~ ": " ~ object|string ~ " and association have been updated in workbasket " ~ request.user.current_workbasket.pk, + "classes": "govuk-!-margin-bottom-7" + }) }} +{% endblock %} + + {% block button_group %} + {{ govukButton({ + "text": "View workbasket summary", + "href": url("workbaskets:current-workbasket"), + "classes": "govuk-button" + }) }} + {{ govukButton({ + "text": "Return to main quota", + "href": association.main_quota.order_number.get_url(), + "classes": "govuk-button--secondary" + }) }} + {% endblock %} + +{% block actions %} +
  • View this sub-quota definition's quota order number
  • +
  • Find and edit quotas
  • +{% endblock %} \ No newline at end of file diff --git a/quotas/jinja2/quota-definitions/sub-quota-definitions-updates.jinja b/quotas/jinja2/quota-definitions/sub-quota-definitions-updates.jinja index 63eab5722..8d6974575 100644 --- a/quotas/jinja2/quota-definitions/sub-quota-definitions-updates.jinja +++ b/quotas/jinja2/quota-definitions/sub-quota-definitions-updates.jinja @@ -1,6 +1,10 @@ {% extends "layouts/form.jinja" %} +{% from "components/breadcrumbs.jinja" import breadcrumbs %} + {% from "components/table/macro.njk" import govukTable %} +{% set page_title = "Update sub-quota definition and association" %} + {% block content %}

    Main quota definition details

    {% set main_definition = view.get_main_definition() %} diff --git a/quotas/jinja2/quotas/tables/sub_quotas.jinja b/quotas/jinja2/quotas/tables/sub_quotas.jinja index eae119037..947746eeb 100644 --- a/quotas/jinja2/quotas/tables/sub_quotas.jinja +++ b/quotas/jinja2/quotas/tables/sub_quotas.jinja @@ -11,27 +11,32 @@ {% set sub_quota_link -%} {{ object.sub_quota.order_number.order_number }} {% endset %} + {% set edit_link -%} + Edit + {% endset %} {{ table_rows.append([ {"text": definition_link }, {"text": sub_quota_link }, - {"text": "{:%d %b %Y}".format(object.sub_quota.valid_between.lower) }, - {"text": "{:%d %b %Y}".format(object.sub_quota.valid_between.upper) if object.sub_quota.valid_between.upper else "-"}, + {"text": "{:%d %b %Y}".format(object.sub_quota.version_at(request.user.current_workbasket.transactions.last()).valid_between.lower) }, + {"text": "{:%d %b %Y}".format(object.sub_quota.version_at(request.user.current_workbasket.transactions.last()).valid_between.upper) if object.sub_quota.valid_between.upper else "-"}, {"text": object.get_sub_quota_relation_type_display() }, {"text": object.coefficient }, + {"text": edit_link }, ]) or "" }} {% endfor %} {{ govukTable({ "head": [ {"text": "Quota definition sid"}, - {"text": "Sub quota order number"}, + {"text": "Sub-quota order number"}, {"text": "Start date"}, {"text": "End date"}, {"text": "Relation type"}, {"text": "Coefficient"}, + {"text": "Actions"}, ], "rows": table_rows }) }} {% else %} -

    There are no sub quotas for this quota order number.

    +

    There are no sub-quotas for this quota order number.

    {% endif %} \ No newline at end of file diff --git a/quotas/models.py b/quotas/models.py index f89807801..402a6654a 100644 --- a/quotas/models.py +++ b/quotas/models.py @@ -404,6 +404,26 @@ def get_url(self, action: str = "detail") -> Optional[str]: def slugify_model_name(cls): return cls._meta.verbose_name.replace(" ", "_") + def get_association_edit_url(self): + """Get the edit url for the sub-quota definition and association edit + journey by checking if it has been updated in an 'EDITING' status + workbasket.""" + url = "sub_quota_definition-edit" + try: + if self.transaction.workbasket.status == WorkflowStatus.EDITING: + # There is no edit-create journey for quota definitions so edits of a + # newly created object will add a new update object to the workbasket + if self.update_type == UpdateType.UPDATE: + url += "-update" + + url = reverse( + url, + kwargs={"sid": self.sid}, + ) + return url + except NoReverseMatch: + return None + class QuotaAssociation(TrackedModel): """The quota association defines the relation between quota and sub- diff --git a/quotas/tests/test_forms.py b/quotas/tests/test_forms.py index 0211992a6..2a51525e5 100644 --- a/quotas/tests/test_forms.py +++ b/quotas/tests/test_forms.py @@ -561,6 +561,24 @@ def quota_definition_1(main_quota_order_number, date_ranges) -> QuotaDefinition: ) +@pytest.fixture +def sub_quota(main_quota_order_number, date_ranges) -> QuotaDefinition: + """ + Provides a definition to be used as a sub_quota. + + It has a valid between in the future as otherwise only the end date can be + edited. + """ + return factories.QuotaDefinitionFactory.create( + order_number=main_quota_order_number, + valid_between=date_ranges.future, + is_physical=True, + initial_volume=1234, + volume=1234, + measurement_unit=factories.MeasurementUnitFactory(), + ) + + def test_select_sub_quota_form_set_staged_definition_data( quota_definition_1, session_request, @@ -616,7 +634,7 @@ def test_quota_duplicator_form_clean_QA2( ) assert not form.is_valid() assert ( - "QA2: Validity period for sub quota must be within the validity period of the main quota" + "QA2: Validity period for sub-quota must be within the validity period of the main quota" in form.errors["__all__"] ) @@ -760,3 +778,192 @@ def test_quota_duplicator_form_clean_QA5_eq(session_request, quota_definition_1) "QA5: Where the relationship type is Equivalent, the coefficient value must be something other than 1" in form.errors["__all__"] ) + + +def test_sub_quota_update_form_valid(session_request_with_workbasket, sub_quota): + """Test that the sub-quota update form initialises correctly and is valid + when valid data is passed in.""" + main_quota = factories.QuotaDefinitionFactory.create( + volume=9999, + initial_volume=9999, + measurement_unit=sub_quota.measurement_unit, + ) + association = factories.QuotaAssociationFactory.create( + sub_quota=sub_quota, + main_quota=main_quota, + sub_quota_relation_type="EQ", + coefficient=1.5, + ) + + form = forms.SubQuotaDefinitionAssociationUpdateForm( + instance=sub_quota, + request=session_request_with_workbasket, + sid=sub_quota.sid, + ) + assert float(form.fields["coefficient"].initial) == association.coefficient + assert ( + form.fields["relationship_type"].initial == association.sub_quota_relation_type + ) + assert form.fields["measurement_unit"].initial == sub_quota.measurement_unit + assert form.fields["initial_volume"].initial == sub_quota.initial_volume + assert form.fields["volume"].initial == sub_quota.volume + assert form.fields["start_date"].initial == sub_quota.valid_between.lower + assert form.fields["end_date"].initial == sub_quota.valid_between.upper + + data = { + "start_date_0": sub_quota.valid_between.lower.day, + "start_date_1": sub_quota.valid_between.lower.month, + "start_date_2": sub_quota.valid_between.lower.year, + "end_date_0": sub_quota.valid_between.upper.day, + "end_date_1": sub_quota.valid_between.upper.month, + "end_date_2": sub_quota.valid_between.upper.year, + "measurement_unit": sub_quota.measurement_unit, + "volume": 100, + "initial_volume": 100, + "coefficient": 1.5, + "relationship_type": "EQ", + } + + with override_current_transaction(Transaction.objects.last()): + form = forms.SubQuotaDefinitionAssociationUpdateForm( + request=session_request_with_workbasket, + data=data, + sid=sub_quota.sid, + instance=sub_quota, + ) + assert form.is_valid() + + +def test_sub_quota_update_form_invalid(session_request_with_workbasket, sub_quota): + """Test that the sub-quota update form is invalid when invalid data is + passed in.""" + main_quota = factories.QuotaDefinitionFactory.create( + volume=9999, + initial_volume=9999, + measurement_unit=sub_quota.measurement_unit, + ) + factories.QuotaAssociationFactory.create( + sub_quota=sub_quota, + main_quota=main_quota, + sub_quota_relation_type="EQ", + coefficient=1.5, + ) + + data = { + "start_date_0": sub_quota.valid_between.lower.day, + "start_date_1": sub_quota.valid_between.lower.month, + "start_date_2": sub_quota.valid_between.lower.year, + "end_date_0": sub_quota.valid_between.upper.day, + "end_date_1": sub_quota.valid_between.upper.month, + "end_date_2": sub_quota.valid_between.upper.year, + "measurement_unit": sub_quota.measurement_unit, + "volume": 100, + "initial_volume": 100, + "coefficient": 1, + "relationship_type": "EQ", + } + + with override_current_transaction(Transaction.objects.last()): + form = forms.SubQuotaDefinitionAssociationUpdateForm( + request=session_request_with_workbasket, + data=data, + sid=sub_quota.sid, + instance=sub_quota, + ) + assert not form.is_valid() + assert ( + "QA5: Where the relationship type is Equivalent, the coefficient value must be something other than 1" + in form.errors["__all__"] + ) + + +def test_only_end_date_editable_for_active_definitions( + date_ranges, + session_request_with_workbasket, +): + """Test that it is not possible for a user to edit any field other than the + end-date for a sub-quota which has already begun.""" + active_sub_quota = factories.QuotaDefinitionFactory.create( + order_number=factories.QuotaOrderNumberFactory.create(), + valid_between=date_ranges.normal, + is_physical=True, + initial_volume=1234, + volume=1234, + measurement_unit=factories.MeasurementUnitFactory(), + ) + main_quota = factories.QuotaDefinitionFactory.create( + volume=9999, + initial_volume=9999, + measurement_unit=active_sub_quota.measurement_unit, + ) + factories.QuotaAssociationFactory.create( + sub_quota=active_sub_quota, + main_quota=main_quota, + sub_quota_relation_type="EQ", + coefficient=1.5, + ) + + new_measurement_unit = factories.MeasurementUnitFactory.create() + + data = { + "end_date_0": 1, + "end_date_1": 1, + "end_date_2": 2035, + "measurement_unit": new_measurement_unit, + "volume": 100, + "coefficient": 1, + } + + with override_current_transaction(Transaction.objects.last()): + form = forms.SubQuotaDefinitionAssociationUpdateForm( + request=session_request_with_workbasket, + data=data, + sid=active_sub_quota.sid, + instance=active_sub_quota, + ) + assert form.is_valid() + cleaned_data = form.cleaned_data + assert not cleaned_data["coefficient"] == 1 + assert not cleaned_data["volume"] == 100 + assert not cleaned_data["measurement_unit"] == new_measurement_unit + assert cleaned_data["valid_between"].upper == datetime.date(2035, 1, 1) + + +def test_quota_association_edit_form_valid(): + "Test that the quota association edit form is valid when valid data is passed in." + association = factories.QuotaAssociationFactory.create( + coefficient=1.67, + sub_quota_relation_type="EQ", + ) + + data = { + "coefficient": 1.5, + "sub_quota_relation_type": "EQ", + "main_quota": association.main_quota, + "sub_quota": association.sub_quota, + } + form = forms.QuotaAssociationEdit(data=data, instance=association) + + assert form.is_valid() + + +def test_quota_association_edit_form_invalid(): + "Test that the quota association edit form is invalid when data is passed in." + association = factories.QuotaAssociationFactory.create( + coefficient=1.67, + sub_quota_relation_type="EQ", + ) + + data = { + "coefficient": "String", + "sub_quota_relation_type": "Equivalent", + "main_quota": association.main_quota, + "sub_quota": association.sub_quota, + } + form = forms.QuotaAssociationEdit(data=data, instance=association) + assert ( + "Select a valid choice. Equivalent is not one of the available choices." + in form.errors["sub_quota_relation_type"] + ) + assert "Enter a number." in form.errors["coefficient"] + assert not form.is_valid() diff --git a/quotas/tests/test_models.py b/quotas/tests/test_models.py index 1efe2b318..d1f150aef 100644 --- a/quotas/tests/test_models.py +++ b/quotas/tests/test_models.py @@ -7,6 +7,7 @@ from common.serializers import AutoCompleteSerializer from common.tests import factories from common.tests.util import raises_if +from common.validators import UpdateType pytestmark = pytest.mark.django_db @@ -167,3 +168,23 @@ def test_quota_definition_urls(url_name, exp_path): sid=987654321, ) assert definition.get_url(url_name) == exp_path + + +def test_get_association_edit_url(workbasket): + """Test that the correct edit url is generated for the sub-quota edit and + edit-update journeys.""" + quota = factories.QuotaOrderNumberFactory.create(sid=123456789) + definition = factories.QuotaDefinitionFactory.create( + order_number=quota, + sid=987654321, + ) + exp_path = reverse("sub_quota_definition-edit", kwargs={"sid": definition.sid}) + assert definition.get_association_edit_url() == exp_path + + definition.new_version(workbasket=workbasket, update_type=UpdateType.UPDATE) + updated_definition_instance = definition.version_at(workbasket.transactions.last()) + exp_path = reverse( + "sub_quota_definition-edit-update", + kwargs={"sid": definition.sid}, + ) + assert updated_definition_instance.get_association_edit_url() == exp_path diff --git a/quotas/tests/test_views.py b/quotas/tests/test_views.py index 875c953bc..29ab8e2d7 100644 --- a/quotas/tests/test_views.py +++ b/quotas/tests/test_views.py @@ -116,7 +116,7 @@ def test_quota_delete_form(factory, use_delete_form): def test_quota_detail_views( view, url_pattern, - valid_user_client, + client_with_current_workbasket, mock_quota_api_no_data, ): """Verify that quota detail views are under the url quotas and don't return @@ -124,7 +124,7 @@ def test_quota_detail_views( assert_model_view_renders( view, url_pattern, - valid_user_client, + client_with_current_workbasket, override_models={"quotas.views.QuotaDefinitionCreate": models.QuotaOrderNumber}, ) @@ -389,17 +389,20 @@ def test_quota_event_api_list_view(valid_user_client): ) -def test_quota_definitions_list_200(valid_user_client, quota_order_number): +def test_quota_definitions_list_200(client_with_current_workbasket, quota_order_number): factories.QuotaDefinitionFactory.create_batch(5, order_number=quota_order_number) url = reverse("quota_definition-ui-list", kwargs={"sid": quota_order_number.sid}) - response = valid_user_client.get(url) + response = client_with_current_workbasket.get(url) assert response.status_code == 200 -def test_quota_definitions_list_no_quota_data(valid_user_client, quota_order_number): +def test_quota_definitions_list_no_quota_data( + client_with_current_workbasket, + quota_order_number, +): factories.QuotaDefinitionFactory.create_batch(5, order_number=quota_order_number) url = ( @@ -410,13 +413,16 @@ def test_quota_definitions_list_no_quota_data(valid_user_client, quota_order_num with mock.patch( "common.tariffs_api.get_quota_definitions_data", ) as mock_get_quotas: - response = valid_user_client.get(url) + response = client_with_current_workbasket.get(url) mock_get_quotas.assert_not_called() assert response.status_code == 200 -def test_quota_definitions_list_sids(valid_user_client, quota_order_number): +def test_quota_definitions_list_sids( + client_with_current_workbasket, + quota_order_number, +): definitions = factories.QuotaDefinitionFactory.create_batch( 5, order_number=quota_order_number, @@ -424,7 +430,7 @@ def test_quota_definitions_list_sids(valid_user_client, quota_order_number): url = reverse("quota_definition-ui-list", kwargs={"sid": quota_order_number.sid}) - response = valid_user_client.get(url) + response = client_with_current_workbasket.get(url) soup = BeautifulSoup(response.content.decode(response.charset), "html.parser") sids = { @@ -437,12 +443,15 @@ def test_quota_definitions_list_sids(valid_user_client, quota_order_number): assert not sids.difference(object_sids) -def test_quota_definitions_list_title(valid_user_client, quota_order_number): +def test_quota_definitions_list_title( + client_with_current_workbasket, + quota_order_number, +): factories.QuotaDefinitionFactory.create_batch(5, order_number=quota_order_number) url = reverse("quota_definition-ui-list", kwargs={"sid": quota_order_number.sid}) - response = valid_user_client.get(url) + response = client_with_current_workbasket.get(url) soup = BeautifulSoup(response.content.decode(response.charset), "html.parser") title = soup.select("h1")[0].text @@ -450,7 +459,7 @@ def test_quota_definitions_list_title(valid_user_client, quota_order_number): def test_quota_definitions_list_current_versions( - valid_user_client, + client_with_current_workbasket, approved_transaction, ): quota_order_number = factories.QuotaOrderNumberFactory() @@ -477,7 +486,7 @@ def test_quota_definitions_list_current_versions( url = reverse("quota_definition-ui-list", kwargs={"sid": quota_order_number.sid}) - response = valid_user_client.get(url) + response = client_with_current_workbasket.get(url) soup = BeautifulSoup(response.content.decode(response.charset), "html.parser") num_definitions = len( @@ -515,7 +524,7 @@ def test_quota_definitions_list_current_measures( def test_quota_definitions_list_edit_delete( - valid_user_client, + client_with_current_workbasket, date_ranges, mock_quota_api_no_data, ): @@ -537,7 +546,7 @@ def test_quota_definitions_list_edit_delete( url = reverse("quota_definition-ui-list", kwargs={"sid": quota_order_number.sid}) - response = valid_user_client.get(url) + response = client_with_current_workbasket.get(url) soup = BeautifulSoup(response.content.decode(response.charset), "html.parser") actions = [item.text for item in soup.select("table tbody tr td:last-child")] @@ -557,7 +566,7 @@ def test_quota_definitions_list_edit_delete( def test_quota_definitions_list_sort_by_start_date( - valid_user_client, + client_with_current_workbasket, date_ranges, ): """Test that quota definitions list can be sorted by start date in ascending @@ -575,7 +584,9 @@ def test_quota_definitions_list_sort_by_start_date( ) url = reverse("quota_definition-ui-list", kwargs={"sid": quota_order_number.sid}) - response = valid_user_client.get(f"{url}?sort_by=valid_between&ordered=asc") + response = client_with_current_workbasket.get( + f"{url}?sort_by=valid_between&ordered=asc", + ) assert response.status_code == 200 page = BeautifulSoup(response.content.decode(response.charset), "html.parser") definition_sids = [ @@ -584,7 +595,9 @@ def test_quota_definitions_list_sort_by_start_date( ] assert definition_sids == [definition1.sid, definition2.sid] - response = valid_user_client.get(f"{url}?sort_by=valid_between&ordered=desc") + response = client_with_current_workbasket.get( + f"{url}?sort_by=valid_between&ordered=desc", + ) assert response.status_code == 200 page = BeautifulSoup(response.content.decode(response.charset), "html.parser") definition_sids = [ @@ -697,7 +710,7 @@ def test_quota_detail_sub_quota_tab( def test_current_quota_order_number_returned( workbasket, - valid_user_client, + client_with_current_workbasket, mock_quota_api_no_data, date_ranges, ): @@ -713,7 +726,7 @@ def test_current_quota_order_number_returned( valid_between=date_ranges.normal, ) url = reverse("quota_definition-ui-list", kwargs={"sid": current_version.sid}) - response = valid_user_client.get(url) + response = client_with_current_workbasket.get(url) assert response.status_code == 200 @@ -1913,7 +1926,7 @@ def test_quota_blocking_confirm_create_view(valid_user_client): ) -def test_quota_definition_view(valid_user_client): +def test_quota_definition_view(client_with_current_workbasket): """Test all 4 of the quota definition tabs load and display the correct objects.""" main_quota_definition = factories.QuotaDefinitionFactory.create(sid=123) @@ -1933,7 +1946,7 @@ def test_quota_definition_view(valid_user_client): ) # Definition period tab - response = valid_user_client.get( + response = client_with_current_workbasket.get( reverse("quota_definition-ui-list", kwargs={"sid": main_quota.sid}), ) assert response.status_code == 200 @@ -1943,8 +1956,8 @@ def test_quota_definition_view(valid_user_client): )[0].text.strip() assert int(sid_cell_text) == main_quota_definition.sid - # Sub quotas tab - response = valid_user_client.get( + # Sub-quotas tab + response = client_with_current_workbasket.get( reverse( "quota_definition-ui-list-filter", kwargs={"sid": main_quota.sid, "quota_type": "sub_quotas"}, @@ -1956,7 +1969,7 @@ def test_quota_definition_view(valid_user_client): assert int(sid_cell_text) == sub_quota_definition.sid # Blocking periods tab - response = valid_user_client.get( + response = client_with_current_workbasket.get( reverse( "quota_definition-ui-list-filter", kwargs={"sid": main_quota.sid, "quota_type": "blocking_periods"}, @@ -1968,7 +1981,7 @@ def test_quota_definition_view(valid_user_client): assert description_cell_text == blocking.description # Suspension period tab - response = valid_user_client.get( + response = client_with_current_workbasket.get( reverse( "quota_definition-ui-list-filter", kwargs={"sid": main_quota.sid, "quota_type": "suspension_periods"}, @@ -2247,3 +2260,135 @@ def test_format_date(wizard): date_str = "2021-01-01" formatted_date = wizard.format_date(date_str) assert formatted_date == "01 Jan 2021" + + +@pytest.fixture +def sub_quota_association(date_ranges): + sub_quota = factories.QuotaDefinitionFactory.create( + valid_between=date_ranges.future, + is_physical=True, + initial_volume=1234, + volume=1234, + measurement_unit=factories.MeasurementUnitFactory(), + ) + main_quota = factories.QuotaDefinitionFactory.create( + valid_between=date_ranges.future, + volume=9999, + initial_volume=9999, + measurement_unit=sub_quota.measurement_unit, + ) + association = factories.QuotaAssociationFactory.create( + sub_quota=sub_quota, + main_quota=main_quota, + sub_quota_relation_type="EQ", + coefficient=1.5, + ) + return association + + +def test_sub_quota_update(sub_quota_association, client_with_current_workbasket): + """Test that SubQuotaDefinitionAssociationUpdate returns 200 and creates an + update object for sub-quota definition and association.""" + sub_quota = sub_quota_association.sub_quota + response = client_with_current_workbasket.get( + reverse("sub_quota_definition-edit", kwargs={"sid": sub_quota.sid}), + ) + assert response.status_code == 200 + + form_data = { + "coefficient": 1.2, + "start_date_0": sub_quota.valid_between.lower.day, + "start_date_1": sub_quota.valid_between.lower.month, + "start_date_2": sub_quota.valid_between.lower.year, + "measurement_unit": sub_quota.measurement_unit.pk, + "relationship_type": "EQ", + "end_date_0": sub_quota.valid_between.lower.day, + "end_date_1": sub_quota.valid_between.lower.month, + "end_date_2": sub_quota.valid_between.lower.year, + "volume": 100, + "initial_volume": 100, + } + response = client_with_current_workbasket.post( + reverse("sub_quota_definition-edit", kwargs={"sid": sub_quota.sid}), + form_data, + ) + assert response.status_code == 302 + assert response.url == reverse( + "sub_quota_definition-confirm-update", + kwargs={"sid": sub_quota.sid}, + ) + tx = Transaction.objects.last() + sub_quota_association = models.QuotaAssociation.objects.approved_up_to_transaction( + tx, + ).get(sub_quota__sid=sub_quota.sid) + assert str(sub_quota_association.coefficient) == "1.20000" + assert sub_quota_association.sub_quota.volume == 100 + assert sub_quota_association.update_type == UpdateType.UPDATE + assert sub_quota_association.sub_quota.update_type == UpdateType.UPDATE + + +def test_sub_quota_edit_update(sub_quota_association, client_with_current_workbasket): + """Test that SubQuotaDefinitionAssociationEditUpdate returns 200 and + overwrites the update objects for the sub-quota definition and + association.""" + # Call the previous test first to create the objects and some update instances of them + test_sub_quota_update(sub_quota_association, client_with_current_workbasket) + sub_quota = sub_quota_association.sub_quota + response = client_with_current_workbasket.get( + reverse("sub_quota_definition-edit-update", kwargs={"sid": sub_quota.sid}), + ) + assert response.status_code == 200 + + form_data = { + "coefficient": 1, + "start_date_0": sub_quota.valid_between.lower.day, + "start_date_1": sub_quota.valid_between.lower.month, + "start_date_2": sub_quota.valid_between.lower.year, + "measurement_unit": sub_quota.measurement_unit.pk, + "relationship_type": "NM", + "end_date_0": sub_quota.valid_between.lower.day, + "end_date_1": sub_quota.valid_between.lower.month, + "end_date_2": sub_quota.valid_between.lower.year, + "volume": 200, + "initial_volume": 200, + } + response = client_with_current_workbasket.post( + reverse("sub_quota_definition-edit-update", kwargs={"sid": sub_quota.sid}), + form_data, + ) + assert response.status_code == 302 + # Assert that the update instances have been edited rather than creating another 2 update instances + tx = Transaction.objects.last() + sub_quota_association = models.QuotaAssociation.objects.approved_up_to_transaction( + tx, + ).get(sub_quota__sid=sub_quota.sid) + assert str(sub_quota_association.coefficient) == "1.00000" + assert sub_quota_association.sub_quota.volume == 200 + sub_quota_definitions = models.QuotaDefinition.objects.all().filter( + sid=sub_quota.sid, + ) + sub_quota_associations = models.QuotaAssociation.objects.all().filter( + sub_quota__sid=sub_quota.sid, + ) + assert len(sub_quota_definitions) == 2 + assert len(sub_quota_associations) == 2 + assert sub_quota_definitions[1].update_type == UpdateType.UPDATE + assert sub_quota_associations[1].update_type == UpdateType.UPDATE + + +def test_sub_quota_confirm_update_page( + client_with_current_workbasket, + sub_quota_association, +): + sub_quota = sub_quota_association.sub_quota + response = client_with_current_workbasket.get( + reverse( + "sub_quota_definition-confirm-update", + kwargs={"sid": sub_quota.sid}, + ), + ) + workbasket = response.context_data["view"].workbasket + assert ( + f"Sub-quota definition: {sub_quota.sid} and association have been updated in workbasket {workbasket.pk}" + in str(response.content) + ) diff --git a/quotas/urls.py b/quotas/urls.py index 4b000f931..37ff0fbbc 100644 --- a/quotas/urls.py +++ b/quotas/urls.py @@ -95,6 +95,21 @@ views.QuotaDefinitionDuplicatorSuccess.as_view(), name="sub_quota_definitions-ui-success", ), + path( + f"quotas/sub_quotas_definition_update/", + views.SubQuotaDefinitionAssociationUpdate.as_view(), + name="sub_quota_definition-edit", + ), + path( + f"quotas/sub_quotas_definition_update-edit/", + views.SubQuotaDefinitionAssociationEditUpdate.as_view(), + name="sub_quota_definition-edit-update", + ), + path( + f"quotas/sub_quotas_definition_confirm-update/", + views.SubQuotaConfirmUpdate.as_view(), + name="sub_quota_definition-confirm-update", + ), path( f"quota_definitions//confirm-create/", views.QuotaDefinitionConfirmCreate.as_view(), diff --git a/quotas/views.py b/quotas/views.py index bb39106d4..15289a155 100644 --- a/quotas/views.py +++ b/quotas/views.py @@ -6,6 +6,7 @@ from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin from django.db import transaction +from django.http import HttpResponseRedirect from django.shortcuts import redirect from django.urls import reverse from django.utils.decorators import method_decorator @@ -13,7 +14,6 @@ from django.utils.safestring import mark_safe from django.views.generic import FormView from django.views.generic import TemplateView -from django.views.generic.edit import FormMixin from django.views.generic.list import ListView from formtools.wizard.views import NamedUrlSessionWizardView from rest_framework import permissions @@ -307,7 +307,11 @@ def suspension_periods(self): @property def sub_quotas(self): - return QuotaAssociation.objects.filter(main_quota__order_number=self.quota) + return ( + QuotaAssociation.objects.current() + .filter(main_quota__order_number=self.quota) + .order_by("sub_quota__sid") + ) @cached_property def quota_data(self): @@ -1128,3 +1132,159 @@ def get_context_data(self, *args, **kwargs): }, ) return context + + +class SubQuotaDefinitionAssociationMixin: + template_name = "quota-definitions/sub-quota-definitions-updates.jinja" + form_class = forms.SubQuotaDefinitionAssociationUpdateForm + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["sid"] = self.kwargs["sid"] + kwargs["request"] = self.request + return kwargs + + def dispatch(self, request, *args, **kwargs): + """ + Should a user land on the form for a definition which is not a sub- + quota, perform a redirect. + + This is not possible with current user journeys but this is included for + security and test purposes. + """ + try: + self.association + except models.QuotaAssociation.DoesNotExist: + return HttpResponseRedirect( + reverse( + "quota-ui-detail", + kwargs={"sid": self.sub_quota.order_number.sid}, + ), + ) + return super().dispatch(request, *args, **kwargs) + + def get_success_url(self): + return reverse( + "sub_quota_definition-confirm-update", + kwargs={"sid": self.kwargs["sid"]}, + ) + + @property + def last_transaction(self): + return self.workbasket.transactions.last() + + @property + def sub_quota(self): + return models.QuotaDefinition.objects.current().get(sid=self.kwargs["sid"]) + + @property + def association(self): + return models.QuotaAssociation.objects.current().get( + sub_quota__sid=self.sub_quota.sid, + ) + + def get_main_definition(self): + return self.association.main_quota + + +class SubQuotaDefinitionAssociationUpdate( + SubQuotaDefinitionAssociationMixin, + QuotaDefinitionUpdate, +): + + @transaction.atomic + def get_result_object(self, form): + self.original_association = self.association + instance = super().get_result_object(form) + + sub_quota_relation_type = form.cleaned_data.get("relationship_type") + coefficient = form.cleaned_data.get("coefficient") + + self.update_association(instance, sub_quota_relation_type, coefficient) + + return instance + + def update_association(self, instance, sub_quota_relation_type, coefficient): + "Update the association too if there is updated data submitted." + form_data = { + "main_quota": self.get_main_definition(), + "sub_quota": self.sub_quota, + "coefficient": coefficient, + "sub_quota_relation_type": sub_quota_relation_type, + } + + form = forms.QuotaAssociationEdit( + data=form_data, + instance=self.original_association, + ) + + form.instance.new_version( + workbasket=WorkBasket.current(self.request), + transaction=instance.transaction, + sub_quota=instance, + main_quota=self.get_main_definition(), + coefficient=coefficient, + sub_quota_relation_type=sub_quota_relation_type, + ) + + +class SubQuotaDefinitionAssociationEditUpdate( + SubQuotaDefinitionAssociationMixin, + QuotaDefinitionEditUpdate, +): + + @transaction.atomic + def get_result_object(self, form): + instance = super().get_result_object(form) + + sub_quota_relation_type = form.cleaned_data.get("relationship_type") + coefficient = form.cleaned_data.get("coefficient") + + self.update_association(instance, sub_quota_relation_type, coefficient) + + return instance + + def update_association(self, instance, sub_quota_relation_type, coefficient): + "Update the association too if there is updated data submitted." + current_instance = self.association.version_at(self.last_transaction) + form_data = { + "main_quota": self.get_main_definition(), + "sub_quota": instance, + "coefficient": coefficient, + "sub_quota_relation_type": sub_quota_relation_type, + } + + form = forms.QuotaAssociationEdit(data=form_data, instance=current_instance) + form.save() + + +class SubQuotaConfirmUpdate(TrackedModelDetailView): + model = models.QuotaDefinition + template_name = "quota-definitions/sub-quota-definitions-confirm-update.jinja" + + @property + def association(self): + return QuotaAssociation.objects.current().get(sub_quota__sid=self.kwargs["sid"]) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["association"] = self.association + return context + + def dispatch(self, request, *args, **kwargs): + """ + Should a user land on the this page for a definition which is not a sub- + quota, perform a redirect. + + This is not possible with current user journeys but this is included for + security and test purposes. + """ + try: + self.association + except models.QuotaAssociation.DoesNotExist: + return HttpResponseRedirect( + reverse( + "quota-ui-list", + ), + ) + return super().dispatch(request, *args, **kwargs) diff --git a/workbaskets/views/generic.py b/workbaskets/views/generic.py index 3cadc3acb..376b5c781 100644 --- a/workbaskets/views/generic.py +++ b/workbaskets/views/generic.py @@ -61,8 +61,9 @@ class EditTaricView( generic.UpdateView, ): """ - View used to change an existing model instance in the current workbasket - without creating a new version. The model instance may have an update_type. + View used to change an existing model instance that is in the current + workbasket without creating a new version. The model instance may have an + update_type. of either Create or Update - Delete is not an editable update type. """