From 95b34157b927753e27479c2e894e1edce71e2d34 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Sep 2024 10:18:44 +0100 Subject: [PATCH 1/9] Bump webpack from 5.91.0 to 5.94.0 (#1284) Bumps [webpack](https://github.com/webpack/webpack) from 5.91.0 to 5.94.0. - [Release notes](https://github.com/webpack/webpack/releases) - [Commits](https://github.com/webpack/webpack/compare/v5.91.0...v5.94.0) --- updated-dependencies: - dependency-name: webpack dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 39 ++++++++++++++++----------------------- package.json | 2 +- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index 945fe7e88..709cfc548 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "sass-loader": "^12.1.0", "style-loader": "^3.0.0", "styled-components": "^6.1.0", - "webpack": "^5.76.0", + "webpack": "^5.94.0", "webpack-bundle-tracker": "^3.0.1", "webpack-cli": "^4.7.2" }, @@ -3992,20 +3992,14 @@ "version": "8.56.6", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.6.tgz", "integrity": "sha512-ymwc+qb1XkjT/gfoQwxIeHZ6ixH23A+tCT2ADSA/DPVKzAjwYkTXBMCQ/f6fe4wEa85Lhp26VPeUxI7wMhAi7A==", + "dev": true, + "optional": true, + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -4590,10 +4584,10 @@ "acorn-walk": "^8.0.2" } }, - "node_modules/acorn-import-assertions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", - "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "peerDependencies": { "acorn": "^8" } @@ -6348,9 +6342,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.16.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.16.0.tgz", - "integrity": "sha512-O+QWCviPNSSLAD9Ucn8Awv+poAkqn3T1XY5/N7kR7rQO9yfSGWkYZDwpJ+iKF7B8rxaQKWngSqACpgzeapSyoA==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -14287,20 +14281,19 @@ } }, "node_modules/webpack": { - "version": "5.91.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.91.0.tgz", - "integrity": "sha512-rzVwlLeBWHJbmgTC/8TvAcu5vpJNII+MelQpylD4jNERPwpBJOE2lEcko1zJX3QJeLjTTAnQxn/OJ8bjDzVQaw==", + "version": "5.94.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", + "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dependencies": { - "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", "@webassemblyjs/wasm-edit": "^1.12.1", "@webassemblyjs/wasm-parser": "^1.12.1", "acorn": "^8.7.1", - "acorn-import-assertions": "^1.9.0", + "acorn-import-attributes": "^1.9.5", "browserslist": "^4.21.10", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.16.0", + "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", diff --git a/package.json b/package.json index 68fab2432..ae163f067 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "sass-loader": "^12.1.0", "style-loader": "^3.0.0", "styled-components": "^6.1.0", - "webpack": "^5.76.0", + "webpack": "^5.94.0", "webpack-bundle-tracker": "^3.0.1", "webpack-cli": "^4.7.2" }, From a9a75e11735c4501aec5d796c232f96fbd92b77e Mon Sep 17 00:00:00 2001 From: Charlie Prichard <46421052+CPrich905@users.noreply.github.com> Date: Thu, 12 Sep 2024 14:23:17 +0100 Subject: [PATCH 2/9] Tp2000 1371 implement new sub quotas journey (#1275) --- common/forms.py | 2 +- quotas/business_rules.py | 128 +++++-- quotas/forms.py | 347 ++++++++++++++++++ quotas/jinja2/includes/quotas/actions.jinja | 2 + .../sub-quota-definitions-done.jinja | 48 +++ ...definitions-select-definition-period.jinja | 59 +++ ...ota-definitions-select-order-numbers.jinja | 1 + .../sub-quota-definitions-selected.jinja | 71 ++++ .../sub-quota-definitions-updates.jinja | 29 ++ ...ub-quota-duplicate-definitions-start.jinja | 23 ++ ...sub-quota-duplicate-definitions-step.jinja | 24 ++ quotas/serializers.py | 45 +++ quotas/tests/test_business_rules.py | 115 +++++- quotas/tests/test_forms.py | 220 ++++++++++- quotas/tests/test_views.py | 257 ++++++++++++- quotas/urls.py | 26 ++ quotas/views.py | 261 ++++++++++++- quotas/wizard.py | 5 + .../jinja2/workbaskets/edit-workbasket.jinja | 1 + 19 files changed, 1614 insertions(+), 50 deletions(-) create mode 100644 quotas/jinja2/quota-definitions/sub-quota-definitions-done.jinja create mode 100644 quotas/jinja2/quota-definitions/sub-quota-definitions-select-definition-period.jinja create mode 100644 quotas/jinja2/quota-definitions/sub-quota-definitions-select-order-numbers.jinja create mode 100644 quotas/jinja2/quota-definitions/sub-quota-definitions-selected.jinja create mode 100644 quotas/jinja2/quota-definitions/sub-quota-definitions-updates.jinja create mode 100644 quotas/jinja2/quota-definitions/sub-quota-duplicate-definitions-start.jinja create mode 100644 quotas/jinja2/quota-definitions/sub-quota-duplicate-definitions-step.jinja create mode 100644 quotas/wizard.py diff --git a/common/forms.py b/common/forms.py index 4189ffe0a..6c5de5117 100644 --- a/common/forms.py +++ b/common/forms.py @@ -425,7 +425,7 @@ def __init__(self, *args, **kwargs): self.fields["end_date"].help_text = ( f"Leave empty if {get_model_indefinite_article(self.instance)} " - f"{self.instance._meta.verbose_name} is needed for an unlimited time." + f"{self.instance._meta.verbose_name} is needed for an unlimited time" ) if self.instance.valid_between: diff --git a/quotas/business_rules.py b/quotas/business_rules.py index f26fc27ff..37fcc8ce5 100644 --- a/quotas/business_rules.py +++ b/quotas/business_rules.py @@ -379,6 +379,13 @@ class QA2(ValidityPeriodContained): contained_field_name = "sub_quota" +def check_QA2_dict(sub_definition_valid_between, main_definition_valid_between): + """confirms data is compliant with QA2""" + return ( + sub_definition_valid_between.lower >= main_definition_valid_between.lower + ) and (sub_definition_valid_between.upper <= main_definition_valid_between.upper) + + class QA3(BusinessRule): """ When converted to the measurement unit of the main quota, the volume of a @@ -395,14 +402,36 @@ class QA3(BusinessRule): def validate(self, association): main = association.main_quota sub = association.sub_quota - if not ( - sub.measurement_unit == main.measurement_unit - and sub.volume <= main.volume - and sub.initial_volume <= main.initial_volume + if not check_QA3_dict( + main_definition_unit=main.measurement_unit, + sub_definition_unit=sub.measurement_unit, + main_definition_volume=main.volume, + sub_definition_volume=sub.volume, + main_initial_volume=main.initial_volume, + sub_initial_volume=sub.initial_volume, ): raise self.violation(association) +def check_QA3_dict( + main_definition_unit, + sub_definition_unit, + main_definition_volume, + sub_definition_volume, + sub_initial_volume, + main_initial_volume, +): + """ + Confirms data is compliant with QA3 + See note above about changing the unit types. + """ + return ( + main_definition_unit == sub_definition_unit + and sub_definition_volume <= main_definition_volume + and sub_initial_volume <= main_initial_volume + ) + + class QA4(BusinessRule): """ Whenever a sub-quota receives a coefficient, this has to be a strictly @@ -412,10 +441,14 @@ class QA4(BusinessRule): """ def validate(self, association): - if not association.coefficient > 0: + if not check_QA4_dict(association.coefficient): raise self.violation(association) +def check_QA4_dict(coefficient): + return coefficient > 0 + + class QA5(BusinessRule): """ Whenever a sub-quota is defined with the 'equivalent' type, it must have the @@ -427,7 +460,7 @@ class QA5(BusinessRule): def validate(self, association): if association.sub_quota_relation_type == SubQuotaType.EQUIVALENT: - if association.coefficient == Decimal("1.00000"): + if not check_QA5_equivalent_coefficient(association.coefficient): raise self.violation( model=association, message=( @@ -435,14 +468,7 @@ def validate(self, association): "coefficient not equal to 1" ), ) - - if ( - association.main_quota.sub_quotas.values("volume") - .order_by("volume") - .distinct("volume") - .count() - > 1 - ): + if not check_QA5_equivalent_volumes(association.main_quota): raise self.violation( model=association, message=( @@ -452,17 +478,33 @@ def validate(self, association): ), ) - elif ( - association.sub_quota_relation_type == SubQuotaType.NORMAL - and association.coefficient != Decimal("1.00000") - ): - raise self.violation( - model=association, - message=( - "A sub-quota defined with the 'normal' type must have a coefficient " - "equal to 1" - ), - ) + elif association.sub_quota_relation_type == SubQuotaType.NORMAL: + if not check_QA5_normal_coefficient(association.coefficient): + raise self.violation( + model=association, + message=( + "A sub-quota defined with the 'normal' type must have a coefficient " + "equal to 1" + ), + ) + + +def check_QA5_equivalent_coefficient(coefficient): + return coefficient != Decimal("1.000") + + +def check_QA5_equivalent_volumes(original_definition, volume=None): + return ( + original_definition.sub_quotas.values("volume") + .order_by("volume") + .distinct("volume") + .count() + == 1 + ) + + +def check_QA5_normal_coefficient(coefficient): + return Decimal(coefficient) == Decimal("1.000") class QA6(BusinessRule): @@ -470,21 +512,35 @@ class QA6(BusinessRule): relation type.""" def validate(self, association): - if ( - association.main_quota.sub_quota_associations.approved_up_to_transaction( - association.transaction, - ) - .values( - "sub_quota_relation_type", - ) - .order_by("sub_quota_relation_type") - .distinct() - .count() - > 1 + if not check_QA6_dict( + association.main_quota, + association.sub_quota_relation_type, + association.transaction, ): raise self.violation(association) +def check_QA6_dict(main_quota, new_relation_type, transaction=None): + """ + Confirms the provided data is compliant with the above businsess rule. + The above test will be re-run so as to separate historic violations, which will require TAP to fix, from a user trying to introduce a new violation. + Because the business rule should have been checked and there should only be one type, + we can check the new type against any one of the old type + """ + relation_type = ( + main_quota.sub_quota_associations.approved_up_to_transaction( + transaction, + ) + .values("sub_quota_relation_type") + .order_by("sub_quota_relation_type") + .distinct() + ) + if relation_type.count() > 1: + return False + elif relation_type.count() == 1: + return relation_type[0]["sub_quota_relation_type"] == new_relation_type + + class SameMainAndSubQuota(BusinessRule): """A quota association may only exist between two distinct quota definitions.""" diff --git a/quotas/forms.py b/quotas/forms.py index 7cdd0e062..58d3d412b 100644 --- a/quotas/forms.py +++ b/quotas/forms.py @@ -15,6 +15,8 @@ from django.template.loader import render_to_string from django.urls import reverse_lazy +from common.serializers import deserialize_date +from common.fields import AutoCompleteField from common.forms import BindNestedFormMixin from common.forms import FormSet from common.forms import FormSetField @@ -32,10 +34,15 @@ from measures.models import MeasurementUnit from quotas import models from quotas import validators +from quotas import business_rules from quotas.constants import QUOTA_EXCLUSIONS_FORMSET_PREFIX from quotas.constants import QUOTA_ORIGIN_EXCLUSIONS_FORMSET_PREFIX from quotas.constants import QUOTA_ORIGINS_FORMSET_PREFIX +from quotas.serializers import serialize_duplicate_data +from workbaskets.forms import SelectableObjectsForm +RELATIONSHIP_TYPE_HELP_TEXT = "Select the relationship type for the quota association" +COEFFICIENT_HELP_TEXT = "Select the coefficient for the quota association" CATEGORY_HELP_TEXT = "Categories are required for the TAP database but will not appear as a TARIC3 object in your workbasket" SAFEGUARD_HELP_TEXT = ( "Once the quota category has been set as ‘Safeguard’, this cannot be changed" @@ -1005,3 +1012,343 @@ def create_blocking_period(self, workbasket): update_type=UpdateType.CREATE, transaction=workbasket.new_transaction(), ) + + +class DuplicateQuotaDefinitionPeriodStartForm(forms.Form): + pass + + +class QuotaOrderNumbersSelectForm(forms.Form): + main_quota_order_number = AutoCompleteField( + label="Main quota order number", + queryset=models.QuotaOrderNumber.objects.all(), + required=True, + ) + sub_quota_order_number = AutoCompleteField( + label="Sub-quota order number", + queryset=models.QuotaOrderNumber.objects.all(), + required=True, + ) + + def __init__(self, *args, **kwargs): + self.request = kwargs.pop("request", None) + super().__init__(*args, **kwargs) + self.init_layout(self.request) + + def init_layout(self, request): + self.helper = FormHelper(self) + self.helper.label_size = Size.SMALL + self.helper.legend_size = Size.SMALL + + self.helper.layout = Layout( + Div( + HTML( + '

Enter main and sub-quota order numbers

' + ), + ), + Div( + "main_quota_order_number", + Div( + "sub_quota_order_number", + css_class="govuk-inset-text", + ), + ), + Submit( + "submit", + "Continue", + data_module="govuk-button", + data_prevent_double_click="true", + ), + ) + + +class SelectSubQuotaDefinitionsForm( + SelectableObjectsForm, +): + """ + Form to select the main quota definitions that are to be duplicated. + """ + + def __init__(self, *args, **kwargs): + self.request = kwargs.pop("request", None) + super().__init__(*args, **kwargs) + + def set_staged_definition_data(self, selected_definitions): + if ( + self.prefix in ["select_definition_periods"] + and self.request.path != "/quotas/duplicate_quota_definitions/complete" + ): + staged_definition_data = [] + for definition in selected_definitions: + staged_definition_data.append( + { + "main_definition": definition.pk, + "sub_definition_staged_data": serialize_duplicate_data( + definition + ), + } + ) + self.request.session["staged_definition_data"] = staged_definition_data + + def clean(self): + cleaned_data = super().clean() + selected_definitions = { + key: value for key, value in cleaned_data.items() if value + } + definitions_pks = [ + self.object_id_from_field_name(key) for key in selected_definitions + ] + if len(selected_definitions) < 1: + raise ValidationError("At least one quota definition must be selected") + selected_definitions = models.QuotaDefinition.objects.filter( + pk__in=definitions_pks + ).current() + cleaned_data["selected_definitions"] = selected_definitions + self.set_staged_definition_data(selected_definitions) + return cleaned_data + + +class SelectedDefinitionsForm(forms.Form): + def __init__(self, *args, **kwargs): + self.request = kwargs.pop("request") + super().__init__(*args, **kwargs) + + def clean(self): + cleaned_data = super().clean() + cleaned_data["staged_definitions"] = self.request.session[ + "staged_definition_data" + ] + for definition in cleaned_data["staged_definitions"]: + if not definition["sub_definition_staged_data"]["status"]: + raise ValidationError( + "Each definition period must have a specified relationship and co-efficient value" + ) + return cleaned_data + + +class SubQuotaDefinitionsUpdatesForm( + ValidityPeriodForm, +): + class Meta: + model = models.QuotaDefinition + fields = [ + "coefficient", + "relationship_type", + "volume", + "initial_volume", + "measurement_unit", + "valid_between", + ] + + relationship_type = forms.ChoiceField( + choices=[ + ("EQ", "Equivalent"), + ("NM", "Normal"), + ], + help_text=RELATIONSHIP_TYPE_HELP_TEXT, + error_messages={ + "required": "Choose the category", + }, + ) + + coefficient = forms.DecimalField( + label="Coefficient", + widget=forms.TextInput(), + help_text=COEFFICIENT_HELP_TEXT, + error_messages={ + "invalid": "Coefficient must be a number", + "required": "Enter the coefficient", + }, + ) + + initial_volume = forms.DecimalField( + label="Initial volume", + widget=forms.TextInput(), + error_messages={ + "invalid": "Initial volume must be a number", + "required": "Enter the initial volume", + }, + ) + volume = forms.DecimalField( + label="Volume", + widget=forms.TextInput(), + error_messages={ + "invalid": "Volume must be a number", + "required": "Enter the volume", + }, + ) + + measurement_unit = forms.ModelChoiceField( + queryset=MeasurementUnit.objects.current().order_by("code"), + error_messages={"required": "Select the measurement unit"}, + ) + + def get_duplicate_data(self, original_definition): + staged_definition_data = self.request.session["staged_definition_data"] + duplicate_data = list( + filter( + lambda staged_definition_data: staged_definition_data["main_definition"] + == original_definition.pk, + staged_definition_data, + ) + )[0]["sub_definition_staged_data"] + self.set_initial_data(duplicate_data) + return duplicate_data + + def set_initial_data(self, duplicate_data): + fields = self.fields + fields["relationship_type"].initial = "NM" + fields["coefficient"].initial = 1 + fields["measurement_unit"].initial = MeasurementUnit.objects.get( + code=duplicate_data["measurement_unit_code"] + ) + fields["initial_volume"].initial = duplicate_data["initial_volume"] + fields["volume"].initial = duplicate_data["volume"] + fields["start_date"].initial = deserialize_date(duplicate_data["start_date"]) + fields["end_date"].initial = deserialize_date(duplicate_data["end_date"]) + + def init_fields(self): + self.fields["measurement_unit"].label_from_instance = ( + lambda obj: f"{obj.code} - {obj.description}" + ) + + def __init__(self, *args, **kwargs): + self.request = kwargs.pop("request", None) + main_def_id = kwargs.pop("pk") + super().__init__(*args, **kwargs) + self.original_definition = models.QuotaDefinition.objects.get( + trackedmodel_ptr_id=main_def_id + ) + self.init_fields() + self.get_duplicate_data(self.original_definition) + self.init_layout(self.request) + + def clean(self): + cleaned_data = super().clean() + """ + Carrying out business rule checks here to prevent erroneous + associations, see: + https://uktrade.github.io/tariff-data-manual/documentation/data-structures/quota-associations.html#validation-rules + """ + original_definition = self.original_definition + if cleaned_data["valid_between"].upper is None: + raise ValidationError("An end date must be supplied") + + if not business_rules.check_QA2_dict( + sub_definition_valid_between=cleaned_data["valid_between"], + main_definition_valid_between=original_definition.valid_between, + ): + raise ValidationError( + "QA2: Validity period for sub quota must be within the " + "validity period of the main quota" + ) + + if not business_rules.check_QA3_dict( + main_definition_unit=self.original_definition.measurement_unit, + sub_definition_unit=cleaned_data["measurement_unit"], + main_definition_volume=original_definition.volume, + sub_definition_volume=cleaned_data["volume"], + main_initial_volume=original_definition.initial_volume, + sub_initial_volume=cleaned_data["initial_volume"], + ): + raise ValidationError( + "QA3: When converted to the measurement unit of the main " + "quota, the volume of a sub-quota must always be lower than " + "or equal to the volume of the main quota" + ) + + if not business_rules.check_QA4_dict(cleaned_data["coefficient"]): + raise ValidationError( + "QA4: A coefficient must be a positive decimal number" + ) + + if cleaned_data["relationship_type"] == "NM": + if not business_rules.check_QA5_normal_coefficient( + cleaned_data["coefficient"] + ): + raise ValidationError( + "QA5: Where the relationship type is Normal, the " + "coefficient value must be 1", + ) + elif cleaned_data["relationship_type"] == "EQ": + if not business_rules.check_QA5_equivalent_coefficient( + cleaned_data["coefficient"] + ): + raise ValidationError( + "QA5: Where the relationship type is Equivalent, the " + "coefficient value must be something other than 1" + ) + if not business_rules.check_QA5_equivalent_volumes( + self.original_definition, volume=cleaned_data["volume"] + ): + raise ValidationError( + "Whenever a sub-quota is defined with the 'equivalent' " + "type, it must have the same volume as the ones associated" + " with the parent quota" + ) + + if not business_rules.check_QA6_dict( + main_quota=original_definition, + new_relation_type=cleaned_data["relationship_type"], + ): + ValidationError( + "QA6: Sub-quotas associated with the same main quota must " + "have the same relation type." + ) + + return cleaned_data + + def init_layout(self, request): + self.helper = FormHelper(self) + self.helper.label_size = Size.SMALL + self.helper.legend_size = Size.SMALL + + self.helper.layout = Layout( + Div( + HTML( + '

Quota association details

', + ), + Div( + Div("relationship_type", css_class="govuk-grid-column-one-half"), + Div("coefficient", css_class="govuk-grid-column-one-half"), + css_class="govuk-grid-row", + ), + ), + HTML( + '
', + ), + Div( + HTML( + '

Sub-quota definition details

', + ), + Div( + Div( + "start_date", + css_class="govuk-grid-column-one-half", + ), + Div( + "end_date", + css_class="govuk-grid-column-one-half", + ), + Div( + "initial_volume", + "measurement_unit", + css_class="govuk-grid-column-one-half", + ), + Div( + "volume", + css_class="govuk-grid-column-one-half", + ), + css_class="govuk-grid-row", + ), + HTML( + '
', + ), + Submit( + "submit", + "Save and continue", + data_module="govuk-button", + data_prevent_double_click="true", + ), + ), + ) diff --git a/quotas/jinja2/includes/quotas/actions.jinja b/quotas/jinja2/includes/quotas/actions.jinja index 0bb0d0612..ac408b19e 100644 --- a/quotas/jinja2/includes/quotas/actions.jinja +++ b/quotas/jinja2/includes/quotas/actions.jinja @@ -5,6 +5,8 @@
  • View definition periods
  • Create definition period

  • +
  • Create quota associations
  • +
  • Create suspension or blocking period

  • View all measures
  • diff --git a/quotas/jinja2/quota-definitions/sub-quota-definitions-done.jinja b/quotas/jinja2/quota-definitions/sub-quota-definitions-done.jinja new file mode 100644 index 000000000..e828f1948 --- /dev/null +++ b/quotas/jinja2/quota-definitions/sub-quota-definitions-done.jinja @@ -0,0 +1,48 @@ +{% extends "layouts/layout.jinja" %} +{% from "components/panel/macro.njk" import govukPanel %} +{% from "components/button/macro.njk" import govukButton %} +{% set page_title = "Definitions created" %} + +{% macro panel_title(main_quota, sub_quota) %} + Definition periods for quota ID {{sub_quota }} have been created and associated with related definition periods from quota ID {{main_quota}} +{% endmacro %} + +{% block content %} + +
    +
    + {{ govukPanel({ + "titleText": panel_title(main_quota, sub_quota), + "classes": "govuk-!-margin-bottom-7" + }) }} +
    + {{ govukButton({ + "text": "Create new associations", + "href": url("sub_quota_definitions-ui-create"), + "classes": "govuk-button--primary" + }) }} + {{ govukButton({ + "text": "View association", + "href": definition_view_url, + "classes": "govuk-button--secondary" + }) }} +
    + Go to workbasket summary + +

    Next steps

    + +
    + {{ govukButton({ + "text": "Create measures", + "href": url('measure-ui-create', kwargs={"step": "start"}), + "classes": "govuk-button--primary" + }) }} + {{ govukButton({ + "text": "Homepage", + "href": url("home"), + "classes": "govuk-button--secondary" + }) }} +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/quotas/jinja2/quota-definitions/sub-quota-definitions-select-definition-period.jinja b/quotas/jinja2/quota-definitions/sub-quota-definitions-select-definition-period.jinja new file mode 100644 index 000000000..6f631cd4b --- /dev/null +++ b/quotas/jinja2/quota-definitions/sub-quota-definitions-select-definition-period.jinja @@ -0,0 +1,59 @@ +{% extends "quota-definitions/sub-quota-duplicate-definitions-step.jinja"%} +{% from "components/table/macro.njk" import govukTable %} +{% from "components/create_sortable_anchor.jinja" import create_sortable_anchor %} +{% from "macros/checkbox_item.jinja" import checkbox_item %} +{% from "components/breadcrumbs.jinja" import breadcrumbs %} + + +{% set main_quota_order_number = view.get_cleaned_data_for_step('quota_order_numbers')['main_quota_order_number']%} +{% block breadcrumb %} + {{ breadcrumbs(request, [ + {"text": "Quota "~ main_quota_order_number, "href": url("quota-ui-detail", args=[main_quota_order_number.sid]) }, + {"text": "Create associations"}, + {"text": page_title } + ]) + }} +{% endblock %} + +{% block form %} + + + {% set table_rows = [] %} + {% for field in form %} + {% set checkbox = checkbox_item(field)%} + {{ table_rows.append([ + {"html": checkbox }, + {"text": field.field.obj }, + {"text": "{:%d %b %Y}".format(field.field.obj.valid_between.lower) }, + {"text": "{:%d %b %Y}".format(field.field.obj.valid_between.upper) if field.field.obj.valid_between.upper else "-" }, + {"text": intcomma(field.field.obj.volume) }, + {"text": field.field.obj.measurement_unit.abbreviation}, + ]) or "" }} + {% endfor %} + + {% set checkbox_check_all -%} +
    + {%- endset %} + + {% set main_quota_order_number = view.get_cleaned_data_for_step('quota_order_numbers')['main_quota_order_number']%} + {% set sub_quota_order_number = view.get_cleaned_data_for_step('quota_order_numbers')['sub_quota_order_number']%} +

    Select definition periods from main quota ID {{main_quota_order_number}}

    +

    These definition periods are required for sub-quota ID {{sub_quota_order_number}} and must be edited on the next pages

    + {{ govukTable({ + "head": [ + {"html": checkbox_check_all}, + {"text": "Sid"}, + {"text": "Start date"}, + {"text": "End date"}, + {"text": "Volume"}, + {"text": "Measurement unit"}, + ], + "rows": table_rows, + }) }} + + {{ govukButton({ + "text": "Continue", + }) }} +{% endblock %} \ No newline at end of file diff --git a/quotas/jinja2/quota-definitions/sub-quota-definitions-select-order-numbers.jinja b/quotas/jinja2/quota-definitions/sub-quota-definitions-select-order-numbers.jinja new file mode 100644 index 000000000..20d54cbc8 --- /dev/null +++ b/quotas/jinja2/quota-definitions/sub-quota-definitions-select-order-numbers.jinja @@ -0,0 +1 @@ +{% extends "quota-definitions/sub-quota-duplicate-definitions-step.jinja"%} \ No newline at end of file diff --git a/quotas/jinja2/quota-definitions/sub-quota-definitions-selected.jinja b/quotas/jinja2/quota-definitions/sub-quota-definitions-selected.jinja new file mode 100644 index 000000000..d77646f46 --- /dev/null +++ b/quotas/jinja2/quota-definitions/sub-quota-definitions-selected.jinja @@ -0,0 +1,71 @@ +{% extends "quota-definitions/sub-quota-duplicate-definitions-step.jinja"%} +{% from "components/table/macro.njk" import govukTable %} + +{% block form %} + {% set table_rows = [] %} + {% set data = view.get_staged_definition_data() %} + + {% for definition in data %} + {% set edit_link -%} + Edit + {%- endset %} + {% set definition_status_cell %} + + {{ view.status_tag_generator(definition.sub_definition_staged_data).text | upper}} + + {% endset %} + {% set main_definition = view.get_main_definition(definition.main_definition)%} + + {{ table_rows.append([ + {"text": main_definition.sid}, + {"text": "{:%d %b %Y}".format(main_definition.valid_between.lower) }, + {"text": "{:%d %b %Y}".format(main_definition.valid_between.upper) }, + {"text": intcomma(main_definition.volume) }, + {"text": main_definition.measurement_unit.abbreviation }, + {"text": "-" }, + {"text": "-" }, + {"text": "-" }, + {"text": "-" }, + ]) or "" }} + {% set formatted_start_date = view.format_date(definition.sub_definition_staged_data.start_date) %} + {% set formatted_end_date = view.format_date(definition.sub_definition_staged_data.end_date) %} + {{ table_rows.append([ + {"text": "-"}, + {"text": formatted_start_date }, + {"text": formatted_end_date }, + {"text": intcomma(definition.sub_definition_staged_data.volume) }, + {"text": definition.sub_definition_staged_data.measurement_unit_abbreviation or main_definition.measurement_unit.abbreviation}, + {"text": definition.sub_definition_staged_data.relationship_type or "-" }, + {"text": definition.sub_definition_staged_data.coefficient or "-" }, + {"text": definition_status_cell }, + {"text": edit_link }, + ]) or "" }} + + {% endfor %} +

    You must enter a co-efficient value and specify the relationship type for each definition period.

    + {{ govukTable({ + "head": [ + {"text": "Sid"}, + {"text": "Start date"}, + {"text": "End date"}, + {"text": "Volume"}, + {"text": "Unit"}, + {"text": "Relationship type"}, + {"text": "Coefficient value"}, + {"text": "Status"}, + {"text": "Action"}, + ], + "rows": table_rows, + }) }} +

    Selecting 'Submit' will create the new definitions and create a quota association. Further edits to the definitions can be made via the Quota view

    +
    + {{ govukButton({ + "text": "Submit", + }) }} + {{ govukButton({ + "text": "Start again", + "classes": "govuk-button--secondary", + "href": url('sub_quota_definitions-ui-create', args={'step': 'start'}), + }) }} +
    +{% 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 new file mode 100644 index 000000000..cf4b6b6af --- /dev/null +++ b/quotas/jinja2/quota-definitions/sub-quota-definitions-updates.jinja @@ -0,0 +1,29 @@ +{% extends "layouts/form.jinja" %} +{% from "components/table/macro.njk" import govukTable %} + +{% block content %} +

    Main quota definition details

    + {% set main_definition = view.get_main_definition() %} + {% set table_rows = [] %} + {{ table_rows.append([ + {"html": main_definition.sid }, + {"html": "{:%d %b %Y}".format(main_definition.valid_between.lower) }, + {"html": "{:%d %b %Y}".format(main_definition.valid_between.upper) }, + {"html": intcomma(main_definition.volume) }, + {"html": main_definition.measurement_unit.abbreviation}, + ]) or ""}} + {{ govukTable({ + "head": [ + {"text": "SID"}, + {"text": "Start date"}, + {"text": "End date"}, + {"text": "Volume"}, + {"text": "Unit"}, + ], + "rows": table_rows + }) + }} + {% call django_form() %} + {{ crispy(form) }} + {% endcall %} +{% endblock %} diff --git a/quotas/jinja2/quota-definitions/sub-quota-duplicate-definitions-start.jinja b/quotas/jinja2/quota-definitions/sub-quota-duplicate-definitions-start.jinja new file mode 100644 index 000000000..fde772a87 --- /dev/null +++ b/quotas/jinja2/quota-definitions/sub-quota-duplicate-definitions-start.jinja @@ -0,0 +1,23 @@ +{% extends "quota-definitions/sub-quota-duplicate-definitions-step.jinja"%} + +{% set page_title = step_metadata[wizard.steps.current].title %} +{% block form %} +

    + Before you start, you'll need: +

    +

    +
    + {{ govukButton({"text": "Start now", "isStartButton": True}) }} + {{ govukButton({ + "text": "Back", + "href": url("workbaskets:edit-workbasket"), + "classes": "govuk-button--secondary" + }) }} +
    +{% endblock %} \ No newline at end of file diff --git a/quotas/jinja2/quota-definitions/sub-quota-duplicate-definitions-step.jinja b/quotas/jinja2/quota-definitions/sub-quota-duplicate-definitions-step.jinja new file mode 100644 index 000000000..019f9f857 --- /dev/null +++ b/quotas/jinja2/quota-definitions/sub-quota-duplicate-definitions-step.jinja @@ -0,0 +1,24 @@ +{% extends "layouts/form.jinja" %} +{% from "components/details/macro.njk" import govukDetails %} + +{% set page_title = step_metadata[wizard.steps.current].title %} + +{% block content %} +
    +
    + {{ page_subtitle|default("")}} +

    + {% block page_title_heading %} + {{ page_title }} + {% endblock %} +

    + {% if step_metadata[wizard.steps.current].info %} +

    {{ step_metadata[wizard.steps.current].info }}

    + {% endif %} + {% call django_form(action=view.get_step_url(wizard.steps.current)) %} + {{ wizard.management_form }} + {% block form %}{{ crispy(form) }}{% endblock %} + {% endcall %} +
    +
    +{% endblock %} \ No newline at end of file diff --git a/quotas/serializers.py b/quotas/serializers.py index 9f7702ee6..30cd33eb9 100644 --- a/quotas/serializers.py +++ b/quotas/serializers.py @@ -1,11 +1,17 @@ +from common.util import TaricDateRange from datetime import datetime +from decimal import Decimal from rest_framework import serializers from common.serializers import TrackedModelSerializer from common.serializers import TrackedModelSerializerMixin from common.serializers import ValiditySerializerMixin +from common.serializers import serialize_date, deserialize_date +from common.validators import UpdateType from geo_areas.serializers import GeographicalAreaSerializer +import measures +from measures.models.tracked_models import MeasurementUnit from measures.unit_serializers import MeasurementUnitQualifierSerializer from measures.unit_serializers import MeasurementUnitSerializer from measures.unit_serializers import MonetaryUnitSerializer @@ -262,3 +268,42 @@ class Meta: "subrecord_code", "taric_template", ] + + +def serialize_duplicate_data(selected_definition): + # returns a JSON dictionary of serialized definition data + duplicate_data = { + "initial_volume": str(selected_definition.initial_volume), + "volume": str(selected_definition.volume), + "measurement_unit_code": selected_definition.measurement_unit.code, + "measurement_unit_abbreviation": selected_definition.measurement_unit.abbreviation, + "start_date": serialize_date(selected_definition.valid_between.lower), + "end_date": serialize_date(selected_definition.valid_between.upper), + "status": False, + } + return duplicate_data + + +def deserialize_definition_data(self, definition): + start_date = deserialize_date(definition["start_date"]) + end_date = deserialize_date(definition["end_date"]) + initial_volume = Decimal(definition["initial_volume"]) + vol = Decimal(definition["volume"]) + measurement_unit = MeasurementUnit.objects.get( + code=definition["measurement_unit_code"] + ) + sub_order_number = self.get_cleaned_data_for_step(self.QUOTA_ORDER_NUMBERS)[ + "sub_quota_order_number" + ] + valid_between = TaricDateRange(start_date, end_date) + staged_data = { + "volume": initial_volume, + "initial_volume": vol, + "measurement_unit": measurement_unit, + "order_number": sub_order_number, + "valid_between": valid_between, + "update_type": UpdateType.CREATE, + "maximum_precision": 3, + "quota_critical_threshold": 90, + } + return staged_data diff --git a/quotas/tests/test_business_rules.py b/quotas/tests/test_business_rules.py index baf058ed5..281379e81 100644 --- a/quotas/tests/test_business_rules.py +++ b/quotas/tests/test_business_rules.py @@ -1,6 +1,8 @@ from collections import defaultdict +from datetime import date from decimal import Decimal +from django.forms import ValidationError import pytest from django.db import DataError @@ -8,6 +10,7 @@ from common.tests import factories from common.tests.util import only_applicable_after from common.tests.util import raises_if +from common.util import TaricDateRange from common.validators import UpdateType from geo_areas.validators import AreaCode from quotas import business_rules @@ -636,17 +639,59 @@ def test_QA2(date_ranges): @pytest.mark.parametrize( - "main_volume, main_unit, sub_volume, sub_unit, expect_error", + "sub_definition_valid_between, main_definition_valid_between, expected_response", [ - (1.0, "KGM", 1.0, "KGM", False), - (1.0, "KGM", 0.0, "KGM", False), - (2.0, "KGM", 1.0, "KGM", False), - (1.0, "KGM", 2.0, "KGM", True), - (1.0, "KGM", 1.0, "DTN", True), - (1.0, "DTN", 1.0, "DTN", False), + ( + TaricDateRange(date(2019, 1, 1), date(2020, 2, 2)), + TaricDateRange(date(2021, 1, 1), date(2021, 12, 1)), + False, + ), + ( + TaricDateRange(date(2021, 1, 1), date(2050, 2, 2)), + TaricDateRange(date(2021, 1, 1), date(2021, 12, 1)), + False, + ), + ( + TaricDateRange(date(2021, 1, 1), date(2021, 12, 1)), + TaricDateRange(date(2020, 1, 1), date(2023, 12, 1)), + True, + ), ], ) -def test_QA3(main_volume, main_unit, sub_volume, sub_unit, expect_error): +def test_QA2_dict( + sub_definition_valid_between, main_definition_valid_between, expected_response +): + """As above, but checking between a definition and a dict with date ranges""" + assert ( + business_rules.check_QA2_dict( + sub_definition_valid_between, main_definition_valid_between + ) + == expected_response + ) + + +@pytest.mark.parametrize( + "main_volume, main_unit, sub_volume, sub_unit, main_init_volume, sub_init_volume, expect_error", + [ + (1.0, "KGM", 1.0, "KGM", 1.0, 1.0, False), + (1.0, "KGM", 0.0, "KGM", 1.0, 1.0, False), + (2.0, "KGM", 1.0, "KGM", 1.0, 1.0, False), + (1.0, "KGM", 2.0, "KGM", 1.0, 1.0, True), + (1.0, "KGM", 1.0, "DTN", 1.0, 1.0, True), + (1.0, "DTN", 1.0, "DTN", 1.0, 1.0, False), + (1.0, "DTN", 1.0, "DTN", 1.0, 2.0, True), + (1.0, "DTN", 1.0, "DTN", 1.0, 1.0, False), + ], +) +def test_QA3( + main_volume, + main_unit, + sub_volume, + sub_unit, + main_init_volume, + sub_init_volume, + expect_error, +): """When converted to the measurement unit of the main quota, the volume of a sub-quota must always be lower than or equal to the volume of the main quota.""" @@ -655,15 +700,54 @@ def test_QA3(main_volume, main_unit, sub_volume, sub_unit, expect_error): assoc = factories.QuotaAssociationFactory( main_quota__volume=main_volume, + main_quota__initial_volume=main_init_volume, main_quota__measurement_unit=units[main_unit], sub_quota__volume=sub_volume, sub_quota__measurement_unit=units[sub_unit], + sub_quota__initial_volume=sub_init_volume, ) with raises_if(BusinessRuleViolation, expect_error): business_rules.QA3(assoc.transaction).validate(assoc) +@pytest.mark.parametrize( + "main_volume, main_unit, sub_volume, sub_unit, main_init_volume, sub_init_volume, expected_response", + [ + (1.0, "KGM", 1.0, "KGM", 1.0, 1.0, True), + (1.0, "KGM", 0.0, "KGM", 1.0, 1.0, True), + (2.0, "KGM", 1.0, "KGM", 1.0, 1.0, True), + (1.0, "KGM", 2.0, "KGM", 1.0, 1.0, False), + (1.0, "KGM", 1.0, "DTN", 1.0, 1.0, False), + (1.0, "DTN", 1.0, "DTN", 1.0, 1.0, True), + (1.0, "DTN", 1.0, "DTN", 1.0, 2.0, False), + (1.0, "DTN", 1.0, "DTN", 1.0, 1.0, True), + ], +) +def test_QA3_dict( + main_volume, + main_unit, + sub_volume, + sub_unit, + main_init_volume, + sub_init_volume, + expected_response, +): + """As above, but checking between a definition and a dict""" + + assert ( + business_rules.check_QA3_dict( + main_definition_volume=main_volume, + main_definition_unit=main_unit, + sub_definition_volume=sub_volume, + sub_definition_unit=sub_unit, + sub_initial_volume=sub_init_volume, + main_initial_volume=main_init_volume, + ) + == expected_response + ) + + @pytest.mark.parametrize( "coefficient, expect_error", [ @@ -692,6 +776,20 @@ def test_QA4(coefficient, expect_error): business_rules.QA4(assoc.transaction).validate(assoc) +@pytest.mark.parametrize( + "coefficient, expected_response", + [ + (1.0, True), + (2.0, True), + (0.0, False), + (-1.0, False), + ], +) +def test_QA4_dict(coefficient, expected_response): + """As above, but checking between a definition and a dict""" + assert business_rules.check_QA4_dict(coefficient) == expected_response + + @pytest.mark.parametrize( ("existing_volume", "new_volume", "coeff", "type", "error_expected"), ( @@ -709,6 +807,7 @@ def test_QA5(existing_volume, new_volume, coeff, type, error_expected): same volume as the other sub-quotas associated with the parent quota. Moreover it must be defined with a coefficient not equal to 1. + When a sub-quota relationship type is defined as 'equivalent' it must have the same volume as the ones associated with the parent quota A sub-quota defined with the 'normal' type must have a coefficient of 1. """ diff --git a/quotas/tests/test_forms.py b/quotas/tests/test_forms.py index e21b4ad03..3258e84e8 100644 --- a/quotas/tests/test_forms.py +++ b/quotas/tests/test_forms.py @@ -12,10 +12,12 @@ from common.validators import UpdateType from geo_areas.models import GeographicalArea from geo_areas.validators import AreaCode +from quotas import models from quotas import forms from quotas import validators -from quotas.models import QuotaBlocking +from quotas.models import QuotaBlocking, QuotaDefinition from quotas.models import QuotaSuspension +from quotas.serializers import serialize_duplicate_data pytestmark = pytest.mark.django_db @@ -426,3 +428,219 @@ def test_quota_suspension_or_blockling_create_form_save( assert object.valid_between == quota_definition.valid_between assert object.update_type == UpdateType.CREATE assert object.transaction.workbasket == workbasket + + +@pytest.fixture +def main_quota_order_number() -> models.QuotaOrderNumber: + """Provides a main quota order number for use across the fixtures and following tests""" + return factories.QuotaOrderNumberFactory() + + +@pytest.fixture +def quota_definition_1(main_quota_order_number, date_ranges) -> QuotaDefinition: + """Provides a definition, linked to the main_quota_order_number to be used across the following tests""" + return factories.QuotaDefinitionFactory.create( + order_number=main_quota_order_number, + valid_between=date_ranges.normal, + 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 +): + session_request.path = "" + form = forms.SelectSubQuotaDefinitionsForm( + request=session_request, prefix="select_definition_periods" + ) + quotas = models.QuotaDefinition.objects.all() + with override_current_transaction(Transaction.objects.last()): + form.set_staged_definition_data(quotas) + assert ( + session_request.session["staged_definition_data"][0]["main_definition"] + == quota_definition_1.pk + ) + + +""" +The following test the business rules checks against the provided data. +The checks are run in order, so subsequent tests require the data to +pass the previous rule check. More extensive testing is in test_business_rules.py +""" + + +def test_quota_duplicator_form_clean_QA2( + date_ranges, session_request, quota_definition_1 +): + staged_definition_data = [ + { + "main_definition": quota_definition_1.pk, + "sub_definition_staged_data": serialize_duplicate_data(quota_definition_1), + } + ] + session_request.session["staged_definition_data"] = staged_definition_data + + data = { + "start_date_0": date_ranges.earlier.lower.day, + "start_date_1": date_ranges.earlier.lower.month, + "start_date_2": date_ranges.earlier.lower.year, + "end_date_0": date_ranges.earlier.upper.day, + "end_date_1": date_ranges.earlier.upper.month, + "end_date_2": date_ranges.earlier.upper.year, + } + + with override_current_transaction(Transaction.objects.last()): + form = forms.SubQuotaDefinitionsUpdatesForm( + request=session_request, + data=data, + pk=quota_definition_1.pk, + ) + assert not form.is_valid() + assert ( + "QA2: Validity period for sub quota must be within the validity period of the main quota" + in form.errors["__all__"] + ) + + +def test_quota_duplicator_form_clean_QA3(session_request, quota_definition_1): + staged_definition_data = [ + { + "main_definition": quota_definition_1.pk, + "sub_definition_staged_data": serialize_duplicate_data(quota_definition_1), + } + ] + + data = { + "start_date_0": quota_definition_1.valid_between.lower.day, + "start_date_1": quota_definition_1.valid_between.lower.month, + "start_date_2": quota_definition_1.valid_between.lower.year, + "end_date_0": quota_definition_1.valid_between.upper.day, + "end_date_1": quota_definition_1.valid_between.upper.month, + "end_date_2": quota_definition_1.valid_between.upper.year, + "measurement_unit": quota_definition_1.measurement_unit, + "volume": 1235, + "initial_volume": 1235, + } + session_request.session["staged_definition_data"] = staged_definition_data + + with override_current_transaction(Transaction.objects.last()): + form = forms.SubQuotaDefinitionsUpdatesForm( + request=session_request, + data=data, + pk=quota_definition_1.pk, + ) + assert not form.is_valid() + assert ( + "QA3: When converted to the measurement unit of the main quota, the volume of a sub-quota must always be lower than or equal to the volume of the main quota" + in form.errors["__all__"] + ) + + +def test_quota_duplicator_form_clean_QA4(session_request, quota_definition_1): + staged_definition_data = [ + { + "main_definition": quota_definition_1.pk, + "sub_definition_staged_data": serialize_duplicate_data(quota_definition_1), + } + ] + + data = { + "start_date_0": quota_definition_1.valid_between.lower.day, + "start_date_1": quota_definition_1.valid_between.lower.month, + "start_date_2": quota_definition_1.valid_between.lower.year, + "end_date_0": quota_definition_1.valid_between.upper.day, + "end_date_1": quota_definition_1.valid_between.upper.month, + "end_date_2": quota_definition_1.valid_between.upper.year, + "measurement_unit": quota_definition_1.measurement_unit, + "volume": quota_definition_1.volume, + "initial_volume": quota_definition_1.initial_volume, + "coefficient": -1, + } + session_request.session["staged_definition_data"] = staged_definition_data + + with override_current_transaction(Transaction.objects.last()): + form = forms.SubQuotaDefinitionsUpdatesForm( + request=session_request, + data=data, + pk=quota_definition_1.pk, + ) + assert not form.is_valid() + assert ( + "QA4: A coefficient must be a positive decimal number" + in form.errors["__all__"] + ) + + +def test_quota_duplicator_form_clean_QA5_nm(session_request, quota_definition_1): + staged_definition_data = [ + { + "main_definition": quota_definition_1.pk, + "sub_definition_staged_data": serialize_duplicate_data(quota_definition_1), + } + ] + + data = { + "start_date_0": quota_definition_1.valid_between.lower.day, + "start_date_1": quota_definition_1.valid_between.lower.month, + "start_date_2": quota_definition_1.valid_between.lower.year, + "end_date_0": quota_definition_1.valid_between.upper.day, + "end_date_1": quota_definition_1.valid_between.upper.month, + "end_date_2": quota_definition_1.valid_between.upper.year, + "measurement_unit": quota_definition_1.measurement_unit, + "volume": quota_definition_1.volume, + "initial_volume": quota_definition_1.initial_volume, + "coefficient": 1.5, + "relationship_type": "NM", + } + session_request.session["staged_definition_data"] = staged_definition_data + + with override_current_transaction(Transaction.objects.last()): + form = forms.SubQuotaDefinitionsUpdatesForm( + request=session_request, + data=data, + pk=quota_definition_1.pk, + ) + assert not form.is_valid() + assert ( + "QA5: Where the relationship type is Normal, the coefficient value must be 1" + in form.errors["__all__"] + ) + + +def test_quota_duplicator_form_clean_QA5_eq(session_request, quota_definition_1): + staged_definition_data = [ + { + "main_definition": quota_definition_1.pk, + "sub_definition_staged_data": serialize_duplicate_data(quota_definition_1), + } + ] + + data = { + "start_date_0": quota_definition_1.valid_between.lower.day, + "start_date_1": quota_definition_1.valid_between.lower.month, + "start_date_2": quota_definition_1.valid_between.lower.year, + "end_date_0": quota_definition_1.valid_between.upper.day, + "end_date_1": quota_definition_1.valid_between.upper.month, + "end_date_2": quota_definition_1.valid_between.upper.year, + "measurement_unit": quota_definition_1.measurement_unit, + "volume": quota_definition_1.volume, + "initial_volume": quota_definition_1.initial_volume, + "coefficient": 1, + "relationship_type": "EQ", + } + session_request.session["staged_definition_data"] = staged_definition_data + + with override_current_transaction(Transaction.objects.last()): + form = forms.SubQuotaDefinitionsUpdatesForm( + request=session_request, + data=data, + pk=quota_definition_1.pk, + ) + 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__"] + ) diff --git a/quotas/tests/test_views.py b/quotas/tests/test_views.py index 799ec013b..af582a5eb 100644 --- a/quotas/tests/test_views.py +++ b/quotas/tests/test_views.py @@ -4,9 +4,11 @@ from bs4 import BeautifulSoup from django.contrib.humanize.templatetags.humanize import intcomma from django.urls import reverse +from typing import OrderedDict from common.models.transactions import Transaction from common.models.utils import override_current_transaction +from common.serializers import serialize_date from common.tariffs_api import Endpoints from common.tests import factories from common.tests.util import assert_model_view_renders @@ -21,7 +23,8 @@ from quotas import models from quotas import validators from quotas.forms import QuotaSuspensionType -from quotas.views import QuotaList +from quotas.views import DuplicateDefinitionsWizard, QuotaList +from quotas.wizard import QuotaDefinitionDuplicatorSessionStorage pytestmark = pytest.mark.django_db @@ -1688,3 +1691,255 @@ def test_quota_blocking_confirm_create_view(valid_user_client): assert f"Blocking period SID {blocking.sid} has been created" in str( response.content, ) + + +def test_definition_duplicator_form_wizard_start(client_with_current_workbasket): + url = reverse("sub_quota_definitions-ui-create", kwargs={"step": "start"}) + response = client_with_current_workbasket.get(url) + assert response.status_code == 200 + + +@pytest.fixture +def main_quota_order_number() -> models.QuotaOrderNumber: + """Provides a main quota order number for use across the fixtures and following tests""" + return factories.QuotaOrderNumberFactory() + + +@pytest.fixture +def sub_quota_order_number() -> models.QuotaOrderNumber: + """Provides a sub-quota order number for use across the fixtures and following tests""" + return factories.QuotaOrderNumberFactory() + + +@pytest.fixture +def quota_definition_1(main_quota_order_number, date_ranges) -> models.QuotaDefinition: + """Provides a definition, linked to the main_quota_order_number to be used across the following tests""" + return factories.QuotaDefinitionFactory.create( + order_number=main_quota_order_number, + valid_between=date_ranges.normal, + is_physical=True, + initial_volume=1234, + volume=1234, + measurement_unit=factories.MeasurementUnitFactory(), + ) + + +@pytest.fixture +def quota_definition_2(main_quota_order_number, date_ranges) -> models.QuotaDefinition: + """Provides a definition, linked to the main_quota_order_number to be used across the following tests""" + return factories.QuotaDefinitionFactory.create( + order_number=main_quota_order_number, + valid_between=date_ranges.normal, + ) + + +@pytest.fixture +def quota_definition_3(main_quota_order_number, date_ranges) -> models.QuotaDefinition: + """Provides a definition, linked to the main_quota_order_number to be used across the following tests""" + return factories.QuotaDefinitionFactory.create( + order_number=main_quota_order_number, + valid_between=date_ranges.normal, + ) + + +@pytest.fixture +def wizard(requests_mock, session_request): + """Provides an instance of the form wizard for use across the following tests""" + storage = QuotaDefinitionDuplicatorSessionStorage( + request=session_request, prefix="" + ) + return DuplicateDefinitionsWizard( + request=requests_mock, + storage=storage, + ) + + +def test_duplicate_definition_wizard_get_cleaned_data_for_step( + session_request, main_quota_order_number, sub_quota_order_number +): + + order_number_data = { + "duplicate_definitions_wizard-current_step": "quota_order_numbers", + "quota_order_numbers-main_quota_order_number": [main_quota_order_number.pk], + "quota_order_numbers-sub_quota_order_number": [sub_quota_order_number.pk], + } + storage = QuotaDefinitionDuplicatorSessionStorage( + request=session_request, prefix="" + ) + + storage.set_step_data("quota_order_numbers", order_number_data) + storage._set_current_step("quota_order_numbers") + wizard = DuplicateDefinitionsWizard( + request=session_request, + storage=storage, + initial_dict={"quota_order_numbers": {}}, + instance_dict={"quota_order_numbers": None}, + ) + wizard.form_list = OrderedDict(wizard.form_list) + cleaned_data = wizard.get_cleaned_data_for_step("quota_order_numbers") + + assert cleaned_data["main_quota_order_number"] == main_quota_order_number + assert cleaned_data["sub_quota_order_number"] == sub_quota_order_number + + +@pytest.mark.parametrize( + "step", + ["quota_order_numbers", "select_definition_periods", "selected_definition_periods"], +) +def test_duplicate_definition_wizard_get_form_kwargs( + quota_definition_1, + quota_definition_2, + quota_definition_3, + session_request, + main_quota_order_number, + sub_quota_order_number, + step, +): + + quota_order_numbers_data = { + "duplicate_definitions_wizard-current_step": "quota_order_numbers", + "quota_order_numbers-main_quota_order_number": [main_quota_order_number.pk], + "quota_order_numbers-sub_quota_order_number": [sub_quota_order_number.pk], + } + select_definitions_data = { + "duplicate_definitions_wizard-current_step": "select_definition_periods", + f"select_definition_periods-selectableobject_{quota_definition_1.pk}": ["on"], + f"select_definition_periods-selectableobject_{quota_definition_2.pk}": ["on"], + f"select_definition_periods-selectableobject_{quota_definition_3.pk}": [], + } + + storage = QuotaDefinitionDuplicatorSessionStorage( + request=session_request, prefix="" + ) + + storage.set_step_data("quota_order_numbers", quota_order_numbers_data) + storage.set_step_data("select_definition_periods", select_definitions_data) + storage._set_current_step(step) + + wizard = DuplicateDefinitionsWizard( + request=session_request, + storage=storage, + initial_dict={"selected_definitions": {}}, + instance_dict={"selected_definitions": None}, + ) + wizard.form_list = OrderedDict(wizard.form_list) + + with override_current_transaction(Transaction.objects.last()): + kwargs = wizard.get_form_kwargs(step) + if step == "select_definition_periods": + definitions = models.QuotaDefinition.objects.filter( + sid__in=[ + quota_definition_1.sid, + quota_definition_2.sid, + quota_definition_3.sid, + ] + ) + assert set(kwargs["objects"]) == set(definitions) + if step == "selected_definition_periods": + assert kwargs["request"].session + + +def test_definition_duplicator_creates_definition_and_association( + quota_definition_1, main_quota_order_number, sub_quota_order_number, session_request +): + """ + Pass data to the Duplicator Wizard and verify that the created definition + contains the expected data. + """ + staged_definition_data = [ + { + "main_definition": quota_definition_1.pk, + "sub_definition_staged_data": { + "initial_volume": str(quota_definition_1.initial_volume), + "volume": str(quota_definition_1.volume), + "measurement_unit_code": quota_definition_1.measurement_unit.code, + "start_date": serialize_date(quota_definition_1.valid_between.lower), + "end_date": serialize_date(quota_definition_1.valid_between.upper), + "status": True, + "coefficient": 1, + "relationship_type": "NM", + }, + } + ] + session_request.session["staged_definition_data"] = staged_definition_data + order_number_data = { + "duplicate_definitions_wizard-current_step": "quota_order_numbers", + "quota_order_numbers-main_quota_order_number": [main_quota_order_number.pk], + "quota_order_numbers-sub_quota_order_number": [sub_quota_order_number.pk], + } + storage = QuotaDefinitionDuplicatorSessionStorage( + request=session_request, prefix="" + ) + + storage.set_step_data("quota_order_numbers", order_number_data) + storage._set_current_step("quota_order_numbers") + wizard = DuplicateDefinitionsWizard( + request=session_request, + storage=storage, + initial_dict={"quota_order_numbers": {}}, + instance_dict={"quota_order_numbers": None}, + ) + wizard.form_list = OrderedDict(wizard.form_list) + + association_table_before = models.QuotaAssociation.objects.all() + assert len(association_table_before) == 0 + for definition in session_request.session["staged_definition_data"]: + wizard.create_definition(definition) + + definition_objects = models.QuotaDefinition.objects.all() + + # assert that the values of the definitions match + assert definition_objects[0].volume == definition_objects[1].volume + assert ( + definition_objects[0].measurement_unit == definition_objects[1].measurement_unit + ) + assert definition_objects[0].valid_between == definition_objects[1].valid_between + + assert len(definition_objects) == 2 + # assert that the association is created + association_table_after = models.QuotaAssociation.objects.all() + assert association_table_after[0].main_quota == quota_definition_1 + assert association_table_after[0].sub_quota in definition_objects + + +def test_status_tag_generator(quota_definition_1, quota_definition_2, wizard): + staged_definition_data = [ + { + "main_definition": quota_definition_1.pk, + "sub_definition_staged_data": { + "initial_volume": str(quota_definition_1.initial_volume), + "volume": str(quota_definition_1.volume), + "measurement_unit_code": quota_definition_1.measurement_unit.code, + "start_date": serialize_date(quota_definition_1.valid_between.lower), + "end_date": serialize_date(quota_definition_1.valid_between.upper), + "status": False, + "coefficient": 1, + "relationship_type": "NM", + }, + }, + { + "main_definition": quota_definition_2.pk, + "sub_definition_staged_data": { + "initial_volume": str(quota_definition_2.initial_volume), + "volume": str(quota_definition_2.volume), + "measurement_unit_code": quota_definition_2.measurement_unit.code, + "start_date": serialize_date(quota_definition_2.valid_between.lower), + "end_date": serialize_date(quota_definition_2.valid_between.upper), + "status": True, + "coefficient": 1, + "relationship_type": "NM", + }, + }, + ] + for definition in staged_definition_data: + status = wizard.status_tag_generator(definition["sub_definition_staged_data"]) + if definition["main_definition"] == quota_definition_1.pk: + assert status["text"] == "Unedited" + elif definition["main_definition"] == quota_definition_2.pk: + assert status["text"] == "Edited" + + +def test_format_date(wizard): + date_str = "2021-01-01" + formatted_date = wizard.format_date(date_str) + assert formatted_date == "01 Jan 2021" diff --git a/quotas/urls.py b/quotas/urls.py index aead7aaf8..7da0d9807 100644 --- a/quotas/urls.py +++ b/quotas/urls.py @@ -64,6 +64,32 @@ views.QuotaDefinitionCreate.as_view(), name="quota_definition-ui-create", ), + path( + f"quotas/duplicate_quota_definitions/", + views.DuplicateDefinitionsWizard.as_view( + url_name="sub_quota_definitions-ui-create", + done_step_name="complete", + ), + name="sub_quota_definitions-ui-create", + ), + path( + f"quotas/duplicate_quota_definitions/", + views.DuplicateDefinitionsWizard.as_view( + url_name="sub_quota_definitions-ui-create", + done_step_name="complete", + ), + name="sub_quota_definitions-ui-create", + ), + path( + f"quotas/sub_quota_definition_updates/", + views.QuotaDefinitionDuplicateUpdates.as_view(), + name="sub_quota_definitions-ui-updates", + ), + path( + f"quotas/sub_quota_definition_success", + views.QuotaDefinitionDuplicatorSuccess.as_view(), + name="sub_quota_definitions-ui-success", + ), path( f"quota_definitions//confirm-create/", views.QuotaDefinitionConfirmCreate.as_view(), diff --git a/quotas/views.py b/quotas/views.py index 2aa7ca73c..10aa0f951 100644 --- a/quotas/views.py +++ b/quotas/views.py @@ -1,9 +1,12 @@ +import datetime from datetime import date +from decimal import Decimal from urllib.parse import urlencode - from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin from django.db import transaction +from django.shortcuts import redirect +from django.views.generic import TemplateView from django.urls import reverse from django.utils.decorators import method_decorator from django.utils.functional import cached_property @@ -11,18 +14,19 @@ from django.views.generic import FormView 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 from rest_framework import viewsets from common.business_rules import UniqueIdentifyingFields from common.business_rules import UpdateValidity from common.forms import delete_form_for -from common.serializers import AutoCompleteSerializer +from common.serializers import AutoCompleteSerializer, serialize_date from common.tariffs_api import URLs from common.tariffs_api import get_quota_data from common.tariffs_api import get_quota_definitions_data from common.validators import UpdateType -from common.views import SortingMixin +from common.views import BusinessRulesMixin, SortingMixin from common.views import TamatoListView from common.views import TrackedModelDetailMixin from common.views import TrackedModelDetailView @@ -41,6 +45,8 @@ from quotas.models import QuotaAssociation from quotas.models import QuotaBlocking from quotas.models import QuotaSuspension +from quotas.serializers import deserialize_definition_data +from settings.common import DATE_FORMAT from workbaskets.models import WorkBasket from workbaskets.views.decorators import require_current_workbasket from workbaskets.views.generic import CreateTaricCreateView @@ -732,6 +738,255 @@ class QuotaDefinitionConfirmDelete( template_name = "quota-definitions/confirm-delete.jinja" +class DuplicateDefinitionsWizard( + PermissionRequiredMixin, + NamedUrlSessionWizardView, +): + """ + Multipart form wizard for duplicating QuotaDefinitionPeriods from a parent QuotaOrderNumber + to a child QuotaOrderNumber. + + https://django-formtools.readthedocs.io/en/latest/wizard.html + """ + + storage_name = "quotas.wizard.QuotaDefinitionDuplicatorSessionStorage" + permission_required = ["common.add_trackedmodel"] + + START = "start" + QUOTA_ORDER_NUMBERS = "quota_order_numbers" + SELECT_DEFINITION_PERIODS = "select_definition_periods" + SELECTED_DEFINITIONS = "selected_definition_periods" + COMPLETE = "complete" + + form_list = [ + (START, forms.DuplicateQuotaDefinitionPeriodStartForm), + (QUOTA_ORDER_NUMBERS, forms.QuotaOrderNumbersSelectForm), + (SELECT_DEFINITION_PERIODS, forms.SelectSubQuotaDefinitionsForm), + (SELECTED_DEFINITIONS, forms.SelectedDefinitionsForm), + ] + + templates = { + START: "quota-definitions/sub-quota-duplicate-definitions-start.jinja", + QUOTA_ORDER_NUMBERS: "quota-definitions/sub-quota-definitions-select-order-numbers.jinja", + SELECT_DEFINITION_PERIODS: "quota-definitions/sub-quota-definitions-select-definition-period.jinja", + SELECTED_DEFINITIONS: "quota-definitions/sub-quota-definitions-selected.jinja", + COMPLETE: "quota-definitions/sub-quota-definitions-done.jinja", + } + + step_metadata = { + START: { + "title": "Duplicate quota definitions", + "link_text": "Start", + }, + QUOTA_ORDER_NUMBERS: { + "title": "Create associations", + "link_text": "Order numbers", + }, + SELECT_DEFINITION_PERIODS: { + "title": "Select definition periods", + "link_text": "Definition periods", + }, + SELECTED_DEFINITIONS: { + "title": "Provide updates and details for duplicated definitions", + "link_text": "Selected definitions", + }, + COMPLETE: {"title": "Finished", "link_text": "Success"}, + } + + def get_context_data(self, form, **kwargs): + context = super().get_context_data(form=form, **kwargs) + context["step_metadata"] = self.step_metadata + return context + + def get_template_names(self): + template = self.templates.get( + self.steps.current, + "quota-definitions/sub-quota-duplicate-definitions-step.jinja", + ) + return template + + def get_cleaned_data_for_step(self, step): + """ + Returns cleaned data for a given `step`. + + Note: This patched version of `super().get_cleaned_data_for_step` temporarily saves the cleaned_data + to provide quick retrieval should another call for it be made in the same request (as happens in + `get_form_kwargs()`) to avoid revalidating forms unnecessarily. + """ + self.cleaned_data = getattr(self, "cleaned_data", {}) + if step in self.cleaned_data: + return self.cleaned_data[step] + + self.cleaned_data[step] = super().get_cleaned_data_for_step(step) + return self.cleaned_data[step] + + def format_date(self, date_str): + """Parses and converts a date string from that used for storing data + to the one used in the TAP UI.""" + if date_str: + date_object = datetime.datetime.strptime(date_str, "%Y-%m-%d").date() + return date_object.strftime(DATE_FORMAT) + return "" + + def get_staged_definition_data(self): + return self.request.session["staged_definition_data"] + + def get_main_definition(self, main_definition_pk): + return models.QuotaDefinition.objects.get(pk=main_definition_pk) + + def get_form_kwargs(self, step): + kwargs = {} + if step == self.SELECT_DEFINITION_PERIODS: + main_quota_order_number_sid = self.get_cleaned_data_for_step( + self.QUOTA_ORDER_NUMBERS + )["main_quota_order_number"].sid + main_quota_definitions = ( + models.QuotaDefinition.objects.filter( + order_number__sid=main_quota_order_number_sid, + ) + .current() + .order_by("pk") + ) + kwargs["request"] = self.request + kwargs["objects"] = main_quota_definitions + + elif step == self.SELECTED_DEFINITIONS: + kwargs["request"] = self.request + + return kwargs + + def status_tag_generator(self, definition) -> dict: + """ + Based on the status_tag_generator() for the Measure create Process + queue. + Returns a dict with text and a CSS class for a label for a duplicated + definition. + """ + if definition["status"]: + return { + "text": "Edited", + "tag_class": "tamato-badge-light-green", + } + else: + return { + "text": "Unedited", + "tag_class": "tamato-badge-light-blue", + } + + def done(self, form_list, **kwargs): + cleaned_data = self.get_all_cleaned_data() + + with transaction.atomic(): + for definition in cleaned_data["staged_definitions"]: + self.create_definition(definition) + sub_quota_view_url = reverse( + "quota_definition-ui-list", + kwargs={"sid": cleaned_data["main_quota_order_number"].sid}, + ) + sub_quota_view_query_string = "quota_type=sub_quotas&submit=" + self.request.session["success_data"] = { + "main_quota": cleaned_data["main_quota_order_number"].order_number, + "sub_quota": cleaned_data["sub_quota_order_number"].order_number, + "definition_view_url": ( + f"{sub_quota_view_url}?{sub_quota_view_query_string}" + ), + } + + return redirect("sub_quota_definitions-ui-success") + + def create_definition(self, definition): + staged_data = deserialize_definition_data( + self, definition["sub_definition_staged_data"] + ) + instance = models.QuotaDefinition.objects.create( + **staged_data, transaction=WorkBasket.get_current_transaction(self.request) + ) + association_data = { + "main_quota": models.QuotaDefinition.objects.get( + pk=definition["main_definition"] + ), + "sub_quota": instance, + "coefficient": Decimal( + definition["sub_definition_staged_data"]["coefficient"] + ), + "sub_quota_relation_type": definition["sub_definition_staged_data"][ + "relationship_type" + ], + "update_type": UpdateType.CREATE, + } + self.create_definition_association(association_data) + + def create_definition_association(self, association_data): + models.QuotaAssociation.objects.create( + **association_data, + transaction=WorkBasket.get_current_transaction(self.request), + ) + + +class QuotaDefinitionDuplicateUpdates( + FormView, + BusinessRulesMixin, +): + """UI endpoint for any updates to duplicated definitions""" + + template_name = "quota-definitions/sub-quota-definitions-updates.jinja" + form_class = forms.SubQuotaDefinitionsUpdatesForm + permission_required = "common.add_trackedmodel" + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["pk"] = self.kwargs["pk"] + kwargs["request"] = self.request + return kwargs + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + context["page_title"] = "Update definition and association details" + context["quota_order_number"] = self.kwargs["pk"] + return context + + def get_main_definition(self): + return models.QuotaDefinition.objects.current().get( + trackedmodel_ptr_id=self.kwargs["pk"] + ) + + def form_valid(self, form): + main_definition = self.get_main_definition() + cleaned_data = form.cleaned_data + updated_serialized_data = { + "initial_volume": str(cleaned_data["initial_volume"]), + "volume": str(cleaned_data["volume"]), + "measurement_unit_code": cleaned_data["measurement_unit"].code, + "start_date": serialize_date(cleaned_data["valid_between"].lower), + "end_date": serialize_date(cleaned_data["valid_between"].upper), + "status": True, + "coefficient": str(cleaned_data["coefficient"]), + "relationship_type": cleaned_data["relationship_type"], + } + staged_definition_data = self.request.session["staged_definition_data"] + list( + filter( + lambda staged_definition_data: staged_definition_data["main_definition"] + == main_definition.pk, + staged_definition_data, + ) + )[0]["sub_definition_staged_data"] = updated_serialized_data + + return redirect(reverse("sub_quota_definitions-ui-create")) + + +class QuotaDefinitionDuplicatorSuccess(TemplateView): + template_name = "quota-definitions/sub-quota-definitions-done.jinja" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + success_data = self.request.session["success_data"] + context["main_quota"] = success_data["main_quota"] + context["sub_quota"] = success_data["sub_quota"] + context["definition_view_url"] = success_data["definition_view_url"] + return context + + @method_decorator(require_current_workbasket, name="dispatch") class QuotaSuspensionOrBlockingCreate( PermissionRequiredMixin, diff --git a/quotas/wizard.py b/quotas/wizard.py new file mode 100644 index 000000000..2da160c4a --- /dev/null +++ b/quotas/wizard.py @@ -0,0 +1,5 @@ +from formtools.wizard.storage.session import SessionStorage + + +class QuotaDefinitionDuplicatorSessionStorage(SessionStorage): + pass diff --git a/workbaskets/jinja2/workbaskets/edit-workbasket.jinja b/workbaskets/jinja2/workbaskets/edit-workbasket.jinja index d3e4965a0..a6776cd48 100644 --- a/workbaskets/jinja2/workbaskets/edit-workbasket.jinja +++ b/workbaskets/jinja2/workbaskets/edit-workbasket.jinja @@ -78,6 +78,7 @@ {{ workbasket_column("Quotas", [ {"text": "Create a new quota", "url": url('quota-ui-create')}, {"text": "Find and edit quotas", "url": url('quota-ui-list')}, + {"text": "Create quota association", "url": url('sub_quota_definitions-ui-create')}, ]) }} {{ workbasket_column("Regulations", [ From bc5a576170b7d6589c57409901e66c8fcfec6ef6 Mon Sep 17 00:00:00 2001 From: Charlie Prichard <46421052+CPrich905@users.noreply.github.com> Date: Fri, 13 Sep 2024 16:04:06 +0100 Subject: [PATCH 3/9] Bugfix-QuotaDefinition duplicator transaction (#1291) * formatting from pre-commit hooks missed on previous PR * formatting * cleaning tests file * Workbasket management in view and test. * re-adding import --------- Co-authored-by: Paul Pepper --- quotas/tests/test_views.py | 64 ++++++++++++++++++++++++-------------- quotas/views.py | 62 +++++++++++++++++++----------------- 2 files changed, 75 insertions(+), 51 deletions(-) diff --git a/quotas/tests/test_views.py b/quotas/tests/test_views.py index af582a5eb..bbc333633 100644 --- a/quotas/tests/test_views.py +++ b/quotas/tests/test_views.py @@ -1,10 +1,10 @@ +from typing import OrderedDict from unittest import mock import pytest from bs4 import BeautifulSoup from django.contrib.humanize.templatetags.humanize import intcomma from django.urls import reverse -from typing import OrderedDict from common.models.transactions import Transaction from common.models.utils import override_current_transaction @@ -23,7 +23,8 @@ from quotas import models from quotas import validators from quotas.forms import QuotaSuspensionType -from quotas.views import DuplicateDefinitionsWizard, QuotaList +from quotas.views import DuplicateDefinitionsWizard +from quotas.views import QuotaList from quotas.wizard import QuotaDefinitionDuplicatorSessionStorage pytestmark = pytest.mark.django_db @@ -1701,19 +1702,22 @@ def test_definition_duplicator_form_wizard_start(client_with_current_workbasket) @pytest.fixture def main_quota_order_number() -> models.QuotaOrderNumber: - """Provides a main quota order number for use across the fixtures and following tests""" + """Provides a main quota order number for use across the fixtures and + following tests.""" return factories.QuotaOrderNumberFactory() @pytest.fixture def sub_quota_order_number() -> models.QuotaOrderNumber: - """Provides a sub-quota order number for use across the fixtures and following tests""" + """Provides a sub-quota order number for use across the fixtures and + following tests.""" return factories.QuotaOrderNumberFactory() @pytest.fixture def quota_definition_1(main_quota_order_number, date_ranges) -> models.QuotaDefinition: - """Provides a definition, linked to the main_quota_order_number to be used across the following tests""" + """Provides a definition, linked to the main_quota_order_number to be used + across the following tests.""" return factories.QuotaDefinitionFactory.create( order_number=main_quota_order_number, valid_between=date_ranges.normal, @@ -1726,7 +1730,8 @@ def quota_definition_1(main_quota_order_number, date_ranges) -> models.QuotaDefi @pytest.fixture def quota_definition_2(main_quota_order_number, date_ranges) -> models.QuotaDefinition: - """Provides a definition, linked to the main_quota_order_number to be used across the following tests""" + """Provides a definition, linked to the main_quota_order_number to be used + across the following tests.""" return factories.QuotaDefinitionFactory.create( order_number=main_quota_order_number, valid_between=date_ranges.normal, @@ -1735,7 +1740,8 @@ def quota_definition_2(main_quota_order_number, date_ranges) -> models.QuotaDefi @pytest.fixture def quota_definition_3(main_quota_order_number, date_ranges) -> models.QuotaDefinition: - """Provides a definition, linked to the main_quota_order_number to be used across the following tests""" + """Provides a definition, linked to the main_quota_order_number to be used + across the following tests.""" return factories.QuotaDefinitionFactory.create( order_number=main_quota_order_number, valid_between=date_ranges.normal, @@ -1744,9 +1750,11 @@ def quota_definition_3(main_quota_order_number, date_ranges) -> models.QuotaDefi @pytest.fixture def wizard(requests_mock, session_request): - """Provides an instance of the form wizard for use across the following tests""" + """Provides an instance of the form wizard for use across the following + tests.""" storage = QuotaDefinitionDuplicatorSessionStorage( - request=session_request, prefix="" + request=session_request, + prefix="", ) return DuplicateDefinitionsWizard( request=requests_mock, @@ -1755,7 +1763,9 @@ def wizard(requests_mock, session_request): def test_duplicate_definition_wizard_get_cleaned_data_for_step( - session_request, main_quota_order_number, sub_quota_order_number + session_request, + main_quota_order_number, + sub_quota_order_number, ): order_number_data = { @@ -1764,7 +1774,8 @@ def test_duplicate_definition_wizard_get_cleaned_data_for_step( "quota_order_numbers-sub_quota_order_number": [sub_quota_order_number.pk], } storage = QuotaDefinitionDuplicatorSessionStorage( - request=session_request, prefix="" + request=session_request, + prefix="", ) storage.set_step_data("quota_order_numbers", order_number_data) @@ -1809,7 +1820,8 @@ def test_duplicate_definition_wizard_get_form_kwargs( } storage = QuotaDefinitionDuplicatorSessionStorage( - request=session_request, prefix="" + request=session_request, + prefix="", ) storage.set_step_data("quota_order_numbers", quota_order_numbers_data) @@ -1832,7 +1844,7 @@ def test_duplicate_definition_wizard_get_form_kwargs( quota_definition_1.sid, quota_definition_2.sid, quota_definition_3.sid, - ] + ], ) assert set(kwargs["objects"]) == set(definitions) if step == "selected_definition_periods": @@ -1840,12 +1852,14 @@ def test_duplicate_definition_wizard_get_form_kwargs( def test_definition_duplicator_creates_definition_and_association( - quota_definition_1, main_quota_order_number, sub_quota_order_number, session_request + quota_definition_1, + main_quota_order_number, + sub_quota_order_number, + session_request_with_workbasket, ): - """ - Pass data to the Duplicator Wizard and verify that the created definition - contains the expected data. - """ + """Pass data to the Duplicator Wizard and verify that the created definition + contains the expected data.""" + staged_definition_data = [ { "main_definition": quota_definition_1.pk, @@ -1859,22 +1873,25 @@ def test_definition_duplicator_creates_definition_and_association( "coefficient": 1, "relationship_type": "NM", }, - } + }, ] - session_request.session["staged_definition_data"] = staged_definition_data + session_request_with_workbasket.session["staged_definition_data"] = ( + staged_definition_data + ) order_number_data = { "duplicate_definitions_wizard-current_step": "quota_order_numbers", "quota_order_numbers-main_quota_order_number": [main_quota_order_number.pk], "quota_order_numbers-sub_quota_order_number": [sub_quota_order_number.pk], } storage = QuotaDefinitionDuplicatorSessionStorage( - request=session_request, prefix="" + request=session_request_with_workbasket, + prefix="", ) storage.set_step_data("quota_order_numbers", order_number_data) storage._set_current_step("quota_order_numbers") wizard = DuplicateDefinitionsWizard( - request=session_request, + request=session_request_with_workbasket, storage=storage, initial_dict={"quota_order_numbers": {}}, instance_dict={"quota_order_numbers": None}, @@ -1882,8 +1899,9 @@ def test_definition_duplicator_creates_definition_and_association( wizard.form_list = OrderedDict(wizard.form_list) association_table_before = models.QuotaAssociation.objects.all() + # assert 0 assert len(association_table_before) == 0 - for definition in session_request.session["staged_definition_data"]: + for definition in session_request_with_workbasket.session["staged_definition_data"]: wizard.create_definition(definition) definition_objects = models.QuotaDefinition.objects.all() diff --git a/quotas/views.py b/quotas/views.py index 10aa0f951..d65b640c0 100644 --- a/quotas/views.py +++ b/quotas/views.py @@ -2,16 +2,17 @@ from datetime import date from decimal import Decimal from urllib.parse import urlencode + from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin from django.db import transaction from django.shortcuts import redirect -from django.views.generic import TemplateView from django.urls import reverse from django.utils.decorators import method_decorator from django.utils.functional import cached_property 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 @@ -21,12 +22,14 @@ from common.business_rules import UniqueIdentifyingFields from common.business_rules import UpdateValidity from common.forms import delete_form_for -from common.serializers import AutoCompleteSerializer, serialize_date +from common.serializers import AutoCompleteSerializer +from common.serializers import serialize_date from common.tariffs_api import URLs from common.tariffs_api import get_quota_data from common.tariffs_api import get_quota_definitions_data from common.validators import UpdateType -from common.views import BusinessRulesMixin, SortingMixin +from common.views import BusinessRulesMixin +from common.views import SortingMixin from common.views import TamatoListView from common.views import TrackedModelDetailMixin from common.views import TrackedModelDetailView @@ -738,13 +741,14 @@ class QuotaDefinitionConfirmDelete( template_name = "quota-definitions/confirm-delete.jinja" +@method_decorator(require_current_workbasket, name="dispatch") class DuplicateDefinitionsWizard( PermissionRequiredMixin, NamedUrlSessionWizardView, ): """ - Multipart form wizard for duplicating QuotaDefinitionPeriods from a parent QuotaOrderNumber - to a child QuotaOrderNumber. + Multipart form wizard for duplicating QuotaDefinitionPeriods from a parent + QuotaOrderNumber to a child QuotaOrderNumber. https://django-formtools.readthedocs.io/en/latest/wizard.html """ @@ -793,6 +797,10 @@ class DuplicateDefinitionsWizard( COMPLETE: {"title": "Finished", "link_text": "Success"}, } + @property + def workbasket(self) -> WorkBasket: + return WorkBasket.current(self.request) + def get_context_data(self, form, **kwargs): context = super().get_context_data(form=form, **kwargs) context["step_metadata"] = self.step_metadata @@ -821,8 +829,8 @@ def get_cleaned_data_for_step(self, step): return self.cleaned_data[step] def format_date(self, date_str): - """Parses and converts a date string from that used for storing data - to the one used in the TAP UI.""" + """Parses and converts a date string from that used for storing data to + the one used in the TAP UI.""" if date_str: date_object = datetime.datetime.strptime(date_str, "%Y-%m-%d").date() return date_object.strftime(DATE_FORMAT) @@ -838,7 +846,7 @@ def get_form_kwargs(self, step): kwargs = {} if step == self.SELECT_DEFINITION_PERIODS: main_quota_order_number_sid = self.get_cleaned_data_for_step( - self.QUOTA_ORDER_NUMBERS + self.QUOTA_ORDER_NUMBERS, )["main_quota_order_number"].sid main_quota_definitions = ( models.QuotaDefinition.objects.filter( @@ -859,6 +867,7 @@ def status_tag_generator(self, definition) -> dict: """ Based on the status_tag_generator() for the Measure create Process queue. + Returns a dict with text and a CSS class for a label for a duplicated definition. """ @@ -896,30 +905,27 @@ def done(self, form_list, **kwargs): def create_definition(self, definition): staged_data = deserialize_definition_data( - self, definition["sub_definition_staged_data"] + self, + definition["sub_definition_staged_data"], ) + transaction = self.workbasket.new_transaction() instance = models.QuotaDefinition.objects.create( - **staged_data, transaction=WorkBasket.get_current_transaction(self.request) + **staged_data, + transaction=transaction, ) - association_data = { - "main_quota": models.QuotaDefinition.objects.get( - pk=definition["main_definition"] + models.QuotaAssociation.objects.create( + main_quota=models.QuotaDefinition.objects.get( + pk=definition["main_definition"], ), - "sub_quota": instance, - "coefficient": Decimal( - definition["sub_definition_staged_data"]["coefficient"] + sub_quota=instance, + coefficient=Decimal( + definition["sub_definition_staged_data"]["coefficient"], ), - "sub_quota_relation_type": definition["sub_definition_staged_data"][ + sub_quota_relation_type=definition["sub_definition_staged_data"][ "relationship_type" ], - "update_type": UpdateType.CREATE, - } - self.create_definition_association(association_data) - - def create_definition_association(self, association_data): - models.QuotaAssociation.objects.create( - **association_data, - transaction=WorkBasket.get_current_transaction(self.request), + update_type=UpdateType.CREATE, + transaction=transaction, ) @@ -927,7 +933,7 @@ class QuotaDefinitionDuplicateUpdates( FormView, BusinessRulesMixin, ): - """UI endpoint for any updates to duplicated definitions""" + """UI endpoint for any updates to duplicated definitions.""" template_name = "quota-definitions/sub-quota-definitions-updates.jinja" form_class = forms.SubQuotaDefinitionsUpdatesForm @@ -947,7 +953,7 @@ def get_context_data(self, *args, **kwargs): def get_main_definition(self): return models.QuotaDefinition.objects.current().get( - trackedmodel_ptr_id=self.kwargs["pk"] + trackedmodel_ptr_id=self.kwargs["pk"], ) def form_valid(self, form): @@ -969,7 +975,7 @@ def form_valid(self, form): lambda staged_definition_data: staged_definition_data["main_definition"] == main_definition.pk, staged_definition_data, - ) + ), )[0]["sub_definition_staged_data"] = updated_serialized_data return redirect(reverse("sub_quota_definitions-ui-create")) From fe7df62dcd9675bb5050a28fd5e1b30c2fa05fe5 Mon Sep 17 00:00:00 2001 From: Matthew McKenzie <97194636+mattjamc@users.noreply.github.com> Date: Mon, 16 Sep 2024 12:18:44 +0100 Subject: [PATCH 4/9] TP2000-1474 Amend view definition periods (#1283) * Replace radio buttons for tabs * Add test and match figma design * Content updates * Make sub-quotas heading consistent * black --- quotas/forms.py | 32 --------- quotas/jinja2/includes/quotas/actions.jinja | 2 +- quotas/jinja2/quotas/definitions.jinja | 45 +++++++++---- quotas/jinja2/quotas/tables/definitions.jinja | 2 +- quotas/jinja2/quotas/tables/sub_quotas.jinja | 2 +- quotas/tests/test_views.py | 67 +++++++++++++++++++ quotas/urls.py | 5 ++ quotas/views.py | 26 +------ 8 files changed, 109 insertions(+), 72 deletions(-) diff --git a/quotas/forms.py b/quotas/forms.py index 58d3d412b..32b258a6f 100644 --- a/quotas/forms.py +++ b/quotas/forms.py @@ -73,38 +73,6 @@ def __init__(self, *args, **kwargs): QuotaDeleteForm = delete_form_for(models.QuotaOrderNumber) -class QuotaDefinitionFilterForm(forms.Form): - quota_type = forms.MultipleChoiceField( - label="View by", - choices=[ - ("sub_quotas", "Sub-quotas"), - ("blocking_periods", "Blocking periods"), - ("suspension_periods", "Suspension periods"), - ], - widget=forms.RadioSelect(), - ) - - def __init__(self, *args, **kwargs): - quota_type_initial = kwargs.pop("form_initial") - object_sid = kwargs.pop("object_sid") - super().__init__(*args, **kwargs) - self.fields["quota_type"].initial = quota_type_initial - self.helper = FormHelper() - - clear_url = reverse_lazy( - "quota_definition-ui-list", - kwargs={"sid": object_sid}, - ) - - self.helper.layout = Layout( - Field.radios("quota_type", label_size=Size.SMALL), - Button("submit", "Apply"), - HTML( - f'Restore defaults', - ), - ) - - class QuotaOriginExclusionsForm(forms.Form): exclusion = forms.ModelChoiceField( label="", diff --git a/quotas/jinja2/includes/quotas/actions.jinja b/quotas/jinja2/includes/quotas/actions.jinja index ac408b19e..393fe213c 100644 --- a/quotas/jinja2/includes/quotas/actions.jinja +++ b/quotas/jinja2/includes/quotas/actions.jinja @@ -2,7 +2,7 @@