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:
+
+ the main quota order number
+ the sub-quota order number
+ which definitions should be duplicated from main to sub-quota
+ any updates to be made to the duplicated definitions
+ (additional updates can be made after duplication, via the edit definition page)
+
+
+
+ {{ 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 @@
Actions
- View definition periods
+ View and edit quota data
Create definition period
Create quota associations
diff --git a/quotas/jinja2/quotas/definitions.jinja b/quotas/jinja2/quotas/definitions.jinja
index 34f82e280..7fdf121a9 100644
--- a/quotas/jinja2/quotas/definitions.jinja
+++ b/quotas/jinja2/quotas/definitions.jinja
@@ -5,10 +5,35 @@
{% from "components/details/macro.njk" import govukDetails %}
{% from "components/summary-list/macro.njk" import govukSummaryList %}
{% from "components/create_sortable_anchor.jinja" import create_sortable_anchor %}
+{% from "macros/fake_tabs.jinja" import fake_tabs %}
{% set page_title = "Quota ID: " ~ quota.order_number ~ " - Data" %}
{% set find_edit_url = url("quota-ui-list") %}
+{% set links = [
+ {
+ "text": "Quota definition periods",
+ "href": url('quota_definition-ui-list', kwargs={"sid": quota.sid}),
+ "selected": quota_type == None
+ },
+ {
+ "text": "Sub-quotas",
+ "href": url('quota_definition-ui-list-filter', kwargs={"sid": quota.sid, "quota_type": "sub_quotas"}),
+ "selected": quota_type == "sub_quotas",
+ },
+ {
+ "text": "Blocking periods",
+ "href": url('quota_definition-ui-list-filter', kwargs={"sid": quota.sid, "quota_type": "blocking_periods"}),
+ "selected": quota_type == "blocking_periods"
+ },
+ {
+ "text": "Suspension periods",
+ "href": url('quota_definition-ui-list-filter', kwargs={"sid": quota.sid, "quota_type": "suspension_periods"}),
+ "selected": quota_type == "suspension_periods"
+ }
+ ]
+%}
+
{% block breadcrumb %}
{{ breadcrumbs(request, [
{"text": "Find and edit quotas", "href": url("quota-ui-list")},
@@ -21,20 +46,10 @@
{{ page_title }}
- {% set filters_html -%}
-
- {% endset %}
-
- {{ govukDetails({
- "summaryText": "Customise table",
- "html": filters_html
- }) }}
-
+{{ fake_tabs(links) }}
{% set base_url = url('quota_definition-ui-list', args=[quota.sid] ) %}
-
+
{% if quota_type == "blocking_periods" %}
{% include "quotas/tables/blocking_periods.jinja" %}
{% elif quota_type == "suspension_periods" %}
@@ -44,6 +59,8 @@
{% else %}
{% include "quotas/tables/definitions.jinja" %}
{% endif %}
-
+
-{% endblock %}
+
+
Back to quota ID
+{% endblock %}
\ No newline at end of file
diff --git a/quotas/jinja2/quotas/tables/definitions.jinja b/quotas/jinja2/quotas/tables/definitions.jinja
index b9f1bac9e..27105d196 100644
--- a/quotas/jinja2/quotas/tables/definitions.jinja
+++ b/quotas/jinja2/quotas/tables/definitions.jinja
@@ -1,5 +1,5 @@
-
Quota definitions
+
Quota definition periods
{% set table_rows = [] %}
diff --git a/quotas/jinja2/quotas/tables/sub_quotas.jinja b/quotas/jinja2/quotas/tables/sub_quotas.jinja
index b6d76607f..eae119037 100644
--- a/quotas/jinja2/quotas/tables/sub_quotas.jinja
+++ b/quotas/jinja2/quotas/tables/sub_quotas.jinja
@@ -1,5 +1,5 @@
-
Sub quotas
+
Sub-quotas
{% set table_rows = [] %}
diff --git a/quotas/tests/test_views.py b/quotas/tests/test_views.py
index bbc333633..6a6d718f8 100644
--- a/quotas/tests/test_views.py
+++ b/quotas/tests/test_views.py
@@ -1694,6 +1694,73 @@ def test_quota_blocking_confirm_create_view(valid_user_client):
)
+def test_quota_definition_view(valid_user_client):
+ """Test all 4 of the quota definition tabs load and display the correct
+ objects."""
+ main_quota_definition = factories.QuotaDefinitionFactory.create(sid=123)
+ sub_quota_definition = factories.QuotaDefinitionFactory.create(sid=234)
+ main_quota = main_quota_definition.order_number
+ association = factories.QuotaAssociationFactory.create(
+ main_quota=main_quota_definition,
+ sub_quota=sub_quota_definition,
+ )
+ blocking = factories.QuotaBlockingFactory.create(
+ quota_definition=main_quota_definition,
+ description="Blocking period description",
+ )
+ suspension = factories.QuotaSuspensionFactory.create(
+ quota_definition=main_quota_definition,
+ description="Suspension period description",
+ )
+
+ # Definition period tab
+ response = valid_user_client.get(
+ reverse("quota_definition-ui-list", kwargs={"sid": main_quota.sid}),
+ )
+ assert response.status_code == 200
+ soup = BeautifulSoup(response.content.decode(response.charset), "html.parser")
+ sid_cell_text = soup.select(
+ "tbody tr:first-child td:first-child details summary span",
+ )[0].text.strip()
+ assert int(sid_cell_text) == main_quota_definition.sid
+
+ # Sub quotas tab
+ response = valid_user_client.get(
+ reverse(
+ "quota_definition-ui-list-filter",
+ kwargs={"sid": main_quota.sid, "quota_type": "sub_quotas"},
+ ),
+ )
+ assert response.status_code == 200
+ soup = BeautifulSoup(response.content.decode(response.charset), "html.parser")
+ sid_cell_text = soup.select("tbody tr:first-child td:first-child a")[0].text.strip()
+ assert int(sid_cell_text) == sub_quota_definition.sid
+
+ # Blocking periods tab
+ response = valid_user_client.get(
+ reverse(
+ "quota_definition-ui-list-filter",
+ kwargs={"sid": main_quota.sid, "quota_type": "blocking_periods"},
+ ),
+ )
+ assert response.status_code == 200
+ soup = BeautifulSoup(response.content.decode(response.charset), "html.parser")
+ description_cell_text = soup.select("tbody tr:first-child td:last-child")[0].text
+ assert description_cell_text == blocking.description
+
+ # Suspension period tab
+ response = valid_user_client.get(
+ reverse(
+ "quota_definition-ui-list-filter",
+ kwargs={"sid": main_quota.sid, "quota_type": "suspension_periods"},
+ ),
+ )
+ assert response.status_code == 200
+ soup = BeautifulSoup(response.content.decode(response.charset), "html.parser")
+ description_cell_text = soup.select("tbody tr:first-child td:last-child")[0].text
+ assert description_cell_text == suspension.description
+
+
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)
diff --git a/quotas/urls.py b/quotas/urls.py
index 7da0d9807..4b000f931 100644
--- a/quotas/urls.py
+++ b/quotas/urls.py
@@ -59,6 +59,11 @@
views.QuotaDefinitionList.as_view(),
name="quota_definition-ui-list",
),
+ path(
+ f"quotas/
/quota_definitions/",
+ views.QuotaDefinitionList.as_view(),
+ name="quota_definition-ui-list-filter",
+ ),
path(
f"quotas//quota_definitions/create/",
views.QuotaDefinitionCreate.as_view(),
diff --git a/quotas/views.py b/quotas/views.py
index d65b640c0..98ec7a2dc 100644
--- a/quotas/views.py
+++ b/quotas/views.py
@@ -210,17 +210,10 @@ def get_context_data(self, *args, **kwargs):
return context
-class QuotaDefinitionList(FormMixin, SortingMixin, ListView):
+class QuotaDefinitionList(SortingMixin, ListView):
template_name = "quotas/definitions.jinja"
model = models.QuotaDefinition
sort_by_fields = ["sid", "valid_between"]
- form_class = forms.QuotaDefinitionFilterForm
-
- def get_form_kwargs(self):
- kwargs = super().get_form_kwargs()
- kwargs["object_sid"] = self.quota.sid
- kwargs["form_initial"] = self.quota_type
- return kwargs
def get_queryset(self):
queryset = (
@@ -253,7 +246,7 @@ def sub_quotas(self):
@cached_property
def quota_data(self):
- if not self.quota_type:
+ if not self.kwargs.get("quota_type"):
return get_quota_definitions_data(self.quota.order_number, self.object_list)
return None
@@ -261,27 +254,14 @@ def quota_data(self):
def quota(self):
return models.QuotaOrderNumber.objects.current().get(sid=self.kwargs["sid"])
- @property
- def quota_type(self):
- return (
- self.request.GET.get("quota_type")
- if self.request.GET.get("quota_type")
- in ["sub_quotas", "blocking_periods", "suspension_periods"]
- else None
- )
-
def get_context_data(self, *args, **kwargs):
return super().get_context_data(
quota=self.quota,
- quota_type=self.quota_type,
+ quota_type=self.kwargs.get("quota_type"),
quota_data=self.quota_data,
blocking_periods=self.blocking_periods,
suspension_periods=self.suspension_periods,
sub_quotas=self.sub_quotas,
- form_url=reverse(
- "quota_definition-ui-list",
- kwargs={"sid": self.quota.sid},
- ),
*args,
**kwargs,
)
From 38f28cfdf103114195e7964f1eb55d3fc009c33c Mon Sep 17 00:00:00 2001
From: Charlie Prichard <46421052+CPrich905@users.noreply.github.com>
Date: Mon, 16 Sep 2024 16:24:13 +0100
Subject: [PATCH 5/9] bugfix (#1293)
* bugfix
* fixing bugs for business rule checks: empty end date on main_definition
* removed bug test lines
---
quotas/business_rules.py | 29 +++++++++++--------
.../sub-quota-definitions-selected.jinja | 2 +-
.../sub-quota-definitions-updates.jinja | 2 +-
3 files changed, 19 insertions(+), 14 deletions(-)
diff --git a/quotas/business_rules.py b/quotas/business_rules.py
index 37fcc8ce5..0092ecd5e 100644
--- a/quotas/business_rules.py
+++ b/quotas/business_rules.py
@@ -380,10 +380,15 @@ class QA2(ValidityPeriodContained):
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)
+ """Confirms data is compliant with QA2."""
+ if main_definition_valid_between.upper:
+ return (
+ sub_definition_valid_between.lower >= main_definition_valid_between.lower
+ ) and (
+ sub_definition_valid_between.upper <= main_definition_valid_between.upper
+ )
+ else:
+ return sub_definition_valid_between.lower >= main_definition_valid_between.lower
class QA3(BusinessRule):
@@ -421,10 +426,8 @@ def check_QA3_dict(
sub_initial_volume,
main_initial_volume,
):
- """
- Confirms data is compliant with QA3
- See note above about changing the unit types.
- """
+ """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
@@ -499,7 +502,7 @@ def check_QA5_equivalent_volumes(original_definition, volume=None):
.order_by("volume")
.distinct("volume")
.count()
- == 1
+ <= 1
)
@@ -523,9 +526,11 @@ def validate(self, 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
+
+ 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(
diff --git a/quotas/jinja2/quota-definitions/sub-quota-definitions-selected.jinja b/quotas/jinja2/quota-definitions/sub-quota-definitions-selected.jinja
index d77646f46..eab834e68 100644
--- a/quotas/jinja2/quota-definitions/sub-quota-definitions-selected.jinja
+++ b/quotas/jinja2/quota-definitions/sub-quota-definitions-selected.jinja
@@ -19,7 +19,7 @@
{{ 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": "{:%d %b %Y}".format(main_definition.valid_between.upper) if main_definition.valid_between.upper else "-" },
{"text": intcomma(main_definition.volume) },
{"text": main_definition.measurement_unit.abbreviation },
{"text": "-" },
diff --git a/quotas/jinja2/quota-definitions/sub-quota-definitions-updates.jinja b/quotas/jinja2/quota-definitions/sub-quota-definitions-updates.jinja
index cf4b6b6af..63eab5722 100644
--- a/quotas/jinja2/quota-definitions/sub-quota-definitions-updates.jinja
+++ b/quotas/jinja2/quota-definitions/sub-quota-definitions-updates.jinja
@@ -8,7 +8,7 @@
{{ 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": "{:%d %b %Y}".format(main_definition.valid_between.upper) if main_definition.valid_between.upper else "-" },
{"html": intcomma(main_definition.volume) },
{"html": main_definition.measurement_unit.abbreviation},
]) or ""}}
From b90fe5e89b771ea653a7920219bbd00076342500 Mon Sep 17 00:00:00 2001
From: Dale Cannon <118175145+dalecannon@users.noreply.github.com>
Date: Tue, 17 Sep 2024 12:34:57 +0100
Subject: [PATCH 6/9] TP2000-1495 Prevent CrownDependenciesEnvelope
TransitionNotAllowed exception (#1294)
* Add FAILED_PUBLISHING as valid source state for publishing_failed transition method on CrownDependenciesEnvelope model
---
.../models/crown_dependencies_envelope.py | 5 ++-
.../test_model_crown_dependencies_envelope.py | 39 +++++++++++++++++++
2 files changed, 43 insertions(+), 1 deletion(-)
diff --git a/publishing/models/crown_dependencies_envelope.py b/publishing/models/crown_dependencies_envelope.py
index 043acfcd0..cf85ea584 100644
--- a/publishing/models/crown_dependencies_envelope.py
+++ b/publishing/models/crown_dependencies_envelope.py
@@ -144,7 +144,10 @@ def publishing_succeeded(self):
@save_after
@transition(
field=publishing_state,
- source=ApiPublishingState.CURRENTLY_PUBLISHING,
+ source=[
+ ApiPublishingState.CURRENTLY_PUBLISHING,
+ ApiPublishingState.FAILED_PUBLISHING,
+ ],
target=ApiPublishingState.FAILED_PUBLISHING,
custom={"label": "Publishing failed"},
)
diff --git a/publishing/tests/test_model_crown_dependencies_envelope.py b/publishing/tests/test_model_crown_dependencies_envelope.py
index b2ba46820..928998448 100644
--- a/publishing/tests/test_model_crown_dependencies_envelope.py
+++ b/publishing/tests/test_model_crown_dependencies_envelope.py
@@ -1,5 +1,6 @@
import pytest
+from common.tests.factories import CrownDependenciesEnvelopeFactory
from notifications.models import Notification
from publishing.models import ApiPublishingState
from publishing.models import CrownDependenciesEnvelope
@@ -70,3 +71,41 @@ def test_notify_processing_failed(
Notification.objects.last()
mocked_send_emails_apply_async.assert_called()
+
+
+@pytest.mark.parametrize(
+ "transition_method, source_state, target_state",
+ [
+ (
+ "publishing_succeeded",
+ ApiPublishingState.CURRENTLY_PUBLISHING,
+ ApiPublishingState.SUCCESSFULLY_PUBLISHED,
+ ),
+ (
+ "publishing_failed",
+ ApiPublishingState.CURRENTLY_PUBLISHING,
+ ApiPublishingState.FAILED_PUBLISHING,
+ ),
+ (
+ "publishing_failed",
+ ApiPublishingState.FAILED_PUBLISHING,
+ ApiPublishingState.FAILED_PUBLISHING,
+ ),
+ ],
+)
+def test_crown_dependencies_envelope_transition_methods(
+ transition_method,
+ source_state,
+ target_state,
+ settings,
+):
+ """Tests that `CrownDependenciesEnvelope` transition methods move
+ `publishing_state` from source_state to target_state."""
+
+ settings.ENABLE_PACKAGING_NOTIFICATIONS = False
+
+ envelope = CrownDependenciesEnvelopeFactory.create(
+ publishing_state=source_state,
+ )
+ getattr(envelope, transition_method)()
+ assert envelope.publishing_state == target_state
From 1a1619767bb43df272fd6278c38537d514cab2ca Mon Sep 17 00:00:00 2001
From: Charlie Prichard <46421052+CPrich905@users.noreply.github.com>
Date: Tue, 17 Sep 2024 13:39:36 +0100
Subject: [PATCH 7/9] formatting (#1295)
---
quotas/forms.py | 48 ++++++++++++++++++++++---------------------
quotas/serializers.py | 12 +++++------
2 files changed, 31 insertions(+), 29 deletions(-)
diff --git a/quotas/forms.py b/quotas/forms.py
index 32b258a6f..74295f484 100644
--- a/quotas/forms.py
+++ b/quotas/forms.py
@@ -15,7 +15,6 @@
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
@@ -27,14 +26,15 @@
from common.forms import delete_form_for
from common.forms import formset_factory
from common.forms import unprefix_formset_data
+from common.serializers import deserialize_date
from common.util import validity_range_contains_range
from common.validators import SymbolValidator
from common.validators import UpdateType
from geo_areas.models import GeographicalArea
from measures.models import MeasurementUnit
+from quotas import business_rules
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
@@ -1011,7 +1011,7 @@ def init_layout(self, request):
self.helper.layout = Layout(
Div(
HTML(
- 'Enter main and sub-quota order numbers '
+ 'Enter main and sub-quota order numbers ',
),
),
Div(
@@ -1033,9 +1033,7 @@ def init_layout(self, request):
class SelectSubQuotaDefinitionsForm(
SelectableObjectsForm,
):
- """
- Form to select the main quota definitions that are to be duplicated.
- """
+ """Form to select the main quota definitions that are to be duplicated."""
def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request", None)
@@ -1052,9 +1050,9 @@ def set_staged_definition_data(self, selected_definitions):
{
"main_definition": definition.pk,
"sub_definition_staged_data": serialize_duplicate_data(
- definition
+ definition,
),
- }
+ },
)
self.request.session["staged_definition_data"] = staged_definition_data
@@ -1069,7 +1067,7 @@ def clean(self):
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
+ pk__in=definitions_pks,
).current()
cleaned_data["selected_definitions"] = selected_definitions
self.set_staged_definition_data(selected_definitions)
@@ -1089,7 +1087,7 @@ def clean(self):
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"
+ "Each definition period must have a specified relationship and co-efficient value",
)
return cleaned_data
@@ -1132,14 +1130,16 @@ class Meta:
initial_volume = forms.DecimalField(
label="Initial volume",
widget=forms.TextInput(),
+ help_text="The initial volume is the legal balance applied to the definition period.",
error_messages={
"invalid": "Initial volume must be a number",
"required": "Enter the initial volume",
},
)
volume = forms.DecimalField(
- label="Volume",
+ label="Current volume",
widget=forms.TextInput(),
+ help_text="The current volume is the starting balance for the quota.",
error_messages={
"invalid": "Volume must be a number",
"required": "Enter the volume",
@@ -1158,7 +1158,7 @@ def get_duplicate_data(self, original_definition):
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
@@ -1168,7 +1168,7 @@ def set_initial_data(self, duplicate_data):
fields["relationship_type"].initial = "NM"
fields["coefficient"].initial = 1
fields["measurement_unit"].initial = MeasurementUnit.objects.get(
- code=duplicate_data["measurement_unit_code"]
+ code=duplicate_data["measurement_unit_code"],
)
fields["initial_volume"].initial = duplicate_data["initial_volume"]
fields["volume"].initial = duplicate_data["volume"]
@@ -1185,7 +1185,7 @@ def __init__(self, *args, **kwargs):
main_def_id = kwargs.pop("pk")
super().__init__(*args, **kwargs)
self.original_definition = models.QuotaDefinition.objects.get(
- trackedmodel_ptr_id=main_def_id
+ trackedmodel_ptr_id=main_def_id,
)
self.init_fields()
self.get_duplicate_data(self.original_definition)
@@ -1196,6 +1196,7 @@ def clean(self):
"""
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
@@ -1208,7 +1209,7 @@ def clean(self):
):
raise ValidationError(
"QA2: Validity period for sub quota must be within the "
- "validity period of the main quota"
+ "validity period of the main quota",
)
if not business_rules.check_QA3_dict(
@@ -1222,17 +1223,17 @@ def clean(self):
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"
+ "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"
+ "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"]
+ cleaned_data["coefficient"],
):
raise ValidationError(
"QA5: Where the relationship type is Normal, the "
@@ -1240,19 +1241,20 @@ def clean(self):
)
elif cleaned_data["relationship_type"] == "EQ":
if not business_rules.check_QA5_equivalent_coefficient(
- cleaned_data["coefficient"]
+ cleaned_data["coefficient"],
):
raise ValidationError(
"QA5: Where the relationship type is Equivalent, the "
- "coefficient value must be something other than 1"
+ "coefficient value must be something other than 1",
)
if not business_rules.check_QA5_equivalent_volumes(
- self.original_definition, volume=cleaned_data["volume"]
+ 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"
+ " with the parent quota",
)
if not business_rules.check_QA6_dict(
@@ -1261,7 +1263,7 @@ def clean(self):
):
ValidationError(
"QA6: Sub-quotas associated with the same main quota must "
- "have the same relation type."
+ "have the same relation type.",
)
return cleaned_data
diff --git a/quotas/serializers.py b/quotas/serializers.py
index 30cd33eb9..9d03d2ed8 100644
--- a/quotas/serializers.py
+++ b/quotas/serializers.py
@@ -1,4 +1,3 @@
-from common.util import TaricDateRange
from datetime import datetime
from decimal import Decimal
@@ -7,10 +6,11 @@
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.serializers import deserialize_date
+from common.serializers import serialize_date
+from common.util import TaricDateRange
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
@@ -290,15 +290,15 @@ def deserialize_definition_data(self, definition):
initial_volume = Decimal(definition["initial_volume"])
vol = Decimal(definition["volume"])
measurement_unit = MeasurementUnit.objects.get(
- code=definition["measurement_unit_code"]
+ 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,
+ "volume": vol,
+ "initial_volume": initial_volume,
"measurement_unit": measurement_unit,
"order_number": sub_order_number,
"valid_between": valid_between,
From 31fc85a4695287f7c280203d3b657dcd19503b0d Mon Sep 17 00:00:00 2001
From: Paul Pepper <85895113+paulpepper-trade@users.noreply.github.com>
Date: Tue, 17 Sep 2024 18:55:01 +0100
Subject: [PATCH 8/9] TP2000-1463 Support SQLite dump on DBT Platform (#1281)
* Switch to python-dotenv.
Both django-dotenv and python-dotenv rely upon the same `import dotenv`, causing a conflict when dependended-upon packages rely upon python-dotenv. Therefore use the more popular and ubiquitous python-dotenv package, reducing the likelihood of conflict.
* Support SQLite migrations under /tmp
Add support to generate and execute SQLite migration source files under a system's temporary file storage directory. Copilot-based service deployments run under /tmp while all other deployments do not do so by default - the env var SQLITE_MIGRATIONS_IN_TMP_DIR may be set to True to force /tmp directory behaviour.
* Black formatting.
* Unit test SQLiteMigrator.
* Corrected param name.
* Corrected comment typo.
* More logging and more path control.
* Docstring tweak
* Add SQLite validation capability.
* Add --env-info flag for platform diagnostics.
* Show env diagnostics during sqlite dump.
* Pull out path var.
* Get current user name with env info.
* Ensure write perms on migraton directories.
* Allow logging migrations output
* Add comment
* Cater for SQLite DB in config
* Correctly set DATABASE_CREDENTIALS as a string
* Remove SQLITE_LOG_MIGRATIONS debugging
* Remove unnecessary logging
* Replace APP_UPDATED_TIME with UPTIME for DBT Platoform compatibility.
* Typo fix
* Remove unused function.
* Add missing PaaS setting.
* Added is_cloud_foundry() util function.
* Factored out function to get uptime.
---
common/jinja2/common/app_info.jinja | 6 +-
common/tests/test_util.py | 59 ++++++
common/util.py | 44 ++++
common/views/pages.py | 36 +++-
exporter/sqlite/__init__.py | 4 +-
exporter/sqlite/runner.py | 211 ++++++++++++++++----
exporter/storages.py | 39 +++-
exporter/tests/test_files/empty_sqlite.db | 0
exporter/tests/test_files/invalid_sqlite.db | 1 +
exporter/tests/test_files/valid_sqlite.db | Bin 0 -> 4096 bytes
exporter/tests/test_sqlite.py | 57 ++++++
hmrc_sdes/tests/test_client.py | 4 +-
manage.py | 42 +++-
pyproject.toml | 2 +-
requirements-dev-jupyter.txt | 5 +-
requirements.txt | 2 +-
settings/common.py | 8 +-
setup.py | 2 +-
wsgi.py | 2 +-
19 files changed, 460 insertions(+), 64 deletions(-)
create mode 100644 exporter/tests/test_files/empty_sqlite.db
create mode 100644 exporter/tests/test_files/invalid_sqlite.db
create mode 100644 exporter/tests/test_files/valid_sqlite.db
diff --git a/common/jinja2/common/app_info.jinja b/common/jinja2/common/app_info.jinja
index 5dfd98afd..c462ec71b 100644
--- a/common/jinja2/common/app_info.jinja
+++ b/common/jinja2/common/app_info.jinja
@@ -52,9 +52,9 @@
{"text": "Environment variable"},
],
[
- {"text": "APP_UPDATED_TIME"},
- {"text": APP_UPDATED_TIME},
- {"text": "Estimated application deploy time"},
+ {"text": "UPTIME"},
+ {"text": UPTIME},
+ {"text": "Time this instance has been in service"},
],
[
{"text": "LAST_TRANSACTION_TIME"},
diff --git a/common/tests/test_util.py b/common/tests/test_util.py
index 66ed96f2a..ba956d454 100644
--- a/common/tests/test_util.py
+++ b/common/tests/test_util.py
@@ -1,3 +1,4 @@
+import json
import os
from unittest import mock
@@ -14,6 +15,64 @@
pytestmark = pytest.mark.django_db
+@pytest.mark.parametrize(
+ "environment_key, expected_result",
+ (
+ (
+ {
+ "engine": "engine",
+ "username": "username",
+ "password": "password",
+ "host": "host",
+ "port": 1234,
+ "dbname": "dbname",
+ },
+ "engine://username:password@host:1234/dbname",
+ ),
+ (
+ {
+ "engine": "engine",
+ "username": "username",
+ "host": "host",
+ "dbname": "dbname",
+ },
+ "engine://username@host/dbname",
+ ),
+ (
+ {
+ "engine": "engine",
+ "host": "host",
+ "dbname": "dbname",
+ },
+ "engine://host/dbname",
+ ),
+ (
+ {
+ "engine": "engine",
+ "password": "password",
+ "port": 1234,
+ "dbname": "dbname",
+ },
+ "engine:///dbname",
+ ),
+ (
+ {
+ "engine": "engine",
+ "dbname": "dbname",
+ },
+ "engine:///dbname",
+ ),
+ ),
+)
+def test_database_url_from_env(environment_key, expected_result):
+ with mock.patch.dict(
+ os.environ,
+ {"DATABASE_CREDENTIALS": json.dumps(environment_key)},
+ clear=True,
+ ):
+ assert util.database_url_from_env("DATABASE_CREDENTIALS") == expected_result
+
+
@pytest.mark.parametrize(
"value, expected",
[
diff --git a/common/util.py b/common/util.py
index 7f06fb9c1..667f96b79 100644
--- a/common/util.py
+++ b/common/util.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import json
import os
import re
import typing
@@ -54,10 +55,53 @@
major, minor, patch = python_version_tuple()
+def is_cloud_foundry():
+ """Return True if the deployment environment contains a `VCAP_SERVICES` env
+ var, indicating a CloudFoundry environment, False otherwise."""
+ return "VCAP_SERVICES" in os.environ
+
+
def classproperty(fn):
return classmethod(property(fn))
+def database_url_from_env(environment_key: str) -> str:
+ """
+ Return a database URL string from the environment variable identified by
+ `environment_key`. The environment variable should be parsable as a
+ JSON-like string and may contain the keys:
+
+ "engine" (Required) - database engine id. For instance "postgres" or "sqlite".
+ "username" (Optional if "password" is not present) - database user name.
+ "password" (Optional) - database user's password.
+ "host" (Optional if "port" is not present) - database hostname.
+ "port" (Optional) - database host port.
+ "dbname" (Required) - database name.
+
+ If all keys are present, then the returned result would be a string of the
+ form:
+
+ ://:@:/
+
+ This is a plug-in, less naive version of
+ `dbt_copilot_python.database.database_url_from_env()` making `username`,
+ `password`, `host` and `port` an optional as described above.
+ """
+ config = json.loads(os.environ[environment_key])
+
+ username = config.get("username", "")
+ password = config.get("password")
+ host = config.get("host", "")
+ port = config.get("port")
+
+ config["username"] = username
+ config["password"] = f":{password}" if username and password else ""
+ config["host"] = f"@{host}" if (username or password) and host else host
+ config["port"] = f":{port}" if host and port else ""
+
+ return "{engine}://{username}{password}{host}{port}/{dbname}".format(**config)
+
+
def is_truthy(value: Union[str, bool]) -> bool:
"""
Check whether a string represents a True boolean value.
diff --git a/common/views/pages.py b/common/views/pages.py
index 875d45e85..23259ddf8 100644
--- a/common/views/pages.py
+++ b/common/views/pages.py
@@ -1,8 +1,10 @@
"""Common views."""
+import logging
import os
import time
from datetime import datetime
+from datetime import timedelta
from typing import Dict
from typing import List
from typing import Optional
@@ -36,6 +38,7 @@
from common.celery import app as celery_app
from common.forms import HomeSearchForm
from common.models import Transaction
+from common.util import is_cloud_foundry
from exporter.sqlite.util import sqlite_dumps
from footnotes.models import Footnote
from geo_areas.models import GeographicalArea
@@ -47,6 +50,8 @@
from workbaskets.models import WorkBasket
from workbaskets.models import WorkflowStatus
+logger = logging.getLogger(__name__)
+
class HomeView(LoginRequiredMixin, FormView):
template_name = "common/homepage.jinja"
@@ -322,6 +327,33 @@ def get(self, request, *args, **kwargs) -> HttpResponse:
)
+def get_uptime() -> str:
+ """
+ Return approximate system uptime in a platform-independent way as a string
+ in the following format:
+ " days, hours, minutes"
+ """
+ try:
+ if is_cloud_foundry():
+ # CF recycles Garden containers so time.monotonic() returns a
+ # misleading value. However, file modified time is set on deployment.
+ uptime = timedelta(seconds=(time.time() - os.path.getmtime(__file__)))
+ else:
+ # time.monotonic() doesn't count time spent in hibernation, so may
+ # be inaccurate on systems that hibernate.
+ uptime = timedelta(seconds=time.monotonic())
+
+ formatted_uptime = (
+ f"{uptime.days} days, {uptime.seconds // 3600} hours, "
+ f"{uptime.seconds // 60 % 60} minutes"
+ )
+ except Exception as e:
+ logger.error(e)
+ formatted_uptime = "Error getting uptime"
+
+ return formatted_uptime
+
+
class AppInfoView(
LoginRequiredMixin,
TemplateView,
@@ -416,9 +448,7 @@ def get_context_data(self, **kwargs):
if self.request.user.is_superuser:
data["GIT_BRANCH"] = os.getenv("GIT_BRANCH", "Unavailable")
data["GIT_COMMIT"] = os.getenv("GIT_COMMIT", "Unavailable")
- data["APP_UPDATED_TIME"] = AppInfoView.timestamp_to_datetime_string(
- os.path.getmtime(__file__),
- )
+ data["UPTIME"] = get_uptime()
last_transaction = Transaction.objects.order_by("updated_at").last()
data["LAST_TRANSACTION_TIME"] = (
format(
diff --git a/exporter/sqlite/__init__.py b/exporter/sqlite/__init__.py
index e85ac64c1..0450c3a21 100644
--- a/exporter/sqlite/__init__.py
+++ b/exporter/sqlite/__init__.py
@@ -73,8 +73,8 @@ def make_export(connection: apsw.Connection):
Path(temp_sqlite_db.name),
)
plan = make_export_plan(plan_runner)
- # make_tamato_database() creates a Connection instance that needs
- # closing once an in-memory plan has been created from it.
+ # Runner.make_tamato_database() (above) creates a Connection instance
+ # that needs closing once an in-memory plan has been created from it.
plan_runner.database.close()
export_runner = runner.Runner(connection)
diff --git a/exporter/sqlite/runner.py b/exporter/sqlite/runner.py
index 070a87cf2..ae31bb75b 100644
--- a/exporter/sqlite/runner.py
+++ b/exporter/sqlite/runner.py
@@ -1,9 +1,11 @@
import json
import logging
import os
+import shutil
+import subprocess
import sys
from pathlib import Path
-from subprocess import run
+from tempfile import TemporaryDirectory
from typing import Iterable
from typing import Iterator
from typing import Tuple
@@ -16,80 +18,203 @@
logger = logging.getLogger(__name__)
-class Runner:
- """Runs commands on an SQLite database."""
+def normalise_loglevel(loglevel):
+ """
+ Attempt conversion of `loglevel` from a string integer value (e.g. "20") to
+ its loglevel name (e.g. "INFO").
- database: apsw.Connection
+ This function can be used after, for instance, copying log levels from
+ environment variables, when the incorrect representation (int as string
+ rather than the log level name) may occur.
+ """
+ try:
+ return logging._levelToName.get(int(loglevel))
+ except:
+ return loglevel
- def __init__(self, database: apsw.Connection) -> None:
- self.database = database
- @classmethod
- def normalise_loglevel(cls, loglevel):
- """
- Attempt conversion of `loglevel` from a string integer value (e.g. "20")
- to its loglevel name (e.g. "INFO").
+SQLITE_MIGRATIONS_NAME = "sqlite_export"
+"""Name passed to `manage.py makemigrations`, via the --name flag, when creating
+the SQLite migrations source files."""
- This function can be used after, for instance, copying log levels from
- environment variables, when the incorrect representation (int as string
- rather than the log level name) may occur.
- """
- try:
- return logging._levelToName.get(int(loglevel))
- except:
- return loglevel
+SQLITE_MIGRATIONS_GLOB = f"**/migrations/*{SQLITE_MIGRATIONS_NAME}.py"
+"""Glob pattern matching all SQLite-specific migration source files generated by
+the `manage.py makemigrations --name sqlite_export` command."""
- @classmethod
- def manage(cls, sqlite_file: Path, *args: str):
+
+class SQLiteMigrationCurrentDirectory:
+ """
+ Context manager class that uses the application's current base directory for
+ managing SQLite migrations.
+
+ Upon exiting the context manager, SQLite-specific migration files are
+ deleted.
+ """
+
+ def __enter__(self):
+ logger.info(f"Entering context manager {self.__class__.__name__}")
+ return settings.BASE_DIR
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ logger.info(f"Exiting context manager {self.__class__.__name__}")
+ for file in Path(settings.BASE_DIR).rglob(SQLITE_MIGRATIONS_GLOB):
+ file.unlink()
+
+
+class SQLiteMigrationTemporaryDirectory(TemporaryDirectory):
+ """
+ Context manager class that provides a newly created temporary directory
+ (under the OS's temporary directory system) for managing SQLite migrations.
+
+ Upon exiting the context manager, the temporary directory is deleted.
+ """
+
+ def __enter__(self):
+ logger.info(f"Entering context manager {self.__class__.__name__}")
+
+ tmp_dir = super().__enter__()
+ tmp_dir = os.path.join(tmp_dir, "tamato_sqlite_migration")
+ shutil.copytree(settings.BASE_DIR, tmp_dir)
+
+ # Ensure migrations directories are writable to allow SQLite migrations
+ # to be created - some deployments make source tree directories
+ # non-wriable.
+ for d in [p for p in Path(tmp_dir).rglob("migrations") if p.is_dir()]:
+ d.chmod(0o777)
+
+ copied_files = [f for f in Path(tmp_dir).rglob("*") if f.is_file()]
+ logger.info(f"Copied {len(copied_files)} files to {tmp_dir}")
+
+ return tmp_dir
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ logger.info(f"Exiting context manager {self.__class__.__name__}")
+ super().__exit__(exc_type, exc_value, traceback)
+
+
+class SQLiteMigrator:
+ """
+ Populates a new and empty SQLite database file with the Tamato database
+ schema derived from Tamato's models.
+
+ This is required because SQLite uses different fields to PostgreSQL, missing
+ migrations are first generated to bring in the different style of validity
+ fields.
+
+ This is done by creating additional, auxiliary migrations that are specific
+ to the SQLite and then executing them to populate the database with the
+ schema.
+ """
+
+ sqlite_file: Path
+
+ def __init__(self, sqlite_file: Path, migrations_in_tmp_dir=False):
+ self.sqlite_file = sqlite_file
+ self.migration_directory_class = (
+ SQLiteMigrationTemporaryDirectory
+ if migrations_in_tmp_dir
+ else SQLiteMigrationCurrentDirectory
+ )
+
+ def migrate(self):
+ from manage import ENV_INFO_FLAG
+
+ with self.migration_directory_class() as migration_dir:
+ logger.info(f"Running `makemigrations` in {migration_dir}")
+ self.manage(
+ migration_dir,
+ ENV_INFO_FLAG,
+ "makemigrations",
+ "--name",
+ SQLITE_MIGRATIONS_NAME,
+ )
+
+ sqlite_migration_files = [
+ f
+ for f in Path(migration_dir).rglob(SQLITE_MIGRATIONS_GLOB)
+ if f.is_file()
+ ]
+ logger.info(
+ f"{len(sqlite_migration_files)} SQLite migration files "
+ f"generated in {migration_dir}",
+ )
+
+ logger.info(f"Running `migrate` in {migration_dir}")
+ self.manage(
+ migration_dir,
+ ENV_INFO_FLAG,
+ "migrate",
+ )
+
+ def manage(self, exec_dir: str, *manage_args: str):
"""
Runs a Django management command on the SQLite database.
This management command will be run such that ``settings.SQLITE`` is
True, allowing SQLite specific functionality to be switched on and off
using the value of this setting.
+
+ `exec_dir` sets the directory in which the management command should be
+ executed.
"""
+
sqlite_env = os.environ.copy()
# Correct log levels that are incorrectly expressed as string ints.
if "CELERY_LOG_LEVEL" in sqlite_env:
- sqlite_env["CELERY_LOG_LEVEL"] = cls.normalise_loglevel(
+ sqlite_env["CELERY_LOG_LEVEL"] = normalise_loglevel(
sqlite_env["CELERY_LOG_LEVEL"],
)
- sqlite_env["DATABASE_URL"] = f"sqlite:///{str(sqlite_file)}"
- # Required to make sure the postgres default isn't set as the DB_URL
+ # Set up environment-specific env var values.
if sqlite_env.get("VCAP_SERVICES"):
vcap_env = json.loads(sqlite_env["VCAP_SERVICES"])
vcap_env.pop("postgres", None)
sqlite_env["VCAP_SERVICES"] = json.dumps(vcap_env)
+ sqlite_env["DATABASE_URL"] = f"sqlite:///{str(self.sqlite_file)}"
+ elif sqlite_env.get("COPILOT_ENVIRONMENT_NAME"):
+ sqlite_env["DATABASE_CREDENTIALS"] = json.dumps(
+ {
+ "engine": "sqlite",
+ "dbname": f"{str(self.sqlite_file)}",
+ },
+ )
+ else:
+ sqlite_env["DATABASE_URL"] = f"sqlite:///{str(self.sqlite_file)}"
- run(
- [sys.executable, "manage.py", *args],
- cwd=settings.BASE_DIR,
- capture_output=False,
+ sqlite_env["PATH"] = exec_dir + ":" + sqlite_env["PATH"]
+ manage_cmd = os.path.join(exec_dir, "manage.py")
+
+ subprocess.run(
+ [sys.executable, manage_cmd, *manage_args],
+ cwd=exec_dir,
+ check=True,
env=sqlite_env,
)
+
+class Runner:
+ """Runs commands on an SQLite database."""
+
+ database: apsw.Connection
+
+ def __init__(self, database: apsw.Connection) -> None:
+ self.database = database
+
@classmethod
def make_tamato_database(cls, sqlite_file: Path) -> "Runner":
"""Generate a new and empty SQLite database with the TaMaTo schema
derived from Tamato's models - by performing 'makemigrations' followed
by 'migrate' on the Sqlite file located at `sqlite_file`."""
- try:
- # Because SQLite uses different fields to PostgreSQL, missing
- # migrations are first generated to bring in the different style of
- # validity fields. However, these should not be applied to Postgres
- # and so should be removed (in the `finally` block) after they have
- # been applied (when running `migrate`).
- cls.manage(sqlite_file, "makemigrations", "--name", "sqlite_export")
- cls.manage(sqlite_file, "migrate")
- assert sqlite_file.exists()
- return cls(apsw.Connection(str(sqlite_file)))
- finally:
- for file in Path(settings.BASE_DIR).rglob(
- "**/migrations/*sqlite_export.py",
- ):
- file.unlink()
+
+ sqlite_migrator = SQLiteMigrator(
+ sqlite_file=sqlite_file,
+ migrations_in_tmp_dir=settings.SQLITE_MIGRATIONS_IN_TMP_DIR,
+ )
+ sqlite_migrator.migrate()
+
+ assert sqlite_file.exists()
+ return cls(apsw.Connection(str(sqlite_file)))
def read_schema(self, type: str) -> Iterator[Tuple[str, str]]:
"""
diff --git a/exporter/storages.py b/exporter/storages.py
index a6a86ac7d..6b2c65d0a 100644
--- a/exporter/storages.py
+++ b/exporter/storages.py
@@ -1,4 +1,5 @@
import logging
+import sqlite3
from functools import cached_property
from os import path
from pathlib import Path
@@ -15,6 +16,40 @@
logger = logging.getLogger(__name__)
+class EmptyFileException(Exception):
+ pass
+
+
+def is_valid_sqlite(file_path: str) -> bool:
+ """
+ `file_path` should be a path to a file on the local file system. Validation.
+
+ includes:
+ - test that a file exists at `file_path`,
+ - test that the file at `file_path` has non-zero size,
+ - perform a SQLite PRAGMA quick_check on file at `file_path`.
+
+ If errors are found during validation, then exceptions that this function
+ may raise include:
+ - sqlite3.DatabaseError if the PRAGMA quick_check fails.
+ - FileNotFoundError if no file was found at `file_path`.
+ - exporter.storage.EmptyFileException if the file at `file_path` has
+ zero size.
+
+ Returns True if validation checks all pass.
+ """
+
+ if path.getsize(file_path) == 0:
+ raise EmptyFileException(f"{file_path} has zero size.")
+
+ with sqlite3.connect(file_path) as connection:
+ cursor = connection.cursor()
+ # Executing "PRAGMA quick_check" raises DatabaseError if the SQLite
+ # database file is invalid.
+ cursor.execute("PRAGMA quick_check")
+ return True
+
+
class HMRCStorage(S3Boto3Storage):
def get_default_settings(self):
# Importing settings here makes it possible for tests to override_settings
@@ -113,7 +148,9 @@ def export_database(self, filename: str):
sqlite.make_export(connection)
connection.close()
logger.info(f"Saving {filename} to S3 storage.")
- self.save(filename, temp_sqlite_db.file)
+ if is_valid_sqlite(temp_sqlite_db.name):
+ # Only save to S3 if the SQLite file is valid.
+ self.save(filename, temp_sqlite_db.file)
class SQLiteLocalStorage(SQLiteExportMixin, Storage):
diff --git a/exporter/tests/test_files/empty_sqlite.db b/exporter/tests/test_files/empty_sqlite.db
new file mode 100644
index 000000000..e69de29bb
diff --git a/exporter/tests/test_files/invalid_sqlite.db b/exporter/tests/test_files/invalid_sqlite.db
new file mode 100644
index 000000000..abe95ec8e
--- /dev/null
+++ b/exporter/tests/test_files/invalid_sqlite.db
@@ -0,0 +1 @@
+invalid sqlite file content
\ No newline at end of file
diff --git a/exporter/tests/test_files/valid_sqlite.db b/exporter/tests/test_files/valid_sqlite.db
new file mode 100644
index 0000000000000000000000000000000000000000..8c92662d429086144d3ba5010520498e934d2033
GIT binary patch
literal 4096
zcmWFz^vNtqRY=P(%1ta$FlG>7U}WTRP*7lCU|@t|AO!{>KB<6_K`(C?FGv^v7gF_(
ssvix3(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@Rpi&3`08;V@Hvj+t
literal 0
HcmV?d00001
diff --git a/exporter/tests/test_sqlite.py b/exporter/tests/test_sqlite.py
index c75d8f839..bb4f7cdf9 100644
--- a/exporter/tests/test_sqlite.py
+++ b/exporter/tests/test_sqlite.py
@@ -1,4 +1,6 @@
+import sqlite3
import tempfile
+from contextlib import nullcontext
from io import BytesIO
from os import path
from pathlib import Path
@@ -13,6 +15,9 @@
from exporter.sqlite import plan
from exporter.sqlite import tasks
from exporter.sqlite.runner import Runner
+from exporter.sqlite.runner import SQLiteMigrator
+from exporter.storages import EmptyFileException
+from exporter.storages import is_valid_sqlite
from workbaskets.validators import WorkflowStatus
pytestmark = pytest.mark.django_db
@@ -42,6 +47,58 @@ def sqlite_database(sqlite_template: Runner) -> Iterator[Runner]:
yield Runner(in_memory_database)
+def get_test_file_path(filename):
+ return path.join(
+ path.dirname(path.abspath(__file__)),
+ "test_files",
+ filename,
+ )
+
+
+@pytest.mark.parametrize(
+ ("test_file_path, expect_context"),
+ (
+ (
+ get_test_file_path("valid_sqlite.db"),
+ nullcontext(),
+ ),
+ (
+ "/invalid/file/path",
+ pytest.raises(FileNotFoundError),
+ ),
+ (
+ get_test_file_path("empty_sqlite.db"),
+ pytest.raises(EmptyFileException),
+ ),
+ (
+ get_test_file_path("invalid_sqlite.db"),
+ pytest.raises(sqlite3.DatabaseError),
+ ),
+ ),
+)
+def test_is_valid_sqlite(test_file_path, expect_context):
+ """Test that `is_valid_sqlite()` raises correct exceptions for invalid
+ SQLite files and succeeds for valid SQLite files."""
+ with expect_context:
+ is_valid_sqlite(test_file_path)
+
+
+@pytest.mark.parametrize(
+ ("migrations_in_tmp_dir"),
+ (False, True),
+)
+def test_sqlite_migrator(migrations_in_tmp_dir):
+ """Test SQLiteMigrator."""
+ with tempfile.NamedTemporaryFile() as sqlite_file:
+ sqlite_migrator = SQLiteMigrator(
+ sqlite_file=Path(sqlite_file.name),
+ migrations_in_tmp_dir=migrations_in_tmp_dir,
+ )
+ sqlite_migrator.migrate()
+
+ assert is_valid_sqlite(sqlite_file.name)
+
+
FACTORIES_EXPORTED = [
factory
for factory in factories.TrackedModelMixin.__subclasses__()
diff --git a/hmrc_sdes/tests/test_client.py b/hmrc_sdes/tests/test_client.py
index ebd660b11..9533729dc 100644
--- a/hmrc_sdes/tests/test_client.py
+++ b/hmrc_sdes/tests/test_client.py
@@ -63,7 +63,9 @@ def test_api_call(responses, settings):
responses.add_passthru(settings.HMRC["base_url"])
# reload settings from env, overriding test settings
- dotenv.read_dotenv(os.path.join(settings.BASE_DIR, ".env"))
+ import dotenv
+
+ dotenv.load_dotenv(dot_envpath=os.path.join(settings.BASE_DIR, ".env"))
settings.HMRC["client_id"] = os.environ.get("HMRC_API_CLIENT_ID")
settings.HMRC["client_secret"] = os.environ.get("HMRC_API_CLIENT_SECRET")
settings.HMRC["service_reference_number"] = os.environ.get(
diff --git a/manage.py b/manage.py
index 6122f2dd9..d101448f0 100755
--- a/manage.py
+++ b/manage.py
@@ -6,14 +6,52 @@
import dotenv
+ENV_INFO_FLAG = "--env-info"
+
+
+def output_env_info():
+ """Inspect and output environment diagnostics for help with platform /
+ environment debugging."""
+
+ import pwd
+ from pathlib import Path
+
+ cwd = Path().resolve()
+ script_path = Path(__file__).resolve()
+ executable_path = Path(sys.executable).resolve()
+ path = os.environ.get("PATH")
+ username = pwd.getpwuid(os.getuid()).pw_name
+
+ print("Environment diagnostics")
+ print("----")
+ print(f" Current working directory: {cwd}")
+ print(f" Current script path: {script_path}")
+ print(f" Python executable path: {executable_path}")
+ print(f" PATH: {path}")
+ print(f" username: {username}")
+ print("----")
+
+ # Remove the flag to avoid Django unknown command errors.
+ sys.argv = [arg for arg in sys.argv if arg != ENV_INFO_FLAG]
+
+
+def set_django_settings_module():
+ """Set the DJANGO_SETTINGS_MODULE env var with an appropriate value."""
-def main():
in_test = not {"pytest", "test"}.isdisjoint(sys.argv[1:])
in_dev = in_test is False and "DEV" == str(os.environ.get("ENV")).upper()
os.environ.setdefault(
"DJANGO_SETTINGS_MODULE",
"settings.test" if in_test else "settings.dev" if in_dev else "settings",
)
+
+
+def main():
+ if ENV_INFO_FLAG in sys.argv:
+ output_env_info()
+
+ set_django_settings_module()
+
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
@@ -28,5 +66,5 @@ def main():
if __name__ == "__main__":
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- dotenv.read_dotenv()
+ dotenv.load_dotenv()
main()
diff --git a/pyproject.toml b/pyproject.toml
index 127fb30e6..fbd3393b3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,7 +16,6 @@ readme = "README.md"
dependencies = [
"dj-database-url",
"django",
- "django-dotenv",
"django-extra-fields",
"django-filter",
"django-fsm",
@@ -28,6 +27,7 @@ dependencies = [
"gunicorn",
"jinja2",
"psycopg[binary]",
+ "python-dotenv",
"sentry-sdk",
"werkzeug",
"whitenoise",
diff --git a/requirements-dev-jupyter.txt b/requirements-dev-jupyter.txt
index ab997a0de..b9d584e35 100644
--- a/requirements-dev-jupyter.txt
+++ b/requirements-dev-jupyter.txt
@@ -1,6 +1,5 @@
-r requirements-dev.txt
-ipython==8.18.1
+dj-notebook==0.7.0
+ipython==8.26.0
jupyter==1.0.0
-jupyter-nbextensions-configurator==0.6.3
-notebook==6.5.6
diff --git a/requirements.txt b/requirements.txt
index ddcefd61b..3d434b6d0 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -17,7 +17,6 @@ django-chunk-upload-handlers==0.0.13
django-crispy-forms==1.14.0
django-csp==3.6
django-cte==1.3.1
-django-dotenv==1.4.2
django-extensions==3.2.3
django-filter==23.5
django-formtools==2.3
@@ -67,6 +66,7 @@ pytest-forked==1.4.0
pytest-responses==0.5.0
pytest-xdist==2.5.0
pytest==7.4.0
+python-dotenv==1.0.1
python-magic==0.4.25
requests-oauthlib==1.3.0
requests-mock==1.10.0
diff --git a/settings/common.py b/settings/common.py
index bbf3cdb60..da592d65a 100644
--- a/settings/common.py
+++ b/settings/common.py
@@ -12,12 +12,12 @@
import dj_database_url
from celery.schedules import crontab
-from dbt_copilot_python.database import database_url_from_env
from dbt_copilot_python.network import setup_allowed_hosts
from dbt_copilot_python.utility import is_copilot
from django.urls import reverse_lazy
from django_log_formatter_asim import ASIMFormatter
+from common.util import database_url_from_env
from common.util import is_truthy
# Name of the deployment environment (dev/alpha)
@@ -319,9 +319,9 @@
# -- Database
+
if MAINTENANCE_MODE:
DATABASES = {}
-
# DBT PaaS
elif is_copilot():
DB_URL = database_url_from_env("DATABASE_CREDENTIALS")
@@ -527,6 +527,7 @@
if is_copilot():
SQLITE_S3_ACCESS_KEY_ID = None
SQLITE_S3_SECRET_ACCESS_KEY = None
+ SQLITE_MIGRATIONS_IN_TMP_DIR = True
else:
SQLITE_S3_ACCESS_KEY_ID = os.environ.get(
"SQLITE_S3_ACCESS_KEY_ID",
@@ -536,6 +537,9 @@
"SQLITE_S3_SECRET_ACCESS_KEY",
"test_sqlite_key",
)
+ SQLITE_MIGRATIONS_IN_TMP_DIR = is_truthy(
+ os.environ.get("SQLITE_MIGRATIONS_IN_TMP_DIR", False),
+ )
SQLITE_STORAGE_BUCKET_NAME = os.environ.get("SQLITE_STORAGE_BUCKET_NAME", "sqlite")
SQLITE_S3_ENDPOINT_URL = os.environ.get(
diff --git a/setup.py b/setup.py
index 4dd71a5aa..93b4fde7b 100644
--- a/setup.py
+++ b/setup.py
@@ -14,7 +14,6 @@
install_requires=[
"dj-database-url",
"django",
- "django-dotenv",
"django-extra-fields",
"django-filter",
"django-fsm",
@@ -26,6 +25,7 @@
"gunicorn",
"jinja2",
"psycopg[binary]",
+ "python-dotenv",
"sentry-sdk",
"werkzeug",
"whitenoise",
diff --git a/wsgi.py b/wsgi.py
index 92efa35aa..e537f5824 100644
--- a/wsgi.py
+++ b/wsgi.py
@@ -13,7 +13,7 @@
from django.core.wsgi import get_wsgi_application
-dotenv.read_dotenv()
+dotenv.load_dotenv()
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings")
From 830cbdb2c59151c8830c6eb2aa2eafe82c9723da Mon Sep 17 00:00:00 2001
From: Dale Cannon <118175145+dalecannon@users.noreply.github.com>
Date: Wed, 18 Sep 2024 17:10:01 +0100
Subject: [PATCH 9/9] TP2000-1498 Fix intermittent unit test failure (#1296)
* Removes superfluous PreferentialQuotaOrderNumberFactory call
---
.../tests/test_preferential_quota_order_number_forms.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/reference_documents/tests/test_preferential_quota_order_number_forms.py b/reference_documents/tests/test_preferential_quota_order_number_forms.py
index caa473894..238d109a0 100644
--- a/reference_documents/tests/test_preferential_quota_order_number_forms.py
+++ b/reference_documents/tests/test_preferential_quota_order_number_forms.py
@@ -148,7 +148,6 @@ def test_clean_quota_order_number_invalid_order_number_adding(self):
assert "quota_order_number" in target.errors.keys()
def test_clean_coefficient_no_main_order(self):
- factories.PreferentialQuotaOrderNumberFactory()
pref_quota_order_number = factories.PreferentialQuotaOrderNumberFactory()
data = {