From 911957157485da700435c2c9ab633672298e8287 Mon Sep 17 00:00:00 2001 From: Charles Prichard Date: Mon, 28 Oct 2024 12:02:54 +0000 Subject: [PATCH 1/8] formatting --- quotas/forms/__init__.py | 2 + quotas/{forms.py => forms/base.py} | 113 ------- quotas/forms/wizards.py | 309 ++++++++++++++++++ .../bulk-create-definition-intro-period.jinja | 4 + .../bulk-create-definition-review.jinja | 5 + .../bulk-create-definitions-create.jinja | 6 + .../bulk-create-definitions-start.jinja | 4 + .../quota-definitions/bulk-create-step.jinja | 28 ++ quotas/tests/test_views.py | 2 +- quotas/views/__init__.py | 2 + quotas/{views.py => views/base.py} | 261 --------------- quotas/views/wizards.py | 273 ++++++++++++++++ server.py | 16 + 13 files changed, 650 insertions(+), 375 deletions(-) create mode 100644 quotas/forms/__init__.py rename quotas/{forms.py => forms/base.py} (92%) create mode 100644 quotas/forms/wizards.py create mode 100644 quotas/jinja2/quota-definitions/bulk-create-definition-intro-period.jinja create mode 100644 quotas/jinja2/quota-definitions/bulk-create-definition-review.jinja create mode 100644 quotas/jinja2/quota-definitions/bulk-create-definitions-create.jinja create mode 100644 quotas/jinja2/quota-definitions/bulk-create-definitions-start.jinja create mode 100644 quotas/jinja2/quota-definitions/bulk-create-step.jinja create mode 100644 quotas/views/__init__.py rename quotas/{views.py => views/base.py} (79%) create mode 100644 quotas/views/wizards.py create mode 100644 server.py diff --git a/quotas/forms/__init__.py b/quotas/forms/__init__.py new file mode 100644 index 000000000..5181eb5c5 --- /dev/null +++ b/quotas/forms/__init__.py @@ -0,0 +1,2 @@ +from .base import * +from .wizards import * diff --git a/quotas/forms.py b/quotas/forms/base.py similarity index 92% rename from quotas/forms.py rename to quotas/forms/base.py index 0550b5240..8d1363f60 100644 --- a/quotas/forms.py +++ b/quotas/forms/base.py @@ -16,7 +16,6 @@ from django.template.loader import render_to_string from django.urls import reverse_lazy -from common.fields import AutoCompleteField from common.forms import BindNestedFormMixin from common.forms import ExtraErrorFormMixin from common.forms import FormSet @@ -40,8 +39,6 @@ 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" @@ -1064,116 +1061,6 @@ class Meta: QuotaSuspensionDeleteForm = delete_form_for(models.QuotaSuspension) -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, ): diff --git a/quotas/forms/wizards.py b/quotas/forms/wizards.py new file mode 100644 index 000000000..e6d587342 --- /dev/null +++ b/quotas/forms/wizards.py @@ -0,0 +1,309 @@ +from crispy_forms_gds.helper import FormHelper + +# from crispy_forms_gds.layout import Button +from crispy_forms_gds.layout import HTML +from crispy_forms_gds.layout import Div +from crispy_forms_gds.layout import Field +from crispy_forms_gds.layout import Layout +from crispy_forms_gds.layout import Size +from crispy_forms_gds.layout import Submit +from django import forms +from django.core.exceptions import ValidationError + +from common.fields import AutoCompleteField +from common.forms import ValidityPeriodForm +from measures.models import MeasurementUnit +from quotas import models +from quotas import validators +from quotas.serializers import serialize_duplicate_data +from workbaskets.forms import SelectableObjectsForm + + +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 forms.ValidationError( + "Each definition period must have a specified relationship and co-efficient value", + ) + return cleaned_data + + +class BulkQuotaDefinitionCreateStartForm(forms.Form): + pass + + +class BulkQuotaDefinitionCreateIntroductoryPeriod(forms.Form): + pass + + +class QuotaDefinitionCreateForm( + ValidityPeriodForm, + forms.ModelForm, +): + class Meta: + model = models.QuotaDefinition + fields = [ + "valid_between", + "description", + "volume", + "initial_volume", + "measurement_unit", + "measurement_unit_qualifier", + "quota_critical_threshold", + "quota_critical", + "maximum_precision", + ] + + description = forms.CharField(label="", widget=forms.Textarea(), required=False) + volume = forms.DecimalField( + label="Current volume", + widget=forms.TextInput(), + error_messages={ + "invalid": "Volume must be a number", + "required": "Enter the volume", + }, + ) + initial_volume = forms.DecimalField( + widget=forms.TextInput(), + error_messages={ + "invalid": "Initial volume must be a number", + "required": "Enter the initial volume", + }, + ) + measurement_unit = forms.ModelChoiceField( + queryset=MeasurementUnit.objects.current(), + error_messages={"required": "Select the measurement unit"}, + ) + + quota_critical_threshold = forms.DecimalField( + label="Threshold", + help_text="The point at which this quota definition period becomes critical, as a percentage of the total volume.", + widget=forms.TextInput(), + error_messages={ + "invalid": "Critical threshold must be a number", + "required": "Enter the critical threshold", + }, + ) + quota_critical = forms.TypedChoiceField( + label="Is the quota definition period in a critical state?", + help_text="This determines if a trader needs to pay securities when utilising the quota.", + coerce=lambda value: value == "True", + choices=((True, "Yes"), (False, "No")), + widget=forms.RadioSelect(), + error_messages={"required": "Critical state must be set"}, + ) + maximum_precision = forms.IntegerField( + widget=forms.HiddenInput(), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.init_layout() + self.init_fields() + + def clean(self): + validators.validate_quota_volume(self.cleaned_data) + return super().clean() + + def init_fields(self): + # This is always set to 3 for current definitions + # see https://uktrade.github.io/tariff-data-manual/documentation/data-structures/quotas.html#the-quota-definition-table + self.fields["maximum_precision"].initial = 3 + + # Set these as the default values + self.fields["quota_critical"].initial = False + self.fields["quota_critical_threshold"].initial = 90 + + self.fields["measurement_unit"].queryset = self.fields[ + "measurement_unit" + ].queryset.order_by("code") + self.fields["measurement_unit"].label_from_instance = ( + lambda obj: f"{obj.code} - {obj.description}" + ) + + self.fields["measurement_unit_qualifier"].queryset = self.fields[ + "measurement_unit_qualifier" + ].queryset.order_by("code") + self.fields["measurement_unit_qualifier"].label_from_instance = ( + lambda obj: f"{obj.code} - {obj.description}" + ) + + def init_layout(self): + self.helper = FormHelper(self) + self.helper.label_size = Size.SMALL + self.helper.legend_size = Size.SMALL + + self.helper.layout = Layout( + Div( + HTML( + '

Definitions count

', + ), + ), + HTML( + '
', + ), + Div( + HTML( + '

Description

', + ), + HTML.p("Adding a description is optional."), + "description", + "order_number", + ), + HTML( + '
', + ), + Div( + HTML( + '

Validity period

', + ), + "start_date", + "end_date", + ), + HTML( + '
', + ), + Div( + HTML( + '

Measurements

', + ), + HTML.p("A measurement unit qualifier is not always required."), + Field("measurement_unit", css_class="govuk-!-width-full"), + Field("measurement_unit_qualifier", css_class="govuk-!-width-full"), + ), + HTML( + '
', + ), + Div( + HTML( + '

Volume

', + ), + HTML.p( + "The initial volume is the legal balance applied to the definition period.

The current volume is the starting balance for the quota.", + ), + "initial_volume", + "volume", + "maximum_precision", + ), + HTML( + '
', + ), + Div( + HTML( + '

Criticality

', + ), + "quota_critical_threshold", + "quota_critical", + ), + Submit( + "submit", + "Save", + data_module="govuk-button", + data_prevent_double_click="true", + ), + ) + + +class BulkQuotaDefinitionCreateSummaryForm: + pass diff --git a/quotas/jinja2/quota-definitions/bulk-create-definition-intro-period.jinja b/quotas/jinja2/quota-definitions/bulk-create-definition-intro-period.jinja new file mode 100644 index 000000000..a18721389 --- /dev/null +++ b/quotas/jinja2/quota-definitions/bulk-create-definition-intro-period.jinja @@ -0,0 +1,4 @@ +{# +Displays if the option to include an intro period has been selected +Duplicate of the current create definition period page. +#} \ No newline at end of file diff --git a/quotas/jinja2/quota-definitions/bulk-create-definition-review.jinja b/quotas/jinja2/quota-definitions/bulk-create-definition-review.jinja new file mode 100644 index 000000000..a2e238837 --- /dev/null +++ b/quotas/jinja2/quota-definitions/bulk-create-definition-review.jinja @@ -0,0 +1,5 @@ +{# +Summary page for each of the staged definition periods +Similar display to the summary page for the duplicate definition periods +should have a link to each of the staged definitions where data can be updated +#} \ No newline at end of file diff --git a/quotas/jinja2/quota-definitions/bulk-create-definitions-create.jinja b/quotas/jinja2/quota-definitions/bulk-create-definitions-create.jinja new file mode 100644 index 000000000..6be55d705 --- /dev/null +++ b/quotas/jinja2/quota-definitions/bulk-create-definitions-create.jinja @@ -0,0 +1,6 @@ +{# +This will be an update of the current definition create template +Should also include +a numerical input for the number of deffinition periods to be created +an option collecing the date split for the different definition periods + #} \ No newline at end of file diff --git a/quotas/jinja2/quota-definitions/bulk-create-definitions-start.jinja b/quotas/jinja2/quota-definitions/bulk-create-definitions-start.jinja new file mode 100644 index 000000000..7ea517513 --- /dev/null +++ b/quotas/jinja2/quota-definitions/bulk-create-definitions-start.jinja @@ -0,0 +1,4 @@ +{# +First step for bulk creating definition periods +Collects whether there's an introductory period +#} \ No newline at end of file diff --git a/quotas/jinja2/quota-definitions/bulk-create-step.jinja b/quotas/jinja2/quota-definitions/bulk-create-step.jinja new file mode 100644 index 000000000..e257a40a7 --- /dev/null +++ b/quotas/jinja2/quota-definitions/bulk-create-step.jinja @@ -0,0 +1,28 @@ +{# TODO: This is identical to the sub-quota-duplicate-definitions-step.jinja +Investigate renaming the other one and using that here as well. +#} + +{% 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/tests/test_views.py b/quotas/tests/test_views.py index 70c285a35..fb46d55bc 100644 --- a/quotas/tests/test_views.py +++ b/quotas/tests/test_views.py @@ -22,7 +22,7 @@ from geo_areas.validators import AreaCode from quotas import models from quotas import validators -from quotas.forms import QuotaSuspensionType +from quotas.forms.base import QuotaSuspensionType from quotas.views import DuplicateDefinitionsWizard from quotas.views import QuotaList from quotas.wizard import QuotaDefinitionDuplicatorSessionStorage diff --git a/quotas/views/__init__.py b/quotas/views/__init__.py new file mode 100644 index 000000000..5181eb5c5 --- /dev/null +++ b/quotas/views/__init__.py @@ -0,0 +1,2 @@ +from .base import * +from .wizards import * diff --git a/quotas/views.py b/quotas/views/base.py similarity index 79% rename from quotas/views.py rename to quotas/views/base.py index 254913c9e..493174e1b 100644 --- a/quotas/views.py +++ b/quotas/views/base.py @@ -1,6 +1,4 @@ -import datetime from datetime import date -from decimal import Decimal from urllib.parse import urlencode from django.contrib import messages @@ -8,15 +6,12 @@ from django.db import transaction from django.db.models import Q from django.http import HttpResponseRedirect -from django.shortcuts import redirect 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.list import ListView -from formtools.wizard.views import NamedUrlSessionWizardView from rest_framework import permissions from rest_framework import viewsets @@ -24,12 +19,10 @@ from common.business_rules import UpdateValidity from common.forms import delete_form_for 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 from common.views import SortingMixin from common.views import TamatoListView from common.views import TrackedModelDetailMixin @@ -49,8 +42,6 @@ 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 @@ -827,258 +818,6 @@ 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. - - 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"}, - } - - @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 - 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"], - ) - transaction = self.workbasket.new_transaction() - instance = models.QuotaDefinition.objects.create( - **staged_data, - transaction=transaction, - ) - 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_relation_type=definition["sub_definition_staged_data"][ - "relationship_type" - ], - update_type=UpdateType.CREATE, - transaction=transaction, - ) - - -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/views/wizards.py b/quotas/views/wizards.py new file mode 100644 index 000000000..cc942ee72 --- /dev/null +++ b/quotas/views/wizards.py @@ -0,0 +1,273 @@ +import datetime +from decimal import Decimal + +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.db import transaction +from django.shortcuts import redirect +from django.urls import reverse +from django.utils.decorators import method_decorator +from django.views.generic import FormView +from django.views.generic import TemplateView +from formtools.wizard.views import NamedUrlSessionWizardView + +from common.serializers import serialize_date +from common.validators import UpdateType +from common.views import BusinessRulesMixin +from quotas import forms +from quotas import models +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 + + +@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. + + 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"}, + } + + @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 + 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"], + ) + transaction = self.workbasket.new_transaction() + instance = models.QuotaDefinition.objects.create( + **staged_data, + transaction=transaction, + ) + 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_relation_type=definition["sub_definition_staged_data"][ + "relationship_type" + ], + update_type=UpdateType.CREATE, + transaction=transaction, + ) + + +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 diff --git a/server.py b/server.py new file mode 100644 index 000000000..c0412c2d1 --- /dev/null +++ b/server.py @@ -0,0 +1,16 @@ +import asyncio + +import tornado + + +class MainHandler(tornado.web.RequestHandler): + def post(self): + self.write(self.request.body) + + +async def main(): + tornado.web.Application([(r"/", MainHandler)]).listen(8000) + await asyncio.Event().wait() + + +asyncio.run(main()) From 9c0ab460f229e8c02b52b898bf7bad6ebf40936c Mon Sep 17 00:00:00 2001 From: Charles Prichard Date: Mon, 28 Oct 2024 13:55:18 +0000 Subject: [PATCH 2/8] removed extra file --- server.py | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 server.py diff --git a/server.py b/server.py deleted file mode 100644 index c0412c2d1..000000000 --- a/server.py +++ /dev/null @@ -1,16 +0,0 @@ -import asyncio - -import tornado - - -class MainHandler(tornado.web.RequestHandler): - def post(self): - self.write(self.request.body) - - -async def main(): - tornado.web.Application([(r"/", MainHandler)]).listen(8000) - await asyncio.Event().wait() - - -asyncio.run(main()) From 6bde41c5235c9e9995647763fe813926ae26674c Mon Sep 17 00:00:00 2001 From: Charles Prichard Date: Mon, 28 Oct 2024 14:32:54 +0000 Subject: [PATCH 3/8] removed template files --- .../bulk-create-definition-intro-period.jinja | 4 --- .../bulk-create-definition-review.jinja | 5 ---- .../bulk-create-definitions-create.jinja | 6 ---- .../bulk-create-definitions-start.jinja | 4 --- .../quota-definitions/bulk-create-step.jinja | 28 ------------------- 5 files changed, 47 deletions(-) delete mode 100644 quotas/jinja2/quota-definitions/bulk-create-definition-intro-period.jinja delete mode 100644 quotas/jinja2/quota-definitions/bulk-create-definition-review.jinja delete mode 100644 quotas/jinja2/quota-definitions/bulk-create-definitions-create.jinja delete mode 100644 quotas/jinja2/quota-definitions/bulk-create-definitions-start.jinja delete mode 100644 quotas/jinja2/quota-definitions/bulk-create-step.jinja diff --git a/quotas/jinja2/quota-definitions/bulk-create-definition-intro-period.jinja b/quotas/jinja2/quota-definitions/bulk-create-definition-intro-period.jinja deleted file mode 100644 index a18721389..000000000 --- a/quotas/jinja2/quota-definitions/bulk-create-definition-intro-period.jinja +++ /dev/null @@ -1,4 +0,0 @@ -{# -Displays if the option to include an intro period has been selected -Duplicate of the current create definition period page. -#} \ No newline at end of file diff --git a/quotas/jinja2/quota-definitions/bulk-create-definition-review.jinja b/quotas/jinja2/quota-definitions/bulk-create-definition-review.jinja deleted file mode 100644 index a2e238837..000000000 --- a/quotas/jinja2/quota-definitions/bulk-create-definition-review.jinja +++ /dev/null @@ -1,5 +0,0 @@ -{# -Summary page for each of the staged definition periods -Similar display to the summary page for the duplicate definition periods -should have a link to each of the staged definitions where data can be updated -#} \ No newline at end of file diff --git a/quotas/jinja2/quota-definitions/bulk-create-definitions-create.jinja b/quotas/jinja2/quota-definitions/bulk-create-definitions-create.jinja deleted file mode 100644 index 6be55d705..000000000 --- a/quotas/jinja2/quota-definitions/bulk-create-definitions-create.jinja +++ /dev/null @@ -1,6 +0,0 @@ -{# -This will be an update of the current definition create template -Should also include -a numerical input for the number of deffinition periods to be created -an option collecing the date split for the different definition periods - #} \ No newline at end of file diff --git a/quotas/jinja2/quota-definitions/bulk-create-definitions-start.jinja b/quotas/jinja2/quota-definitions/bulk-create-definitions-start.jinja deleted file mode 100644 index 7ea517513..000000000 --- a/quotas/jinja2/quota-definitions/bulk-create-definitions-start.jinja +++ /dev/null @@ -1,4 +0,0 @@ -{# -First step for bulk creating definition periods -Collects whether there's an introductory period -#} \ No newline at end of file diff --git a/quotas/jinja2/quota-definitions/bulk-create-step.jinja b/quotas/jinja2/quota-definitions/bulk-create-step.jinja deleted file mode 100644 index e257a40a7..000000000 --- a/quotas/jinja2/quota-definitions/bulk-create-step.jinja +++ /dev/null @@ -1,28 +0,0 @@ -{# TODO: This is identical to the sub-quota-duplicate-definitions-step.jinja -Investigate renaming the other one and using that here as well. -#} - -{% 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 From b88e303c177b6a6e7083f85a650aa9bda35f4c6f Mon Sep 17 00:00:00 2001 From: Charles Prichard Date: Mon, 28 Oct 2024 17:38:50 +0000 Subject: [PATCH 4/8] formatting --- quotas/forms/__init__.py | 1 + quotas/forms/base.py | 344 ++++++--------------------------------- quotas/views/__init__.py | 1 + quotas/views/base.py | 323 ------------------------------------ 4 files changed, 54 insertions(+), 615 deletions(-) diff --git a/quotas/forms/__init__.py b/quotas/forms/__init__.py index 5181eb5c5..e5c5ff603 100644 --- a/quotas/forms/__init__.py +++ b/quotas/forms/__init__.py @@ -1,2 +1,3 @@ from .base import * +from .definitions import * from .wizards import * diff --git a/quotas/forms/base.py b/quotas/forms/base.py index 8d1363f60..4e108571e 100644 --- a/quotas/forms/base.py +++ b/quotas/forms/base.py @@ -131,37 +131,25 @@ def __init__(self, *args, **kwargs): QuotaDeleteForm = delete_form_for(models.QuotaOrderNumber) -class QuotaOriginExclusionsForm(forms.Form): - exclusion = forms.ModelChoiceField( - label="", - queryset=GeographicalArea.objects.all(), # modified in __init__ - help_text="Select a country to be excluded:", - required=False, - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["exclusion"].queryset = ( - GeographicalArea.objects.current() - .with_latest_description() - .as_at_today_and_beyond() - .order_by("description") - ) - self.fields["exclusion"].label_from_instance = ( - lambda obj: f"{obj.area_id} - {obj.description}" - ) - - -QuotaOriginExclusionsFormSet = formset_factory( - QuotaOriginExclusionsForm, - prefix=QUOTA_ORIGIN_EXCLUSIONS_FORMSET_PREFIX, - formset=FormSet, - min_num=0, - max_num=100, - extra=0, - validate_min=True, - validate_max=True, -) +# class QuotaOriginExclusionsForm(forms.Form): +# exclusion = forms.ModelChoiceField( +# label="", +# queryset=GeographicalArea.objects.all(), # modified in __init__ +# help_text="Select a country to be excluded:", +# required=False, +# ) + +# def __init__(self, *args, **kwargs): +# super().__init__(*args, **kwargs) +# self.fields["exclusion"].queryset = ( +# GeographicalArea.objects.current() +# .with_latest_description() +# .as_at_today_and_beyond() +# .order_by("description") +# ) +# self.fields["exclusion"].label_from_instance = ( +# lambda obj: f"{obj.area_id} - {obj.description}" +# ) class QuotaUpdateForm( @@ -460,6 +448,39 @@ def clean(self): return super().clean() +class QuotaOriginExclusionsForm(forms.Form): + exclusion = forms.ModelChoiceField( + label="", + queryset=GeographicalArea.objects.all(), # modified in __init__ + help_text="Select a country to be excluded:", + required=False, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["exclusion"].queryset = ( + GeographicalArea.objects.current() + .with_latest_description() + .as_at_today_and_beyond() + .order_by("description") + ) + self.fields["exclusion"].label_from_instance = ( + lambda obj: f"{obj.area_id} - {obj.description}" + ) + + +QuotaOriginExclusionsFormSet = formset_factory( + QuotaOriginExclusionsForm, + prefix=QUOTA_ORIGIN_EXCLUSIONS_FORMSET_PREFIX, + formset=FormSet, + min_num=0, + max_num=100, + extra=0, + validate_min=True, + validate_max=True, +) + + class QuotaOrderNumberOriginForm( FormSetSubmitMixin, ValidityPeriodForm, @@ -529,267 +550,6 @@ def init_fields(self): ) -class QuotaDefinitionUpdateForm( - ValidityPeriodForm, - forms.ModelForm, -): - class Meta: - model = models.QuotaDefinition - fields = [ - "valid_between", - "description", - "volume", - "initial_volume", - "measurement_unit", - "measurement_unit_qualifier", - "quota_critical_threshold", - "quota_critical", - ] - - description = forms.CharField(label="", widget=forms.Textarea(), required=False) - volume = forms.DecimalField( - label="Current volume", - widget=forms.TextInput(), - error_messages={ - "invalid": "Volume must be a number", - "required": "Enter the volume", - }, - ) - initial_volume = forms.DecimalField( - widget=forms.TextInput(), - error_messages={ - "invalid": "Initial volume must be a number", - "required": "Enter the initial volume", - }, - ) - measurement_unit = forms.ModelChoiceField( - queryset=MeasurementUnit.objects.current(), - error_messages={"required": "Select the measurement unit"}, - ) - quota_critical_threshold = forms.DecimalField( - label="Threshold", - help_text="The point at which this quota definition period becomes critical, as a percentage of the total volume.", - widget=forms.TextInput(), - error_messages={ - "invalid": "Critical threshold must be a number", - "required": "Enter the critical threshold", - }, - ) - quota_critical = forms.TypedChoiceField( - label="Is the quota definition period in a critical state?", - help_text="This determines if a trader needs to pay securities when utilising the quota.", - coerce=lambda value: value == "True", - choices=((True, "Yes"), (False, "No")), - widget=forms.RadioSelect(), - error_messages={"required": "Critical state must be set"}, - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.init_layout() - self.init_fields() - - def clean(self): - validators.validate_quota_volume(self.cleaned_data) - return super().clean() - - def init_fields(self): - self.fields["measurement_unit"].queryset = self.fields[ - "measurement_unit" - ].queryset.order_by("code") - self.fields["measurement_unit"].label_from_instance = ( - lambda obj: f"{obj.code} - {obj.description}" - ) - - self.fields["measurement_unit_qualifier"].queryset = self.fields[ - "measurement_unit_qualifier" - ].queryset.order_by("code") - self.fields["measurement_unit_qualifier"].label_from_instance = ( - lambda obj: f"{obj.code} - {obj.description}" - ) - - def init_layout(self): - self.helper = FormHelper(self) - self.helper.label_size = Size.SMALL - self.helper.legend_size = Size.SMALL - - self.helper.layout = Layout( - Accordion( - AccordionSection( - "Description", - "description", - ), - AccordionSection( - "Validity period", - "start_date", - "end_date", - ), - AccordionSection( - "Measurements", - Field("measurement_unit", css_class="govuk-!-width-full"), - Field("measurement_unit_qualifier", css_class="govuk-!-width-full"), - ), - AccordionSection( - "Volume", - "initial_volume", - "volume", - ), - AccordionSection( - "Criticality", - "quota_critical_threshold", - "quota_critical", - ), - css_class="govuk-!-width-two-thirds", - ), - Submit( - "submit", - "Save", - data_module="govuk-button", - data_prevent_double_click="true", - ), - ) - - -class QuotaDefinitionCreateForm( - ValidityPeriodForm, - forms.ModelForm, -): - class Meta: - model = models.QuotaDefinition - fields = [ - "valid_between", - "description", - "volume", - "initial_volume", - "measurement_unit", - "measurement_unit_qualifier", - "quota_critical_threshold", - "quota_critical", - "maximum_precision", - ] - - description = forms.CharField(label="", widget=forms.Textarea(), required=False) - volume = forms.DecimalField( - label="Current volume", - widget=forms.TextInput(), - error_messages={ - "invalid": "Volume must be a number", - "required": "Enter the volume", - }, - ) - initial_volume = forms.DecimalField( - widget=forms.TextInput(), - error_messages={ - "invalid": "Initial volume must be a number", - "required": "Enter the initial volume", - }, - ) - measurement_unit = forms.ModelChoiceField( - queryset=MeasurementUnit.objects.current(), - error_messages={"required": "Select the measurement unit"}, - ) - - quota_critical_threshold = forms.DecimalField( - label="Threshold", - help_text="The point at which this quota definition period becomes critical, as a percentage of the total volume.", - widget=forms.TextInput(), - error_messages={ - "invalid": "Critical threshold must be a number", - "required": "Enter the critical threshold", - }, - ) - quota_critical = forms.TypedChoiceField( - label="Is the quota definition period in a critical state?", - help_text="This determines if a trader needs to pay securities when utilising the quota.", - coerce=lambda value: value == "True", - choices=((True, "Yes"), (False, "No")), - widget=forms.RadioSelect(), - error_messages={"required": "Critical state must be set"}, - ) - maximum_precision = forms.IntegerField( - widget=forms.HiddenInput(), - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.init_layout() - self.init_fields() - - def clean(self): - validators.validate_quota_volume(self.cleaned_data) - return super().clean() - - def init_fields(self): - # This is always set to 3 for current definitions - # see https://uktrade.github.io/tariff-data-manual/documentation/data-structures/quotas.html#the-quota-definition-table - self.fields["maximum_precision"].initial = 3 - - # Set these as the default values - self.fields["quota_critical"].initial = False - self.fields["quota_critical_threshold"].initial = 90 - - self.fields["measurement_unit"].queryset = self.fields[ - "measurement_unit" - ].queryset.order_by("code") - self.fields["measurement_unit"].label_from_instance = ( - lambda obj: f"{obj.code} - {obj.description}" - ) - - self.fields["measurement_unit_qualifier"].queryset = self.fields[ - "measurement_unit_qualifier" - ].queryset.order_by("code") - self.fields["measurement_unit_qualifier"].label_from_instance = ( - lambda obj: f"{obj.code} - {obj.description}" - ) - - def init_layout(self): - self.helper = FormHelper(self) - self.helper.label_size = Size.SMALL - self.helper.legend_size = Size.SMALL - - self.helper.layout = Layout( - Accordion( - AccordionSection( - "Description", - HTML.p("Adding a description is optional."), - "description", - "order_number", - ), - AccordionSection( - "Validity period", - "start_date", - "end_date", - ), - AccordionSection( - "Measurements", - HTML.p("A measurement unit qualifier is not always required."), - Field("measurement_unit", css_class="govuk-!-width-full"), - Field("measurement_unit_qualifier", css_class="govuk-!-width-full"), - ), - AccordionSection( - "Volume", - HTML.p( - "The initial volume is the legal balance applied to the definition period.

The current volume is the starting balance for the quota.", - ), - "initial_volume", - "volume", - "maximum_precision", - ), - AccordionSection( - "Criticality", - "quota_critical_threshold", - "quota_critical", - ), - ), - Submit( - "submit", - "Save", - data_module="govuk-button", - data_prevent_double_click="true", - ), - ) - - class QuotaOrderNumberOriginUpdateForm( QuotaOrderNumberOriginForm, ): diff --git a/quotas/views/__init__.py b/quotas/views/__init__.py index 5181eb5c5..e5c5ff603 100644 --- a/quotas/views/__init__.py +++ b/quotas/views/__init__.py @@ -1,2 +1,3 @@ from .base import * +from .definitions import * from .wizards import * diff --git a/quotas/views/base.py b/quotas/views/base.py index 493174e1b..1ef7fa6b3 100644 --- a/quotas/views/base.py +++ b/quotas/views/base.py @@ -4,14 +4,11 @@ from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin from django.db import transaction -from django.db.models import Q from django.http import HttpResponseRedirect 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.list import ListView from rest_framework import permissions from rest_framework import viewsets @@ -21,7 +18,6 @@ from common.serializers import AutoCompleteSerializer 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 TamatoListView @@ -74,13 +70,6 @@ class QuotaOrderNumberOriginExclusionViewset(viewsets.ReadOnlyModelViewSet): permission_classes = [permissions.IsAuthenticated] -class QuotaDefinitionViewset(viewsets.ReadOnlyModelViewSet): - queryset = models.QuotaDefinition.objects.has_approved_state() - serializer_class = serializers.QuotaDefinitionSerializer - permission_classes = [permissions.IsAuthenticated] - search_fields = ["sid", "order_number__order_number", "description"] - - class QuotaAssociationViewset(viewsets.ReadOnlyModelViewSet): queryset = models.QuotaAssociation.objects.has_approved_state() serializer_class = serializers.QuotaAssociationSerializer @@ -269,79 +258,6 @@ def get_context_data(self, *args, **kwargs): return context -class QuotaDefinitionList(SortingMixin, ListView): - template_name = "quotas/definitions.jinja" - model = models.QuotaDefinition - sort_by_fields = ["sid", "valid_between"] - - def get_queryset(self): - queryset = ( - models.QuotaDefinition.objects.filter( - order_number__sid=self.quota.sid, - ) - .current() - .order_by("pk") - ) - - ordering = self.get_ordering() - if ordering: - if isinstance(ordering, str): - ordering = (ordering,) - queryset = queryset.order_by(*ordering) - - return queryset - - @property - def blocking_periods(self): - return QuotaBlocking.objects.filter(quota_definition__order_number=self.quota) - - @property - def suspension_periods(self): - return ( - QuotaSuspension.objects.current() - .filter(quota_definition__order_number=self.quota) - .order_by("quota_definition__sid") - ) - - @property - def sub_quotas(self): - return ( - QuotaAssociation.objects.current() - .filter(main_quota__order_number=self.quota) - .order_by("sub_quota__sid") - ) - - @property - def main_quotas(self): - main_quotas = QuotaAssociation.objects.current().filter( - sub_quota__order_number=self.quota, - ) - return main_quotas - - @cached_property - def quota_data(self): - if not self.kwargs.get("quota_type"): - return get_quota_definitions_data(self.quota.order_number, self.object_list) - return None - - @property - def quota(self): - return models.QuotaOrderNumber.objects.current().get(sid=self.kwargs["sid"]) - - def get_context_data(self, *args, **kwargs): - return super().get_context_data( - quota=self.quota, - 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, - main_quotas=self.main_quotas, - *args, - **kwargs, - ) - - class QuotaDelete( QuotaOrderNumberMixin, TrackedModelDetailMixin, @@ -699,118 +615,6 @@ class QuotaOrderNumberOriginConfirmUpdate( template_name = "quota-origins/confirm-update.jinja" -class QuotaDefinitionMixin: - model = models.QuotaDefinition - - def get_queryset(self): - tx = WorkBasket.get_current_transaction(self.request) - return models.QuotaDefinition.objects.approved_up_to_transaction(tx) - - -class QuotaDefinitionUpdateMixin( - QuotaDefinitionMixin, - TrackedModelDetailMixin, -): - form_class = forms.QuotaDefinitionUpdateForm - permission_required = ["common.change_trackedmodel"] - template_name = "quota-definitions/edit.jinja" - - validate_business_rules = ( - business_rules.QD7, - business_rules.QD8, - business_rules.QD10, - business_rules.QD11, - UniqueIdentifyingFields, - UpdateValidity, - ) - - @transaction.atomic - def get_result_object(self, form): - object = super().get_result_object(form) - return object - - -class QuotaDefinitionUpdate( - QuotaDefinitionUpdateMixin, - CreateTaricUpdateView, -): - pass - - -class QuotaDefinitionCreate(QuotaDefinitionUpdateMixin, CreateTaricCreateView): - template_name = "quota-definitions/create.jinja" - form_class = forms.QuotaDefinitionCreateForm - - def form_valid(self, form): - quota = models.QuotaOrderNumber.objects.current().get(sid=self.kwargs["sid"]) - form.instance.order_number = quota - return super().form_valid(form) - - -class QuotaDefinitionConfirmCreate( - QuotaDefinitionMixin, - TrackedModelDetailView, -): - template_name = "quota-definitions/confirm-create.jinja" - - -class QuotaDefinitionDelete( - QuotaDefinitionUpdateMixin, - CreateTaricDeleteView, -): - form_class = delete_form_for(models.QuotaDefinition) - template_name = "quota-definitions/delete.jinja" - - @property - def related_associations(self): - return models.QuotaAssociation.objects.current().filter( - Q(main_quota__sid=self.object.sid) | Q(sub_quota__sid=self.object.sid), - ) - - @transaction.atomic - def get_result_object(self, form): - """Delete the definition and any linked associations.""" - definition_instance = super().get_result_object(form) - for association in self.related_associations: - association_form = forms.QuotaAssociationUpdateForm( - instance=association, - ) - association_form.instance.new_version( - workbasket=self.workbasket, - update_type=self.update_type, - transaction=definition_instance.transaction, - ) - - return definition_instance - - def form_valid(self, form): - messages.success( - self.request, - f"Quota definition period {self.object.sid} has been deleted", - ) - return super().form_valid(form) - - def get_success_url(self): - return reverse( - "quota_definition-ui-confirm-delete", - kwargs={"sid": self.object.order_number.sid}, - ) - - -class QuotaDefinitionEditUpdate( - QuotaDefinitionUpdateMixin, - EditTaricView, -): - pass - - -class QuotaDefinitionConfirmUpdate( - QuotaDefinitionMixin, - TrackedModelDetailView, -): - template_name = "quota-definitions/confirm-update.jinja" - - class QuotaDefinitionConfirmDelete( QuotaOrderNumberMixin, TrackedModelDetailView, @@ -981,133 +785,6 @@ def get_queryset(self): return QuotaSuspension.objects.filter(pk=self.deleted_suspension) -class SubQuotaDefinitionAssociationMixin: - template_name = "quota-definitions/sub-quota-definitions-updates.jinja" - form_class = forms.SubQuotaDefinitionAssociationUpdateForm - - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["sid"] = self.kwargs["sid"] - kwargs["request"] = self.request - return kwargs - - def dispatch(self, request, *args, **kwargs): - """ - Should a user land on the form for a definition which is not a sub- - quota, perform a redirect. - - This is not possible with current user journeys but this is included for - security and test purposes. - """ - try: - self.association - except models.QuotaAssociation.DoesNotExist: - return HttpResponseRedirect( - reverse( - "quota-ui-detail", - kwargs={"sid": self.sub_quota.order_number.sid}, - ), - ) - return super().dispatch(request, *args, **kwargs) - - def get_success_url(self): - return reverse( - "sub_quota_definition-confirm-update", - kwargs={"sid": self.kwargs["sid"]}, - ) - - @property - def last_transaction(self): - return self.workbasket.transactions.last() - - @property - def sub_quota(self): - return models.QuotaDefinition.objects.current().get(sid=self.kwargs["sid"]) - - @property - def association(self): - return models.QuotaAssociation.objects.current().get( - sub_quota__sid=self.sub_quota.sid, - ) - - def get_main_definition(self): - return self.association.main_quota - - -class SubQuotaDefinitionAssociationUpdate( - SubQuotaDefinitionAssociationMixin, - QuotaDefinitionUpdate, -): - - @transaction.atomic - def get_result_object(self, form): - self.original_association = self.association - instance = super().get_result_object(form) - - sub_quota_relation_type = form.cleaned_data.get("relationship_type") - coefficient = form.cleaned_data.get("coefficient") - - self.update_association(instance, sub_quota_relation_type, coefficient) - - return instance - - def update_association(self, instance, sub_quota_relation_type, coefficient): - "Update the association too if there is updated data submitted." - form_data = { - "main_quota": self.get_main_definition(), - "sub_quota": self.sub_quota, - "coefficient": coefficient, - "sub_quota_relation_type": sub_quota_relation_type, - } - - form = forms.QuotaAssociationUpdateForm( - data=form_data, - instance=self.original_association, - ) - - form.instance.new_version( - workbasket=WorkBasket.current(self.request), - transaction=instance.transaction, - sub_quota=instance, - main_quota=self.get_main_definition(), - coefficient=coefficient, - sub_quota_relation_type=sub_quota_relation_type, - ) - - -class SubQuotaDefinitionAssociationEditUpdate( - SubQuotaDefinitionAssociationMixin, - QuotaDefinitionEditUpdate, -): - - @transaction.atomic - def get_result_object(self, form): - instance = super().get_result_object(form) - - sub_quota_relation_type = form.cleaned_data.get("relationship_type") - coefficient = form.cleaned_data.get("coefficient") - - self.update_association(instance, sub_quota_relation_type, coefficient) - - return instance - - def update_association(self, instance, sub_quota_relation_type, coefficient): - "Update the association too if there is updated data submitted." - current_instance = self.association.version_at(self.last_transaction) - form_data = { - "main_quota": self.get_main_definition(), - "sub_quota": instance, - "coefficient": coefficient, - "sub_quota_relation_type": sub_quota_relation_type, - } - - form = forms.QuotaAssociationUpdateForm( - data=form_data, - instance=current_instance, - ) - form.save() - - class SubQuotaConfirmUpdate(TrackedModelDetailView): model = models.QuotaDefinition template_name = "quota-definitions/sub-quota-definitions-confirm-update.jinja" From 95eb79103144b3b7829ec5050ce331f3673fb9f7 Mon Sep 17 00:00:00 2001 From: Charles Prichard Date: Mon, 28 Oct 2024 17:39:54 +0000 Subject: [PATCH 5/8] tidying up --- quotas/forms/base.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/quotas/forms/base.py b/quotas/forms/base.py index 4e108571e..8db146906 100644 --- a/quotas/forms/base.py +++ b/quotas/forms/base.py @@ -131,27 +131,6 @@ def __init__(self, *args, **kwargs): QuotaDeleteForm = delete_form_for(models.QuotaOrderNumber) -# class QuotaOriginExclusionsForm(forms.Form): -# exclusion = forms.ModelChoiceField( -# label="", -# queryset=GeographicalArea.objects.all(), # modified in __init__ -# help_text="Select a country to be excluded:", -# required=False, -# ) - -# def __init__(self, *args, **kwargs): -# super().__init__(*args, **kwargs) -# self.fields["exclusion"].queryset = ( -# GeographicalArea.objects.current() -# .with_latest_description() -# .as_at_today_and_beyond() -# .order_by("description") -# ) -# self.fields["exclusion"].label_from_instance = ( -# lambda obj: f"{obj.area_id} - {obj.description}" -# ) - - class QuotaUpdateForm( QuotaOriginsReactMixin, ValidityPeriodForm, From a83b0e4264e6bc32e0bcf6d03f725909ae841f0b Mon Sep 17 00:00:00 2001 From: Charles Prichard Date: Tue, 29 Oct 2024 09:16:40 +0000 Subject: [PATCH 6/8] splitting out definition forms and views --- quotas/forms/base.py | 295 ------------------ quotas/forms/definitions.py | 581 ++++++++++++++++++++++++++++++++++++ quotas/views/definitions.py | 348 +++++++++++++++++++++ 3 files changed, 929 insertions(+), 295 deletions(-) create mode 100644 quotas/forms/definitions.py create mode 100644 quotas/views/definitions.py diff --git a/quotas/forms/base.py b/quotas/forms/base.py index 8db146906..0b73baf74 100644 --- a/quotas/forms/base.py +++ b/quotas/forms/base.py @@ -1,5 +1,3 @@ -from datetime import date - from crispy_forms_gds.helper import FormHelper from crispy_forms_gds.layout import HTML from crispy_forms_gds.layout import Accordion @@ -27,21 +25,16 @@ 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.constants import QUOTA_EXCLUSIONS_FORMSET_PREFIX from quotas.constants import QUOTA_ORIGIN_EXCLUSIONS_FORMSET_PREFIX from quotas.constants import QUOTA_ORIGINS_FORMSET_PREFIX -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" @@ -798,291 +791,3 @@ class Meta: QuotaSuspensionDeleteForm = delete_form_for(models.QuotaSuspension) - - -class SubQuotaDefinitionsUpdatesForm( - ValidityPeriodForm, -): - """Form used to edit duplicated sub-quota definitions and associations as - part of the sub-quota create journey.""" - - class Meta: - model = models.QuotaDefinition - fields = [ - "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(), - 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="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", - }, - ) - - measurement_unit = forms.ModelChoiceField( - label="Measurement unit", - 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", - ), - ), - ) - - -class SubQuotaDefinitionAssociationUpdateForm(SubQuotaDefinitionsUpdatesForm): - """Form used to update sub-quota definitions and associations as part of the - edit sub-quotas journey.""" - - def __init__(self, *args, **kwargs): - self.request = kwargs.pop("request", None) - self.workbasket = self.request.user.current_workbasket - sub_quota_definition_sid = kwargs.pop("sid") - ValidityPeriodForm.__init__(self, *args, **kwargs) - self.sub_quota = models.QuotaDefinition.objects.current().get( - sid=sub_quota_definition_sid, - ) - self.init_fields() - self.set_initial_data() - self.init_layout(self.request) - - def set_initial_data(self): - association = models.QuotaAssociation.objects.current().get( - sub_quota__sid=self.sub_quota.sid, - ) - self.original_definition = association.main_quota - fields = self.fields - fields["relationship_type"].initial = association.sub_quota_relation_type - fields["coefficient"].initial = association.coefficient - fields["measurement_unit"].initial = self.sub_quota.measurement_unit - fields["initial_volume"].initial = self.sub_quota.initial_volume - fields["volume"].initial = self.sub_quota.volume - fields["start_date"].initial = self.sub_quota.valid_between.lower - fields["end_date"].initial = self.sub_quota.valid_between.upper - - def init_fields(self): - super().init_fields() - if self.sub_quota.valid_between.lower <= date.today(): - self.fields["coefficient"].disabled = True - self.fields["relationship_type"].disabled = True - self.fields["start_date"].disabled = True - self.fields["initial_volume"].disabled = True - self.fields["volume"].disabled = True - self.fields["measurement_unit"].disabled = True - - -class QuotaAssociationUpdateForm(forms.ModelForm): - class Meta: - model = models.QuotaAssociation - fields = [ - "sub_quota_relation_type", - "coefficient", - "main_quota", - "sub_quota", - ] diff --git a/quotas/forms/definitions.py b/quotas/forms/definitions.py new file mode 100644 index 000000000..db8686083 --- /dev/null +++ b/quotas/forms/definitions.py @@ -0,0 +1,581 @@ +from datetime import date + +from crispy_forms_gds.helper import FormHelper +from crispy_forms_gds.layout import HTML +from crispy_forms_gds.layout import Accordion +from crispy_forms_gds.layout import AccordionSection +from crispy_forms_gds.layout import Div +from crispy_forms_gds.layout import Field +from crispy_forms_gds.layout import Layout +from crispy_forms_gds.layout import Size +from crispy_forms_gds.layout import Submit +from django import forms +from django.core.exceptions import ValidationError + +from common.forms import ValidityPeriodForm +from common.serializers import deserialize_date +from measures.models import MeasurementUnit +from quotas import business_rules +from quotas import models +from quotas import validators +from workbaskets.models import WorkBasket + +RELATIONSHIP_TYPE_HELP_TEXT = "Select the relationship type for the quota association" +COEFFICIENT_HELP_TEXT = "Select the coefficient for the quota association" + + +class QuotaDefinitionMixin: + model = models.QuotaDefinition + + def get_queryset(self): + tx = WorkBasket.get_current_transaction(self.request) + return models.QuotaDefinition.objects.approved_up_to_transaction(tx) + + +class QuotaDefinitionUpdateForm( + ValidityPeriodForm, + forms.ModelForm, +): + class Meta: + model = models.QuotaDefinition + fields = [ + "valid_between", + "description", + "volume", + "initial_volume", + "measurement_unit", + "measurement_unit_qualifier", + "quota_critical_threshold", + "quota_critical", + ] + + description = forms.CharField(label="", widget=forms.Textarea(), required=False) + volume = forms.DecimalField( + label="Current volume", + widget=forms.TextInput(), + error_messages={ + "invalid": "Volume must be a number", + "required": "Enter the volume", + }, + ) + initial_volume = forms.DecimalField( + widget=forms.TextInput(), + error_messages={ + "invalid": "Initial volume must be a number", + "required": "Enter the initial volume", + }, + ) + measurement_unit = forms.ModelChoiceField( + queryset=MeasurementUnit.objects.current(), + error_messages={"required": "Select the measurement unit"}, + ) + quota_critical_threshold = forms.DecimalField( + label="Threshold", + help_text="The point at which this quota definition period becomes critical, as a percentage of the total volume.", + widget=forms.TextInput(), + error_messages={ + "invalid": "Critical threshold must be a number", + "required": "Enter the critical threshold", + }, + ) + quota_critical = forms.TypedChoiceField( + label="Is the quota definition period in a critical state?", + help_text="This determines if a trader needs to pay securities when utilising the quota.", + coerce=lambda value: value == "True", + choices=((True, "Yes"), (False, "No")), + widget=forms.RadioSelect(), + error_messages={"required": "Critical state must be set"}, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.init_layout() + self.init_fields() + + def clean(self): + validators.validate_quota_volume(self.cleaned_data) + return super().clean() + + def init_fields(self): + self.fields["measurement_unit"].queryset = self.fields[ + "measurement_unit" + ].queryset.order_by("code") + self.fields["measurement_unit"].label_from_instance = ( + lambda obj: f"{obj.code} - {obj.description}" + ) + + self.fields["measurement_unit_qualifier"].queryset = self.fields[ + "measurement_unit_qualifier" + ].queryset.order_by("code") + self.fields["measurement_unit_qualifier"].label_from_instance = ( + lambda obj: f"{obj.code} - {obj.description}" + ) + + def init_layout(self): + self.helper = FormHelper(self) + self.helper.label_size = Size.SMALL + self.helper.legend_size = Size.SMALL + + self.helper.layout = Layout( + Accordion( + AccordionSection( + "Description", + "description", + ), + AccordionSection( + "Validity period", + "start_date", + "end_date", + ), + AccordionSection( + "Measurements", + Field("measurement_unit", css_class="govuk-!-width-full"), + Field("measurement_unit_qualifier", css_class="govuk-!-width-full"), + ), + AccordionSection( + "Volume", + "initial_volume", + "volume", + ), + AccordionSection( + "Criticality", + "quota_critical_threshold", + "quota_critical", + ), + css_class="govuk-!-width-two-thirds", + ), + Submit( + "submit", + "Save", + data_module="govuk-button", + data_prevent_double_click="true", + ), + ) + + +class QuotaDefinitionCreateForm( + ValidityPeriodForm, + forms.ModelForm, +): + class Meta: + model = models.QuotaDefinition + fields = [ + "valid_between", + "description", + "volume", + "initial_volume", + "measurement_unit", + "measurement_unit_qualifier", + "quota_critical_threshold", + "quota_critical", + "maximum_precision", + ] + + description = forms.CharField(label="", widget=forms.Textarea(), required=False) + volume = forms.DecimalField( + label="Current volume", + widget=forms.TextInput(), + error_messages={ + "invalid": "Volume must be a number", + "required": "Enter the volume", + }, + ) + initial_volume = forms.DecimalField( + widget=forms.TextInput(), + error_messages={ + "invalid": "Initial volume must be a number", + "required": "Enter the initial volume", + }, + ) + measurement_unit = forms.ModelChoiceField( + queryset=MeasurementUnit.objects.current(), + error_messages={"required": "Select the measurement unit"}, + ) + + quota_critical_threshold = forms.DecimalField( + label="Threshold", + help_text="The point at which this quota definition period becomes critical, as a percentage of the total volume.", + widget=forms.TextInput(), + error_messages={ + "invalid": "Critical threshold must be a number", + "required": "Enter the critical threshold", + }, + ) + quota_critical = forms.TypedChoiceField( + label="Is the quota definition period in a critical state?", + help_text="This determines if a trader needs to pay securities when utilising the quota.", + coerce=lambda value: value == "True", + choices=((True, "Yes"), (False, "No")), + widget=forms.RadioSelect(), + error_messages={"required": "Critical state must be set"}, + ) + maximum_precision = forms.IntegerField( + widget=forms.HiddenInput(), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.init_layout() + self.init_fields() + + def clean(self): + validators.validate_quota_volume(self.cleaned_data) + return super().clean() + + def init_fields(self): + # This is always set to 3 for current definitions + # see https://uktrade.github.io/tariff-data-manual/documentation/data-structures/quotas.html#the-quota-definition-table + self.fields["maximum_precision"].initial = 3 + + # Set these as the default values + self.fields["quota_critical"].initial = False + self.fields["quota_critical_threshold"].initial = 90 + + self.fields["measurement_unit"].queryset = self.fields[ + "measurement_unit" + ].queryset.order_by("code") + self.fields["measurement_unit"].label_from_instance = ( + lambda obj: f"{obj.code} - {obj.description}" + ) + + self.fields["measurement_unit_qualifier"].queryset = self.fields[ + "measurement_unit_qualifier" + ].queryset.order_by("code") + self.fields["measurement_unit_qualifier"].label_from_instance = ( + lambda obj: f"{obj.code} - {obj.description}" + ) + + def init_layout(self): + self.helper = FormHelper(self) + self.helper.label_size = Size.SMALL + self.helper.legend_size = Size.SMALL + + self.helper.layout = Layout( + Accordion( + AccordionSection( + "Description", + HTML.p("Adding a description is optional."), + "description", + "order_number", + ), + AccordionSection( + "Validity period", + "start_date", + "end_date", + ), + AccordionSection( + "Measurements", + HTML.p("A measurement unit qualifier is not always required."), + Field("measurement_unit", css_class="govuk-!-width-full"), + Field("measurement_unit_qualifier", css_class="govuk-!-width-full"), + ), + AccordionSection( + "Volume", + HTML.p( + "The initial volume is the legal balance applied to the definition period.

The current volume is the starting balance for the quota.", + ), + "initial_volume", + "volume", + "maximum_precision", + ), + AccordionSection( + "Criticality", + "quota_critical_threshold", + "quota_critical", + ), + ), + Submit( + "submit", + "Save", + data_module="govuk-button", + data_prevent_double_click="true", + ), + ) + + +class SubQuotaDefinitionsUpdatesForm( + ValidityPeriodForm, +): + """Form used to edit duplicated sub-quota definitions and associations as + part of the sub-quota create journey.""" + + class Meta: + model = models.QuotaDefinition + fields = [ + "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(), + 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="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", + }, + ) + + measurement_unit = forms.ModelChoiceField( + label="Measurement unit", + 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", + ), + ), + ) + + +class SubQuotaDefinitionAssociationUpdateForm(SubQuotaDefinitionsUpdatesForm): + """Form used to update sub-quota definitions and associations as part of the + edit sub-quotas journey.""" + + def __init__(self, *args, **kwargs): + self.request = kwargs.pop("request", None) + self.workbasket = self.request.user.current_workbasket + sub_quota_definition_sid = kwargs.pop("sid") + ValidityPeriodForm.__init__(self, *args, **kwargs) + self.sub_quota = models.QuotaDefinition.objects.current().get( + sid=sub_quota_definition_sid, + ) + self.init_fields() + self.set_initial_data() + self.init_layout(self.request) + + def set_initial_data(self): + association = models.QuotaAssociation.objects.current().get( + sub_quota__sid=self.sub_quota.sid, + ) + self.original_definition = association.main_quota + fields = self.fields + fields["relationship_type"].initial = association.sub_quota_relation_type + fields["coefficient"].initial = association.coefficient + fields["measurement_unit"].initial = self.sub_quota.measurement_unit + fields["initial_volume"].initial = self.sub_quota.initial_volume + fields["volume"].initial = self.sub_quota.volume + fields["start_date"].initial = self.sub_quota.valid_between.lower + fields["end_date"].initial = self.sub_quota.valid_between.upper + + def init_fields(self): + super().init_fields() + if self.sub_quota.valid_between.lower <= date.today(): + self.fields["coefficient"].disabled = True + self.fields["relationship_type"].disabled = True + self.fields["start_date"].disabled = True + self.fields["initial_volume"].disabled = True + self.fields["volume"].disabled = True + self.fields["measurement_unit"].disabled = True + + +class QuotaAssociationUpdateForm(forms.ModelForm): + class Meta: + model = models.QuotaAssociation + fields = [ + "sub_quota_relation_type", + "coefficient", + "main_quota", + "sub_quota", + ] diff --git a/quotas/views/definitions.py b/quotas/views/definitions.py new file mode 100644 index 000000000..715d764ff --- /dev/null +++ b/quotas/views/definitions.py @@ -0,0 +1,348 @@ +from django.contrib import messages +from django.db import transaction +from django.db.models import Q +from django.http import HttpResponseRedirect +from django.urls import reverse +from django.utils.functional import cached_property +from django.views.generic.list import ListView +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.tariffs_api import get_quota_definitions_data +from common.views import SortingMixin +from common.views import TrackedModelDetailMixin +from common.views import TrackedModelDetailView +from quotas import business_rules +from quotas import forms +from quotas import models +from quotas import serializers +from quotas.models import QuotaAssociation +from quotas.models import QuotaBlocking +from quotas.models import QuotaSuspension +from workbaskets.models import WorkBasket +from workbaskets.views.generic import CreateTaricCreateView +from workbaskets.views.generic import CreateTaricDeleteView +from workbaskets.views.generic import CreateTaricUpdateView +from workbaskets.views.generic import EditTaricView + + +class QuotaDefinitionViewset(viewsets.ReadOnlyModelViewSet): + queryset = models.QuotaDefinition.objects.has_approved_state() + serializer_class = serializers.QuotaDefinitionSerializer + permission_classes = [permissions.IsAuthenticated] + search_fields = ["sid", "order_number__order_number", "description"] + + +class QuotaDefinitionList(SortingMixin, ListView): + template_name = "quotas/definitions.jinja" + model = models.QuotaDefinition + sort_by_fields = ["sid", "valid_between"] + + def get_queryset(self): + queryset = ( + models.QuotaDefinition.objects.filter( + order_number__sid=self.quota.sid, + ) + .current() + .order_by("pk") + ) + + ordering = self.get_ordering() + if ordering: + if isinstance(ordering, str): + ordering = (ordering,) + queryset = queryset.order_by(*ordering) + + return queryset + + @property + def blocking_periods(self): + return QuotaBlocking.objects.filter(quota_definition__order_number=self.quota) + + @property + def suspension_periods(self): + return ( + QuotaSuspension.objects.current() + .filter(quota_definition__order_number=self.quota) + .order_by("quota_definition__sid") + ) + + @property + def sub_quotas(self): + return ( + QuotaAssociation.objects.current() + .filter(main_quota__order_number=self.quota) + .order_by("sub_quota__sid") + ) + + @property + def main_quotas(self): + main_quotas = QuotaAssociation.objects.current().filter( + sub_quota__order_number=self.quota, + ) + return main_quotas + + @cached_property + def quota_data(self): + if not self.kwargs.get("quota_type"): + return get_quota_definitions_data(self.quota.order_number, self.object_list) + return None + + @property + def quota(self): + return models.QuotaOrderNumber.objects.current().get(sid=self.kwargs["sid"]) + + def get_context_data(self, *args, **kwargs): + return super().get_context_data( + quota=self.quota, + 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, + main_quotas=self.main_quotas, + *args, + **kwargs, + ) + + +class QuotaDefinitionMixin: + model = models.QuotaDefinition + + def get_queryset(self): + tx = WorkBasket.get_current_transaction(self.request) + return models.QuotaDefinition.objects.approved_up_to_transaction(tx) + + +class QuotaDefinitionUpdateMixin( + QuotaDefinitionMixin, + TrackedModelDetailMixin, +): + form_class = forms.QuotaDefinitionUpdateForm + permission_required = ["common.change_trackedmodel"] + template_name = "quota-definitions/edit.jinja" + + validate_business_rules = ( + business_rules.QD7, + business_rules.QD8, + business_rules.QD10, + business_rules.QD11, + UniqueIdentifyingFields, + UpdateValidity, + ) + + @transaction.atomic + def get_result_object(self, form): + object = super().get_result_object(form) + return object + + +class QuotaDefinitionUpdate( + QuotaDefinitionUpdateMixin, + CreateTaricUpdateView, +): + pass + + +class QuotaDefinitionCreate(QuotaDefinitionUpdateMixin, CreateTaricCreateView): + template_name = "quota-definitions/create.jinja" + form_class = forms.QuotaDefinitionCreateForm + + def form_valid(self, form): + quota = models.QuotaOrderNumber.objects.current().get(sid=self.kwargs["sid"]) + form.instance.order_number = quota + return super().form_valid(form) + + +class QuotaDefinitionConfirmCreate( + QuotaDefinitionMixin, + TrackedModelDetailView, +): + template_name = "quota-definitions/confirm-create.jinja" + + +class QuotaDefinitionDelete( + QuotaDefinitionUpdateMixin, + CreateTaricDeleteView, +): + form_class = delete_form_for(models.QuotaDefinition) + template_name = "quota-definitions/delete.jinja" + + @property + def related_associations(self): + return models.QuotaAssociation.objects.current().filter( + Q(main_quota__sid=self.object.sid) | Q(sub_quota__sid=self.object.sid), + ) + + @transaction.atomic + def get_result_object(self, form): + """Delete the definition and any linked associations.""" + definition_instance = super().get_result_object(form) + for association in self.related_associations: + association_form = forms.QuotaAssociationUpdateForm( + instance=association, + ) + association_form.instance.new_version( + workbasket=self.workbasket, + update_type=self.update_type, + transaction=definition_instance.transaction, + ) + + return definition_instance + + def form_valid(self, form): + messages.success( + self.request, + f"Quota definition period {self.object.sid} has been deleted", + ) + return super().form_valid(form) + + def get_success_url(self): + return reverse( + "quota_definition-ui-confirm-delete", + kwargs={"sid": self.object.order_number.sid}, + ) + + +class QuotaDefinitionEditUpdate( + QuotaDefinitionUpdateMixin, + EditTaricView, +): + pass + + +class QuotaDefinitionConfirmUpdate( + QuotaDefinitionMixin, + TrackedModelDetailView, +): + template_name = "quota-definitions/confirm-update.jinja" + + +class SubQuotaDefinitionAssociationMixin: + template_name = "quota-definitions/sub-quota-definitions-updates.jinja" + form_class = forms.SubQuotaDefinitionAssociationUpdateForm + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["sid"] = self.kwargs["sid"] + kwargs["request"] = self.request + return kwargs + + def dispatch(self, request, *args, **kwargs): + """ + Should a user land on the form for a definition which is not a sub- + quota, perform a redirect. + + This is not possible with current user journeys but this is included for + security and test purposes. + """ + try: + self.association + except models.QuotaAssociation.DoesNotExist: + return HttpResponseRedirect( + reverse( + "quota-ui-detail", + kwargs={"sid": self.sub_quota.order_number.sid}, + ), + ) + return super().dispatch(request, *args, **kwargs) + + def get_success_url(self): + return reverse( + "sub_quota_definition-confirm-update", + kwargs={"sid": self.kwargs["sid"]}, + ) + + @property + def last_transaction(self): + return self.workbasket.transactions.last() + + @property + def sub_quota(self): + return models.QuotaDefinition.objects.current().get(sid=self.kwargs["sid"]) + + @property + def association(self): + return models.QuotaAssociation.objects.current().get( + sub_quota__sid=self.sub_quota.sid, + ) + + def get_main_definition(self): + return self.association.main_quota + + +class SubQuotaDefinitionAssociationUpdate( + SubQuotaDefinitionAssociationMixin, + QuotaDefinitionUpdate, +): + + @transaction.atomic + def get_result_object(self, form): + self.original_association = self.association + instance = super().get_result_object(form) + + sub_quota_relation_type = form.cleaned_data.get("relationship_type") + coefficient = form.cleaned_data.get("coefficient") + + self.update_association(instance, sub_quota_relation_type, coefficient) + + return instance + + def update_association(self, instance, sub_quota_relation_type, coefficient): + "Update the association too if there is updated data submitted." + form_data = { + "main_quota": self.get_main_definition(), + "sub_quota": self.sub_quota, + "coefficient": coefficient, + "sub_quota_relation_type": sub_quota_relation_type, + } + + form = forms.QuotaAssociationUpdateForm( + data=form_data, + instance=self.original_association, + ) + + form.instance.new_version( + workbasket=WorkBasket.current(self.request), + transaction=instance.transaction, + sub_quota=instance, + main_quota=self.get_main_definition(), + coefficient=coefficient, + sub_quota_relation_type=sub_quota_relation_type, + ) + + +class SubQuotaDefinitionAssociationEditUpdate( + SubQuotaDefinitionAssociationMixin, + QuotaDefinitionEditUpdate, +): + + @transaction.atomic + def get_result_object(self, form): + instance = super().get_result_object(form) + + sub_quota_relation_type = form.cleaned_data.get("relationship_type") + coefficient = form.cleaned_data.get("coefficient") + + self.update_association(instance, sub_quota_relation_type, coefficient) + + return instance + + def update_association(self, instance, sub_quota_relation_type, coefficient): + "Update the association too if there is updated data submitted." + current_instance = self.association.version_at(self.last_transaction) + form_data = { + "main_quota": self.get_main_definition(), + "sub_quota": instance, + "coefficient": coefficient, + "sub_quota_relation_type": sub_quota_relation_type, + } + + form = forms.QuotaAssociationUpdateForm( + data=form_data, + instance=current_instance, + ) + form.save() From c959e36975acd053efdf75311bd41aba91a494ee Mon Sep 17 00:00:00 2001 From: Charles Prichard Date: Wed, 30 Oct 2024 09:58:36 +0000 Subject: [PATCH 7/8] formatting --- quotas/forms/__init__.py | 1 + quotas/forms/base.py | 389 --------------------------------- quotas/forms/react_forms.py | 413 ++++++++++++++++++++++++++++++++++++ quotas/views/__init__.py | 1 + quotas/views/base.py | 349 +----------------------------- quotas/views/definitions.py | 63 ++++++ quotas/views/mixins.py | 291 +++++++++++++++++++++++++ 7 files changed, 774 insertions(+), 733 deletions(-) create mode 100644 quotas/forms/react_forms.py create mode 100644 quotas/views/mixins.py diff --git a/quotas/forms/__init__.py b/quotas/forms/__init__.py index e5c5ff603..9467e517f 100644 --- a/quotas/forms/__init__.py +++ b/quotas/forms/__init__.py @@ -1,3 +1,4 @@ from .base import * from .definitions import * +from .react_forms import * from .wizards import * diff --git a/quotas/forms/base.py b/quotas/forms/base.py index 0b73baf74..2ee5f3c5b 100644 --- a/quotas/forms/base.py +++ b/quotas/forms/base.py @@ -1,7 +1,5 @@ from crispy_forms_gds.helper import FormHelper from crispy_forms_gds.layout import HTML -from crispy_forms_gds.layout import Accordion -from crispy_forms_gds.layout import AccordionSection from crispy_forms_gds.layout import Button from crispy_forms_gds.layout import Div from crispy_forms_gds.layout import Field @@ -11,11 +9,9 @@ from django import forms from django.core.exceptions import ValidationError from django.db.models import TextChoices -from django.template.loader import render_to_string from django.urls import reverse_lazy from common.forms import BindNestedFormMixin -from common.forms import ExtraErrorFormMixin from common.forms import FormSet from common.forms import FormSetField from common.forms import FormSetSubmitMixin @@ -31,9 +27,7 @@ from geo_areas.models import GeographicalArea from quotas import models from quotas import validators -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 CATEGORY_HELP_TEXT = "Categories are required for the TAP database but will not appear as a TARIC3 object in your workbasket" SAFEGUARD_HELP_TEXT = ( @@ -43,65 +37,6 @@ ORDER_NUMBER_HELP_TEXT = "The order number must begin with 05 and be 6 digits long. Licensed quotas must begin 054 and safeguards must begin 058" -class QuotaOriginsReactMixin(ExtraErrorFormMixin): - """Custom cleaning and validation for QuotaUpdateForm and - QuotaOrderNumberCreateForm.""" - - def clean_quota_origins(self): - # unprefix origins formset - submitted_data = unprefix_formset_data( - QUOTA_ORIGINS_FORMSET_PREFIX, - self.data.copy(), - ) - # for each origin, unprefix exclusions formset - for i, origin_data in enumerate(submitted_data): - exclusions = unprefix_formset_data( - QUOTA_EXCLUSIONS_FORMSET_PREFIX, - origin_data.copy(), - ) - submitted_data[i]["exclusions"] = exclusions - - self.cleaned_data["origins"] = [] - - for i, origin_data in enumerate(submitted_data): - # instantiate a form per origin data to do validation - origin_form = QuotaOrderNumberOriginUpdateReactForm( - data=origin_data, - initial=origin_data, - instance=( - models.QuotaOrderNumberOrigin.objects.get(pk=origin_data["pk"]) - if origin_data.get("pk") - else None - ), - ) - - cleaned_exclusions = [] - - for exclusion in origin_data["exclusions"]: - exclusion_form = QuotaOriginExclusionsReactForm( - data=exclusion, - initial=exclusion, - ) - if not exclusion_form.is_valid(): - for j, item in enumerate(exclusion_form.errors.as_data().items()): - self.add_extra_error( - f"{QUOTA_ORIGINS_FORMSET_PREFIX}-{i}-exclusions-{j}-{item[0]}", - item[1], - ) - else: - cleaned_exclusions.append(exclusion_form.cleaned_data) - - if not origin_form.is_valid(): - for field, e in origin_form.errors.as_data().items(): - self.add_extra_error( - f"{QUOTA_ORIGINS_FORMSET_PREFIX}-{i}-{field}", - e, - ) - else: - origin_form.cleaned_data["exclusions"] = cleaned_exclusions - self.cleaned_data["origins"].append(origin_form.cleaned_data) - - class QuotaFilterForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -124,302 +59,6 @@ def __init__(self, *args, **kwargs): QuotaDeleteForm = delete_form_for(models.QuotaOrderNumber) -class QuotaUpdateForm( - QuotaOriginsReactMixin, - ValidityPeriodForm, - forms.ModelForm, -): - class Meta: - model = models.QuotaOrderNumber - fields = [ - "valid_between", - "category", - ] - - category = forms.ChoiceField( - label="", - choices=validators.QuotaCategory.choices, - error_messages={"invalid_choice": "Please select a valid category"}, - ) - - def clean_category(self): - value = self.cleaned_data.get("category") - # the widget is disabled and data is not submitted. fall back to instance value - if not value: - return self.instance.category - if ( - self.instance.category == validators.QuotaCategory.SAFEGUARD - and value != validators.QuotaCategory.SAFEGUARD - ): - raise ValidationError(SAFEGUARD_HELP_TEXT) - return value - - def __init__(self, *args, **kwargs): - self.request = kwargs.pop("request") - self.exclusions_options = kwargs.pop("exclusions_options") - self.geo_area_options = kwargs.pop("geo_area_options") - self.groups_with_members = kwargs.pop("groups_with_members") - self.existing_origins = kwargs.pop("existing_origins") - super().__init__(*args, **kwargs) - self.init_fields() - self.set_initial_data(*args, **kwargs) - self.init_layout(self.request) - - def set_initial_data(self, *args, **kwargs): - self.fields["category"].initial = self.instance.category - - def init_fields(self): - if self.instance.category == validators.QuotaCategory.SAFEGUARD: - self.fields["category"].required = False - self.fields["category"].widget = forms.Select( - choices=[ - ( - validators.QuotaCategory.SAFEGUARD.value, - validators.QuotaCategory.SAFEGUARD.label, - ), - ], - attrs={"disabled": True}, - ) - self.fields["category"].help_text = SAFEGUARD_HELP_TEXT - else: - self.fields["category"].choices = validators.QuotaCategoryEditing.choices - self.fields["category"].help_text = CATEGORY_HELP_TEXT - - self.fields["start_date"].help_text = START_DATE_HELP_TEXT - - def get_origins_initial(self): - initial = [ - { - "id": o.pk, # unique identifier used by react - "pk": o.pk, - "exclusions": [ - e.excluded_geographical_area.pk - for e in o.quotaordernumberoriginexclusion_set.current() - ], - "geographical_area": o.geographical_area.pk, - "start_date_0": o.valid_between.lower.day, - "start_date_1": o.valid_between.lower.month, - "start_date_2": o.valid_between.lower.year, - "end_date_0": ( - o.valid_between.upper.day if o.valid_between.upper else "" - ), - "end_date_1": ( - o.valid_between.upper.month if o.valid_between.upper else "" - ), - "end_date_2": ( - o.valid_between.upper.year if o.valid_between.upper else "" - ), - } - for o in self.existing_origins - ] - # if we just submitted the form, overwrite initial with submitted data - # this prevents newly added origin data being cleared if the form does not pass validation - if self.data.get("submit"): - new_data = unprefix_formset_data( - QUOTA_ORIGINS_FORMSET_PREFIX, - self.data.copy(), - ) - initial = new_data - - return initial - - def clean(self): - self.clean_quota_origins() - return super().clean() - - def init_layout(self, request): - self.helper = FormHelper(self) - self.helper.label_size = Size.SMALL - self.helper.legend_size = Size.SMALL - - origins_html = render_to_string( - "includes/quotas/quota-edit-origins.jinja", - { - "object": self.instance, - "request": request, - "geo_area_options": self.geo_area_options, - "groups_with_members": self.groups_with_members, - "exclusions_options": self.exclusions_options, - "origins_initial": self.get_origins_initial(), - "errors": self.errors, - }, - ) - - self.helper.layout = Layout( - Div( - Accordion( - AccordionSection( - "Validity period", - "start_date", - "end_date", - ), - AccordionSection( - "Category", - "category", - ), - AccordionSection( - "Quota origins", - Div( - HTML(origins_html), - ), - ), - ), - ), - Submit( - "submit", - "Save", - data_module="govuk-button", - data_prevent_double_click="true", - ), - ) - - -class QuotaOrderNumberCreateForm( - QuotaOriginsReactMixin, - ValidityPeriodForm, - forms.ModelForm, -): - class Meta: - model = models.QuotaOrderNumber - fields = [ - "order_number", - "valid_between", - "category", - "mechanism", - ] - - order_number = forms.CharField( - help_text=ORDER_NUMBER_HELP_TEXT, - validators=[validators.quota_order_number_validator], - error_messages={ - "invalid": "Order number must be six digits long and begin with 05", - "required": "Enter the order number", - }, - ) - category = forms.ChoiceField( - choices=validators.QuotaCategory.choices, - help_text=CATEGORY_HELP_TEXT, - error_messages={ - "invalid_choice": "Please select a valid category", - "required": "Choose the category", - }, - ) - mechanism = forms.ChoiceField( - choices=validators.AdministrationMechanism.choices, - error_messages={ - "invalid_choice": "Please select a valid mechanism", - "required": "Choose the mechanism", - }, - ) - - def __init__(self, *args, **kwargs): - self.request = kwargs.pop("request") - self.exclusions_options = kwargs.pop("exclusions_options") - self.geo_area_options = kwargs.pop("geo_area_options") - self.groups_with_members = kwargs.pop("groups_with_members") - super().__init__(*args, **kwargs) - self.init_fields() - self.init_layout(self.request) - - def init_fields(self): - self.fields["start_date"].help_text = START_DATE_HELP_TEXT - - def get_origins_initial(self): - # if we just submitted the form, overwrite initial with submitted data - # this prevents newly added origin data being cleared if the form does not pass validation - initial = [] - if self.data.get("submit"): - initial = unprefix_formset_data( - QUOTA_ORIGINS_FORMSET_PREFIX, - self.data.copy(), - ) - return initial - - def init_layout(self, request): - self.helper = FormHelper(self) - self.helper.label_size = Size.SMALL - self.helper.legend_size = Size.SMALL - - origins_html = render_to_string( - "includes/quotas/quota-create-origins.jinja", - { - "object": self.instance, - "request": request, - "geo_area_options": self.geo_area_options, - "groups_with_members": self.groups_with_members, - "exclusions_options": self.exclusions_options, - "origins_initial": self.get_origins_initial(), - "errors": self.errors, - }, - ) - - self.helper.layout = Layout( - Accordion( - AccordionSection( - "Order number", - Field("order_number", css_class="govuk-input--width-20"), - ), - AccordionSection( - "Validity", - "start_date", - "end_date", - ), - AccordionSection( - "Category and mechanism", - "category", - "mechanism", - ), - AccordionSection( - "Quota origins", - Div( - HTML(origins_html), - ), - ), - css_class="govuk-width-!-two-thirds", - ), - Submit( - "submit", - "Save", - data_module="govuk-button", - data_prevent_double_click="true", - ), - ) - - def clean(self): - category = self.cleaned_data.get("category") - mechanism = self.cleaned_data.get("mechanism") - order_number = self.cleaned_data.get("order_number", "") - - if ( - mechanism is not None - and int(mechanism) == validators.AdministrationMechanism.LICENSED - ): - if int(category) == validators.QuotaCategory.SAFEGUARD: - raise ValidationError( - "Mechanism cannot be set to licensed for safeguard quotas", - ) - if not order_number.startswith("054"): - raise ValidationError( - "The order number for licensed quotas must begin with 054", - ) - - if ( - category is not None - and int( - category, - ) - == validators.QuotaCategory.SAFEGUARD - and not order_number.startswith("058") - ): - raise ValidationError( - "The order number for safeguard quotas must begin with 058", - ) - - self.clean_quota_origins() - - return super().clean() - - class QuotaOriginExclusionsForm(forms.Form): exclusion = forms.ModelChoiceField( label="", @@ -557,34 +196,6 @@ def get_geo_area_initial(self): return initial -class QuotaOrderNumberOriginUpdateReactForm(QuotaOrderNumberOriginUpdateForm): - """Used only to validate data sent from the quota edit React form.""" - - pk = forms.IntegerField(required=False) - - -class QuotaOriginExclusionsReactForm(forms.Form): - """Used only to validate data sent from the quota edit React form.""" - - pk = forms.IntegerField(required=False) - # field name is different to match the react form - geographical_area = forms.ModelChoiceField( - label="", - queryset=GeographicalArea.objects.all(), - help_text="Select a country to be excluded:", - required=False, - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["geographical_area"].queryset = ( - GeographicalArea.objects.current() - .with_latest_description() - .as_at_today_and_beyond() - .order_by("description") - ) - - class QuotaSuspensionType(TextChoices): SUSPENSION = "SUSPENSION", "Suspension period" BLOCKING = "BLOCKING", "Blocking period" diff --git a/quotas/forms/react_forms.py b/quotas/forms/react_forms.py new file mode 100644 index 000000000..3dd210629 --- /dev/null +++ b/quotas/forms/react_forms.py @@ -0,0 +1,413 @@ +from crispy_forms_gds.helper import FormHelper +from crispy_forms_gds.layout import HTML +from crispy_forms_gds.layout import Accordion +from crispy_forms_gds.layout import AccordionSection +from crispy_forms_gds.layout import Div +from crispy_forms_gds.layout import Field +from crispy_forms_gds.layout import Layout +from crispy_forms_gds.layout import Size +from crispy_forms_gds.layout import Submit +from django import forms +from django.core.exceptions import ValidationError +from django.template.loader import render_to_string + +from common.forms import ExtraErrorFormMixin +from common.forms import ValidityPeriodForm +from common.forms import unprefix_formset_data +from geo_areas.models import GeographicalArea +from quotas import models +from quotas import validators +from quotas.constants import QUOTA_EXCLUSIONS_FORMSET_PREFIX +from quotas.constants import QUOTA_ORIGINS_FORMSET_PREFIX + +from .base import QuotaOrderNumberOriginUpdateForm + +ORDER_NUMBER_HELP_TEXT = "The order number must begin with 05 and be 6 digits long. Licensed quotas must begin 054 and safeguards must begin 058" +SAFEGUARD_HELP_TEXT = ( + "Once the quota category has been set as ‘Safeguard’, this cannot be changed" +) +CATEGORY_HELP_TEXT = "Categories are required for the TAP database but will not appear as a TARIC3 object in your workbasket" +START_DATE_HELP_TEXT = "If possible, avoid putting a start date in the past as this may cause issues with CDS downstream" + + +class QuotaOriginsReactMixin(ExtraErrorFormMixin): + """Custom cleaning and validation for QuotaUpdateForm and + QuotaOrderNumberCreateForm.""" + + def clean_quota_origins(self): + # unprefix origins formset + submitted_data = unprefix_formset_data( + QUOTA_ORIGINS_FORMSET_PREFIX, + self.data.copy(), + ) + # for each origin, unprefix exclusions formset + for i, origin_data in enumerate(submitted_data): + exclusions = unprefix_formset_data( + QUOTA_EXCLUSIONS_FORMSET_PREFIX, + origin_data.copy(), + ) + submitted_data[i]["exclusions"] = exclusions + + self.cleaned_data["origins"] = [] + + for i, origin_data in enumerate(submitted_data): + # instantiate a form per origin data to do validation + origin_form = QuotaOrderNumberOriginUpdateReactForm( + data=origin_data, + initial=origin_data, + instance=( + models.QuotaOrderNumberOrigin.objects.get(pk=origin_data["pk"]) + if origin_data.get("pk") + else None + ), + ) + + cleaned_exclusions = [] + + for exclusion in origin_data["exclusions"]: + exclusion_form = QuotaOriginExclusionsReactForm( + data=exclusion, + initial=exclusion, + ) + if not exclusion_form.is_valid(): + for j, item in enumerate(exclusion_form.errors.as_data().items()): + self.add_extra_error( + f"{QUOTA_ORIGINS_FORMSET_PREFIX}-{i}-exclusions-{j}-{item[0]}", + item[1], + ) + else: + cleaned_exclusions.append(exclusion_form.cleaned_data) + + if not origin_form.is_valid(): + for field, e in origin_form.errors.as_data().items(): + self.add_extra_error( + f"{QUOTA_ORIGINS_FORMSET_PREFIX}-{i}-{field}", + e, + ) + else: + origin_form.cleaned_data["exclusions"] = cleaned_exclusions + self.cleaned_data["origins"].append(origin_form.cleaned_data) + + +class QuotaOrderNumberOriginUpdateReactForm(QuotaOrderNumberOriginUpdateForm): + """Used only to validate data sent from the quota edit React form.""" + + pk = forms.IntegerField(required=False) + + +class QuotaOriginExclusionsReactForm(forms.Form): + """Used only to validate data sent from the quota edit React form.""" + + pk = forms.IntegerField(required=False) + # field name is different to match the react form + geographical_area = forms.ModelChoiceField( + label="", + queryset=GeographicalArea.objects.all(), + help_text="Select a country to be excluded:", + required=False, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["geographical_area"].queryset = ( + GeographicalArea.objects.current() + .with_latest_description() + .as_at_today_and_beyond() + .order_by("description") + ) + + +class QuotaUpdateForm( + QuotaOriginsReactMixin, + ValidityPeriodForm, + forms.ModelForm, +): + class Meta: + model = models.QuotaOrderNumber + fields = [ + "valid_between", + "category", + ] + + category = forms.ChoiceField( + label="", + choices=validators.QuotaCategory.choices, + error_messages={"invalid_choice": "Please select a valid category"}, + ) + + def clean_category(self): + value = self.cleaned_data.get("category") + # the widget is disabled and data is not submitted. fall back to instance value + if not value: + return self.instance.category + if ( + self.instance.category == validators.QuotaCategory.SAFEGUARD + and value != validators.QuotaCategory.SAFEGUARD + ): + raise ValidationError(SAFEGUARD_HELP_TEXT) + return value + + def __init__(self, *args, **kwargs): + self.request = kwargs.pop("request") + self.exclusions_options = kwargs.pop("exclusions_options") + self.geo_area_options = kwargs.pop("geo_area_options") + self.groups_with_members = kwargs.pop("groups_with_members") + self.existing_origins = kwargs.pop("existing_origins") + super().__init__(*args, **kwargs) + self.init_fields() + self.set_initial_data(*args, **kwargs) + self.init_layout(self.request) + + def set_initial_data(self, *args, **kwargs): + self.fields["category"].initial = self.instance.category + + def init_fields(self): + if self.instance.category == validators.QuotaCategory.SAFEGUARD: + self.fields["category"].required = False + self.fields["category"].widget = forms.Select( + choices=[ + ( + validators.QuotaCategory.SAFEGUARD.value, + validators.QuotaCategory.SAFEGUARD.label, + ), + ], + attrs={"disabled": True}, + ) + self.fields["category"].help_text = SAFEGUARD_HELP_TEXT + else: + self.fields["category"].choices = validators.QuotaCategoryEditing.choices + self.fields["category"].help_text = CATEGORY_HELP_TEXT + + self.fields["start_date"].help_text = START_DATE_HELP_TEXT + + def get_origins_initial(self): + initial = [ + { + "id": o.pk, # unique identifier used by react + "pk": o.pk, + "exclusions": [ + e.excluded_geographical_area.pk + for e in o.quotaordernumberoriginexclusion_set.current() + ], + "geographical_area": o.geographical_area.pk, + "start_date_0": o.valid_between.lower.day, + "start_date_1": o.valid_between.lower.month, + "start_date_2": o.valid_between.lower.year, + "end_date_0": ( + o.valid_between.upper.day if o.valid_between.upper else "" + ), + "end_date_1": ( + o.valid_between.upper.month if o.valid_between.upper else "" + ), + "end_date_2": ( + o.valid_between.upper.year if o.valid_between.upper else "" + ), + } + for o in self.existing_origins + ] + # if we just submitted the form, overwrite initial with submitted data + # this prevents newly added origin data being cleared if the form does not pass validation + if self.data.get("submit"): + new_data = unprefix_formset_data( + QUOTA_ORIGINS_FORMSET_PREFIX, + self.data.copy(), + ) + initial = new_data + + return initial + + def clean(self): + self.clean_quota_origins() + return super().clean() + + def init_layout(self, request): + self.helper = FormHelper(self) + self.helper.label_size = Size.SMALL + self.helper.legend_size = Size.SMALL + + origins_html = render_to_string( + "includes/quotas/quota-edit-origins.jinja", + { + "object": self.instance, + "request": request, + "geo_area_options": self.geo_area_options, + "groups_with_members": self.groups_with_members, + "exclusions_options": self.exclusions_options, + "origins_initial": self.get_origins_initial(), + "errors": self.errors, + }, + ) + + self.helper.layout = Layout( + Div( + Accordion( + AccordionSection( + "Validity period", + "start_date", + "end_date", + ), + AccordionSection( + "Category", + "category", + ), + AccordionSection( + "Quota origins", + Div( + HTML(origins_html), + ), + ), + ), + ), + Submit( + "submit", + "Save", + data_module="govuk-button", + data_prevent_double_click="true", + ), + ) + + +class QuotaOrderNumberCreateForm( + QuotaOriginsReactMixin, + ValidityPeriodForm, + forms.ModelForm, +): + class Meta: + model = models.QuotaOrderNumber + fields = [ + "order_number", + "valid_between", + "category", + "mechanism", + ] + + order_number = forms.CharField( + help_text=ORDER_NUMBER_HELP_TEXT, + validators=[validators.quota_order_number_validator], + error_messages={ + "invalid": "Order number must be six digits long and begin with 05", + "required": "Enter the order number", + }, + ) + category = forms.ChoiceField( + choices=validators.QuotaCategory.choices, + help_text=CATEGORY_HELP_TEXT, + error_messages={ + "invalid_choice": "Please select a valid category", + "required": "Choose the category", + }, + ) + mechanism = forms.ChoiceField( + choices=validators.AdministrationMechanism.choices, + error_messages={ + "invalid_choice": "Please select a valid mechanism", + "required": "Choose the mechanism", + }, + ) + + def __init__(self, *args, **kwargs): + self.request = kwargs.pop("request") + self.exclusions_options = kwargs.pop("exclusions_options") + self.geo_area_options = kwargs.pop("geo_area_options") + self.groups_with_members = kwargs.pop("groups_with_members") + super().__init__(*args, **kwargs) + self.init_fields() + self.init_layout(self.request) + + def init_fields(self): + self.fields["start_date"].help_text = START_DATE_HELP_TEXT + + def get_origins_initial(self): + # if we just submitted the form, overwrite initial with submitted data + # this prevents newly added origin data being cleared if the form does not pass validation + initial = [] + if self.data.get("submit"): + initial = unprefix_formset_data( + QUOTA_ORIGINS_FORMSET_PREFIX, + self.data.copy(), + ) + return initial + + def init_layout(self, request): + self.helper = FormHelper(self) + self.helper.label_size = Size.SMALL + self.helper.legend_size = Size.SMALL + + origins_html = render_to_string( + "includes/quotas/quota-create-origins.jinja", + { + "object": self.instance, + "request": request, + "geo_area_options": self.geo_area_options, + "groups_with_members": self.groups_with_members, + "exclusions_options": self.exclusions_options, + "origins_initial": self.get_origins_initial(), + "errors": self.errors, + }, + ) + + self.helper.layout = Layout( + Accordion( + AccordionSection( + "Order number", + Field("order_number", css_class="govuk-input--width-20"), + ), + AccordionSection( + "Validity", + "start_date", + "end_date", + ), + AccordionSection( + "Category and mechanism", + "category", + "mechanism", + ), + AccordionSection( + "Quota origins", + Div( + HTML(origins_html), + ), + ), + css_class="govuk-width-!-two-thirds", + ), + Submit( + "submit", + "Save", + data_module="govuk-button", + data_prevent_double_click="true", + ), + ) + + def clean(self): + category = self.cleaned_data.get("category") + mechanism = self.cleaned_data.get("mechanism") + order_number = self.cleaned_data.get("order_number", "") + + if ( + mechanism is not None + and int(mechanism) == validators.AdministrationMechanism.LICENSED + ): + if int(category) == validators.QuotaCategory.SAFEGUARD: + raise ValidationError( + "Mechanism cannot be set to licensed for safeguard quotas", + ) + if not order_number.startswith("054"): + raise ValidationError( + "The order number for licensed quotas must begin with 054", + ) + + if ( + category is not None + and int( + category, + ) + == validators.QuotaCategory.SAFEGUARD + and not order_number.startswith("058") + ): + raise ValidationError( + "The order number for safeguard quotas must begin with 058", + ) + + self.clean_quota_origins() + + return super().clean() diff --git a/quotas/views/__init__.py b/quotas/views/__init__.py index e5c5ff603..f17a8c840 100644 --- a/quotas/views/__init__.py +++ b/quotas/views/__init__.py @@ -1,3 +1,4 @@ from .base import * from .definitions import * +from .mixins import * from .wizards import * diff --git a/quotas/views/base.py b/quotas/views/base.py index 1ef7fa6b3..59457b0ef 100644 --- a/quotas/views/base.py +++ b/quotas/views/base.py @@ -1,10 +1,8 @@ from datetime import date from urllib.parse import urlencode -from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin from django.db import transaction -from django.http import HttpResponseRedirect from django.urls import reverse from django.utils.decorators import method_decorator from django.utils.safestring import mark_safe @@ -14,7 +12,6 @@ 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.tariffs_api import URLs from common.tariffs_api import get_quota_data @@ -25,14 +22,12 @@ from common.views import TrackedModelDetailView from geo_areas.models import GeographicalArea from geo_areas.models import GeographicalMembership -from geo_areas.utils import get_all_members_of_geo_groups from geo_areas.validators import AreaCode from measures.models import Measure from quotas import business_rules from quotas import forms from quotas import models from quotas import serializers -from quotas.constants import QUOTA_ORIGIN_EXCLUSIONS_FORMSET_PREFIX from quotas.filters import OrderNumberFilterBackend from quotas.filters import QuotaFilter from quotas.models import QuotaAssociation @@ -45,6 +40,11 @@ from workbaskets.views.generic import CreateTaricUpdateView from workbaskets.views.generic import EditTaricView +from .mixins import QuotaOrderNumberMixin +from .mixins import QuotaOrderNumberOriginMixin +from .mixins import QuotaOrderNumberOriginUpdateMixin +from .mixins import QuotaUpdateMixin + class QuotaOrderNumberViewset(viewsets.ReadOnlyModelViewSet): """API endpoint that allows quota order numbers to be viewed.""" @@ -94,14 +94,6 @@ class QuotaEventViewset(viewsets.ReadOnlyModelViewSet): permission_classes = [permissions.IsAuthenticated] -class QuotaOrderNumberMixin: - model = models.QuotaOrderNumber - - def get_queryset(self): - tx = WorkBasket.get_current_transaction(self.request) - return models.QuotaOrderNumber.objects.approved_up_to_transaction(tx) - - class QuotaCreate(QuotaOrderNumberMixin, CreateTaricCreateView): form_class = forms.QuotaOrderNumberCreateForm template_name = "layouts/create.jinja" @@ -269,187 +261,6 @@ class QuotaDelete( validate_business_rules = (business_rules.ON11,) -class QuotaUpdateMixin( - QuotaOrderNumberMixin, - TrackedModelDetailMixin, -): - form_class = forms.QuotaUpdateForm - permission_required = ["common.change_trackedmodel"] - - validate_business_rules = ( - business_rules.ON1, - business_rules.ON2, - business_rules.ON4, - business_rules.ON9, - business_rules.ON11, - UniqueIdentifyingFields, - UpdateValidity, - ) - - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["request"] = self.request - geo_area_options = ( - GeographicalArea.objects.current() - .prefetch_related("descriptions") - .with_latest_description() - .as_at_today_and_beyond() - .order_by("description") - ) - groups_options = geo_area_options.filter(area_code=AreaCode.GROUP) - geo_group_pks = [group.pk for group in groups_options] - memberships = GeographicalMembership.objects.filter( - geo_group__pk__in=geo_group_pks, - ).prefetch_related("geo_group", "member") - - groups_with_members = {} - for group_pk in geo_group_pks: - members = memberships.filter(geo_group__pk=group_pk) - groups_with_members[group_pk] = [m.member.pk for m in members] - - kwargs["geo_area_options"] = geo_area_options - kwargs["exclusions_options"] = geo_area_options.exclude( - area_code=AreaCode.GROUP, - ) - kwargs["groups_with_members"] = groups_with_members - kwargs["existing_origins"] = ( - self.object.get_current_origins().with_latest_geo_area_description() - ) - return kwargs - - def update_origins(self, instance, form_origins): - existing_origin_pks = {origin.pk for origin in instance.get_current_origins()} - - if form_origins: - submitted_origin_pks = {o["pk"] for o in form_origins} - deleted_origin_pks = existing_origin_pks.difference(submitted_origin_pks) - - for origin_pk in deleted_origin_pks: - origin = models.QuotaOrderNumberOrigin.objects.get( - pk=origin_pk, - ) - origin.new_version( - update_type=UpdateType.DELETE, - workbasket=WorkBasket.current(self.request), - transaction=instance.transaction, - ) - # Delete the exclusions as well - exclusions = models.QuotaOrderNumberOriginExclusion.objects.filter( - origin__pk=origin_pk, - ) - for exclusion in exclusions: - exclusion.new_version( - update_type=UpdateType.DELETE, - workbasket=WorkBasket.current(self.request), - transaction=instance.transaction, - ) - - for origin in form_origins: - # If origin exists - if origin.get("pk"): - existing_origin = models.QuotaOrderNumberOrigin.objects.get( - pk=origin.get("pk"), - ) - updated_origin = existing_origin.new_version( - workbasket=WorkBasket.current(self.request), - transaction=instance.transaction, - order_number=instance, - valid_between=origin["valid_between"], - geographical_area=origin["geographical_area"], - ) - - # It's a newly created origin - else: - updated_origin = models.QuotaOrderNumberOrigin.objects.create( - order_number=instance, - valid_between=origin["valid_between"], - geographical_area=origin["geographical_area"], - update_type=UpdateType.CREATE, - transaction=instance.transaction, - ) - - # whether it's edited or new we need to add/update exclusions - self.update_exclusions( - instance, - updated_origin, - origin.get("exclusions"), - ) - else: - # even if no changes were made we must update the existing - # origins to link to the updated order number - existing_origins = ( - models.QuotaOrderNumberOrigin.objects.approved_up_to_transaction( - instance.transaction, - ).filter( - order_number__sid=instance.sid, - ) - ) - for origin in existing_origins: - origin.new_version( - workbasket=WorkBasket.current(self.request), - transaction=instance.transaction, - order_number=instance, - ) - - def update_exclusions(self, quota, updated_origin, exclusions): - existing_exclusions = ( - models.QuotaOrderNumberOriginExclusion.objects.current().filter( - origin__sid=updated_origin.sid, - ) - ) - existing_exclusions_geo_area_ids = set( - existing_exclusions.values_list("excluded_geographical_area_id", flat=True), - ) - submitted_exclusions_geo_area_ids = { - e["geographical_area"].id for e in exclusions - } - deleted_exclusion_geo_area_ids = existing_exclusions_geo_area_ids.difference( - submitted_exclusions_geo_area_ids, - ) - - for geo_area_id in deleted_exclusion_geo_area_ids: - exclusion = existing_exclusions.get( - excluded_geographical_area=geo_area_id, - ) - exclusion.new_version( - update_type=UpdateType.DELETE, - workbasket=WorkBasket.current(self.request), - transaction=quota.transaction, - ) - - for exclusion in exclusions: - geo_area = GeographicalArea.objects.get(pk=exclusion["geographical_area"]) - if geo_area.pk in existing_exclusions_geo_area_ids: - existing_exclusion = existing_exclusions.get( - excluded_geographical_area=geo_area, - ) - existing_exclusion.new_version( - workbasket=WorkBasket.current(self.request), - transaction=quota.transaction, - origin=updated_origin, - excluded_geographical_area=geo_area, - ) - - else: - models.QuotaOrderNumberOriginExclusion.objects.create( - origin=updated_origin, - excluded_geographical_area=geo_area, - update_type=UpdateType.CREATE, - transaction=quota.transaction, - ) - - @transaction.atomic - def get_result_object(self, form): - instance = super().get_result_object(form) - - # if JS is enabled we get data from the React form which includes origins and exclusions - form_origins = form.cleaned_data.get("origins") - - self.update_origins(instance, form_origins) - - return instance - - class QuotaUpdate( QuotaUpdateMixin, CreateTaricUpdateView, @@ -475,93 +286,6 @@ class QuotaConfirmUpdate(QuotaOrderNumberMixin, TrackedModelDetailView): template_name = "common/confirm_update.jinja" -class QuotaOrderNumberOriginMixin: - model = models.QuotaOrderNumberOrigin - - def get_queryset(self): - tx = WorkBasket.get_current_transaction(self.request) - return models.QuotaOrderNumberOrigin.objects.approved_up_to_transaction(tx) - - -class QuotaOrderNumberOriginUpdateMixin( - QuotaOrderNumberOriginMixin, - TrackedModelDetailMixin, -): - form_class = forms.QuotaOrderNumberOriginUpdateForm - permission_required = ["common.change_trackedmodel"] - template_name = "quota-origins/edit.jinja" - - validate_business_rules = ( - business_rules.ON5, - business_rules.ON6, - business_rules.ON7, - business_rules.ON10, - business_rules.ON12, - UniqueIdentifyingFields, - UpdateValidity, - ) - - @transaction.atomic - def get_result_object(self, form): - object = super().get_result_object(form) - - geo_area = form.cleaned_data["geographical_area"] - form_exclusions = [ - item["exclusion"] - for item in form.cleaned_data[QUOTA_ORIGIN_EXCLUSIONS_FORMSET_PREFIX] - ] - - all_new_exclusions = get_all_members_of_geo_groups( - object.valid_between, - form_exclusions, - ) - - for geo_area in all_new_exclusions: - existing_exclusion = ( - object.quotaordernumberoriginexclusion_set.filter( - excluded_geographical_area=geo_area, - ) - .current() - .first() - ) - - if existing_exclusion: - existing_exclusion.new_version( - workbasket=WorkBasket.current(self.request), - transaction=object.transaction, - origin=object, - ) - else: - models.QuotaOrderNumberOriginExclusion.objects.create( - origin=object, - excluded_geographical_area=geo_area, - update_type=UpdateType.CREATE, - transaction=object.transaction, - ) - - removed_excluded_areas = { - e.excluded_geographical_area - for e in object.quotaordernumberoriginexclusion_set.current() - }.difference(set(form_exclusions)) - - removed_exclusions = [ - object.quotaordernumberoriginexclusion_set.current().get( - excluded_geographical_area__id=e.id, - ) - for e in removed_excluded_areas - ] - - for removed in removed_exclusions: - removed.new_version( - update_type=UpdateType.DELETE, - workbasket=WorkBasket.current(self.request), - transaction=object.transaction, - origin=object, - ) - - return object - - class QuotaOrderNumberOriginUpdate( QuotaOrderNumberOriginUpdateMixin, CreateTaricUpdateView, @@ -783,66 +507,3 @@ def get_queryset(self): deleted anything. """ return QuotaSuspension.objects.filter(pk=self.deleted_suspension) - - -class SubQuotaConfirmUpdate(TrackedModelDetailView): - model = models.QuotaDefinition - template_name = "quota-definitions/sub-quota-definitions-confirm-update.jinja" - - @property - def association(self): - return QuotaAssociation.objects.current().get(sub_quota__sid=self.kwargs["sid"]) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["association"] = self.association - return context - - def dispatch(self, request, *args, **kwargs): - """ - Should a user land on the this page for a definition which is not a sub- - quota, perform a redirect. - - This is not possible with current user journeys but this is included for - security and test purposes. - """ - try: - self.association - except models.QuotaAssociation.DoesNotExist: - return HttpResponseRedirect( - reverse( - "quota-ui-list", - ), - ) - return super().dispatch(request, *args, **kwargs) - - -class QuotaAssociationDelete( - CreateTaricDeleteView, -): - form_class = delete_form_for(models.QuotaAssociation) - template_name = "quota-associations/delete.jinja" - model = models.QuotaAssociation - - def form_valid(self, form): - messages.success( - self.request, - f"Quota association between {self.object.main_quota.sid} and {self.object.sub_quota.sid} has been deleted", - ) - return super().form_valid(form) - - def get_queryset(self): - return models.QuotaAssociation.objects.current() - - def get_success_url(self): - return reverse( - "quota_association-ui-confirm-delete", - kwargs={"sid": self.object.sub_quota.sid}, - ) - - -class QuotaAssociationConfirmDelete( - TrackedModelDetailView, -): - template_name = "quota-associations/confirm-delete.jinja" - model = models.QuotaDefinition diff --git a/quotas/views/definitions.py b/quotas/views/definitions.py index 715d764ff..b82e2319f 100644 --- a/quotas/views/definitions.py +++ b/quotas/views/definitions.py @@ -346,3 +346,66 @@ def update_association(self, instance, sub_quota_relation_type, coefficient): instance=current_instance, ) form.save() + + +class SubQuotaConfirmUpdate(TrackedModelDetailView): + model = models.QuotaDefinition + template_name = "quota-definitions/sub-quota-definitions-confirm-update.jinja" + + @property + def association(self): + return QuotaAssociation.objects.current().get(sub_quota__sid=self.kwargs["sid"]) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["association"] = self.association + return context + + def dispatch(self, request, *args, **kwargs): + """ + Should a user land on the this page for a definition which is not a sub- + quota, perform a redirect. + + This is not possible with current user journeys but this is included for + security and test purposes. + """ + try: + self.association + except models.QuotaAssociation.DoesNotExist: + return HttpResponseRedirect( + reverse( + "quota-ui-list", + ), + ) + return super().dispatch(request, *args, **kwargs) + + +class QuotaAssociationDelete( + CreateTaricDeleteView, +): + form_class = delete_form_for(models.QuotaAssociation) + template_name = "quota-associations/delete.jinja" + model = models.QuotaAssociation + + def form_valid(self, form): + messages.success( + self.request, + f"Quota association between {self.object.main_quota.sid} and {self.object.sub_quota.sid} has been deleted", + ) + return super().form_valid(form) + + def get_queryset(self): + return models.QuotaAssociation.objects.current() + + def get_success_url(self): + return reverse( + "quota_association-ui-confirm-delete", + kwargs={"sid": self.object.sub_quota.sid}, + ) + + +class QuotaAssociationConfirmDelete( + TrackedModelDetailView, +): + template_name = "quota-associations/confirm-delete.jinja" + model = models.QuotaDefinition diff --git a/quotas/views/mixins.py b/quotas/views/mixins.py new file mode 100644 index 000000000..e0867c60e --- /dev/null +++ b/quotas/views/mixins.py @@ -0,0 +1,291 @@ +from django.db import transaction + +from common.business_rules import UniqueIdentifyingFields +from common.business_rules import UpdateValidity +from common.validators import UpdateType +from common.views.mixins import TrackedModelDetailMixin +from geo_areas.models import GeographicalArea +from geo_areas.models import GeographicalMembership +from geo_areas.utils import get_all_members_of_geo_groups +from geo_areas.validators import AreaCode +from quotas import business_rules +from quotas import forms +from quotas import models +from quotas.constants import QUOTA_ORIGIN_EXCLUSIONS_FORMSET_PREFIX +from workbaskets.models import WorkBasket + + +class QuotaOrderNumberMixin: + model = models.QuotaOrderNumber + + def get_queryset(self): + tx = WorkBasket.get_current_transaction(self.request) + return models.QuotaOrderNumber.objects.approved_up_to_transaction(tx) + + +class QuotaUpdateMixin( + QuotaOrderNumberMixin, + TrackedModelDetailMixin, +): + form_class = forms.QuotaUpdateForm + permission_required = ["common.change_trackedmodel"] + + validate_business_rules = ( + business_rules.ON1, + business_rules.ON2, + business_rules.ON4, + business_rules.ON9, + business_rules.ON11, + UniqueIdentifyingFields, + UpdateValidity, + ) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["request"] = self.request + geo_area_options = ( + GeographicalArea.objects.current() + .prefetch_related("descriptions") + .with_latest_description() + .as_at_today_and_beyond() + .order_by("description") + ) + groups_options = geo_area_options.filter(area_code=AreaCode.GROUP) + geo_group_pks = [group.pk for group in groups_options] + memberships = GeographicalMembership.objects.filter( + geo_group__pk__in=geo_group_pks, + ).prefetch_related("geo_group", "member") + + groups_with_members = {} + for group_pk in geo_group_pks: + members = memberships.filter(geo_group__pk=group_pk) + groups_with_members[group_pk] = [m.member.pk for m in members] + + kwargs["geo_area_options"] = geo_area_options + kwargs["exclusions_options"] = geo_area_options.exclude( + area_code=AreaCode.GROUP, + ) + kwargs["groups_with_members"] = groups_with_members + kwargs["existing_origins"] = ( + self.object.get_current_origins().with_latest_geo_area_description() + ) + return kwargs + + def update_origins(self, instance, form_origins): + existing_origin_pks = {origin.pk for origin in instance.get_current_origins()} + + if form_origins: + submitted_origin_pks = {o["pk"] for o in form_origins} + deleted_origin_pks = existing_origin_pks.difference(submitted_origin_pks) + + for origin_pk in deleted_origin_pks: + origin = models.QuotaOrderNumberOrigin.objects.get( + pk=origin_pk, + ) + origin.new_version( + update_type=UpdateType.DELETE, + workbasket=WorkBasket.current(self.request), + transaction=instance.transaction, + ) + # Delete the exclusions as well + exclusions = models.QuotaOrderNumberOriginExclusion.objects.filter( + origin__pk=origin_pk, + ) + for exclusion in exclusions: + exclusion.new_version( + update_type=UpdateType.DELETE, + workbasket=WorkBasket.current(self.request), + transaction=instance.transaction, + ) + + for origin in form_origins: + # If origin exists + if origin.get("pk"): + existing_origin = models.QuotaOrderNumberOrigin.objects.get( + pk=origin.get("pk"), + ) + updated_origin = existing_origin.new_version( + workbasket=WorkBasket.current(self.request), + transaction=instance.transaction, + order_number=instance, + valid_between=origin["valid_between"], + geographical_area=origin["geographical_area"], + ) + + # It's a newly created origin + else: + updated_origin = models.QuotaOrderNumberOrigin.objects.create( + order_number=instance, + valid_between=origin["valid_between"], + geographical_area=origin["geographical_area"], + update_type=UpdateType.CREATE, + transaction=instance.transaction, + ) + + # whether it's edited or new we need to add/update exclusions + self.update_exclusions( + instance, + updated_origin, + origin.get("exclusions"), + ) + else: + # even if no changes were made we must update the existing + # origins to link to the updated order number + existing_origins = ( + models.QuotaOrderNumberOrigin.objects.approved_up_to_transaction( + instance.transaction, + ).filter( + order_number__sid=instance.sid, + ) + ) + for origin in existing_origins: + origin.new_version( + workbasket=WorkBasket.current(self.request), + transaction=instance.transaction, + order_number=instance, + ) + + def update_exclusions(self, quota, updated_origin, exclusions): + existing_exclusions = ( + models.QuotaOrderNumberOriginExclusion.objects.current().filter( + origin__sid=updated_origin.sid, + ) + ) + existing_exclusions_geo_area_ids = set( + existing_exclusions.values_list("excluded_geographical_area_id", flat=True), + ) + submitted_exclusions_geo_area_ids = { + e["geographical_area"].id for e in exclusions + } + deleted_exclusion_geo_area_ids = existing_exclusions_geo_area_ids.difference( + submitted_exclusions_geo_area_ids, + ) + + for geo_area_id in deleted_exclusion_geo_area_ids: + exclusion = existing_exclusions.get( + excluded_geographical_area=geo_area_id, + ) + exclusion.new_version( + update_type=UpdateType.DELETE, + workbasket=WorkBasket.current(self.request), + transaction=quota.transaction, + ) + + for exclusion in exclusions: + geo_area = GeographicalArea.objects.get(pk=exclusion["geographical_area"]) + if geo_area.pk in existing_exclusions_geo_area_ids: + existing_exclusion = existing_exclusions.get( + excluded_geographical_area=geo_area, + ) + existing_exclusion.new_version( + workbasket=WorkBasket.current(self.request), + transaction=quota.transaction, + origin=updated_origin, + excluded_geographical_area=geo_area, + ) + + else: + models.QuotaOrderNumberOriginExclusion.objects.create( + origin=updated_origin, + excluded_geographical_area=geo_area, + update_type=UpdateType.CREATE, + transaction=quota.transaction, + ) + + @transaction.atomic + def get_result_object(self, form): + instance = super().get_result_object(form) + + # if JS is enabled we get data from the React form which includes origins and exclusions + form_origins = form.cleaned_data.get("origins") + + self.update_origins(instance, form_origins) + + return instance + + +class QuotaOrderNumberOriginMixin: + model = models.QuotaOrderNumberOrigin + + def get_queryset(self): + tx = WorkBasket.get_current_transaction(self.request) + return models.QuotaOrderNumberOrigin.objects.approved_up_to_transaction(tx) + + +class QuotaOrderNumberOriginUpdateMixin( + QuotaOrderNumberOriginMixin, + TrackedModelDetailMixin, +): + form_class = forms.QuotaOrderNumberOriginUpdateForm + permission_required = ["common.change_trackedmodel"] + template_name = "quota-origins/edit.jinja" + + validate_business_rules = ( + business_rules.ON5, + business_rules.ON6, + business_rules.ON7, + business_rules.ON10, + business_rules.ON12, + UniqueIdentifyingFields, + UpdateValidity, + ) + + @transaction.atomic + def get_result_object(self, form): + object = super().get_result_object(form) + + geo_area = form.cleaned_data["geographical_area"] + form_exclusions = [ + item["exclusion"] + for item in form.cleaned_data[QUOTA_ORIGIN_EXCLUSIONS_FORMSET_PREFIX] + ] + + all_new_exclusions = get_all_members_of_geo_groups( + object.valid_between, + form_exclusions, + ) + + for geo_area in all_new_exclusions: + existing_exclusion = ( + object.quotaordernumberoriginexclusion_set.filter( + excluded_geographical_area=geo_area, + ) + .current() + .first() + ) + + if existing_exclusion: + existing_exclusion.new_version( + workbasket=WorkBasket.current(self.request), + transaction=object.transaction, + origin=object, + ) + else: + models.QuotaOrderNumberOriginExclusion.objects.create( + origin=object, + excluded_geographical_area=geo_area, + update_type=UpdateType.CREATE, + transaction=object.transaction, + ) + + removed_excluded_areas = { + e.excluded_geographical_area + for e in object.quotaordernumberoriginexclusion_set.current() + }.difference(set(form_exclusions)) + + removed_exclusions = [ + object.quotaordernumberoriginexclusion_set.current().get( + excluded_geographical_area__id=e.id, + ) + for e in removed_excluded_areas + ] + + for removed in removed_exclusions: + removed.new_version( + update_type=UpdateType.DELETE, + workbasket=WorkBasket.current(self.request), + transaction=object.transaction, + origin=object, + ) + + return object From 821238e8507ad5a982c3811f2cdd059e8bd21f32 Mon Sep 17 00:00:00 2001 From: Charles Prichard Date: Thu, 31 Oct 2024 12:03:04 +0000 Subject: [PATCH 8/8] import --- quotas/forms/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/quotas/forms/base.py b/quotas/forms/base.py index 7300deb65..ca23fa4e0 100644 --- a/quotas/forms/base.py +++ b/quotas/forms/base.py @@ -1,3 +1,5 @@ +from datetime import date + from crispy_forms_gds.helper import FormHelper from crispy_forms_gds.layout import HTML from crispy_forms_gds.layout import Button