- Edit and delete quota
+ Edit and delete quota
{% set edit_url = object.get_url('edit') %}
{% if edit_url %}
- Edit this {{object._meta.verbose_name}}
@@ -12,12 +12,12 @@
- Delete this {{object._meta.verbose_name}}
{% endif %}
- Create quota data
- - Create definition period
+ Create quota data
+ - Create definition period
- Create quota associations
- Create suspension or blocking period
- View and edit quota data
+ View and edit quota data
- View and edit quota data
- View all measures
- View this quota on the UK Integrated Online Tariff
diff --git a/quotas/jinja2/quota-definitions/bulk-create-definition-edit.jinja b/quotas/jinja2/quota-definitions/bulk-create-definition-edit.jinja
new file mode 100644
index 000000000..dfee6114c
--- /dev/null
+++ b/quotas/jinja2/quota-definitions/bulk-create-definition-edit.jinja
@@ -0,0 +1,19 @@
+{% extends "layouts/form.jinja" %}
+
+{% set page_title = "Edit quota definition period" %}
+{% set page_subtitle = "Quota order number: "~request.session.quota_order_number%}
+{% block content %}
+
+ {% block page_title_heading %}
+ {{ page_title }}
+ {% endblock %}
+
+ {% block page_subtitle %}
+ {{ page_subtitle }}
+ {% endblock %}
+
+
+ {% call django_form() %}
+ {{ crispy(form) }}
+ {% endcall %}
+{% endblock %}
diff --git a/quotas/jinja2/quota-definitions/bulk-create-definition-info.jinja b/quotas/jinja2/quota-definitions/bulk-create-definition-info.jinja
new file mode 100644
index 000000000..5bf18eac3
--- /dev/null
+++ b/quotas/jinja2/quota-definitions/bulk-create-definition-info.jinja
@@ -0,0 +1,26 @@
+{% extends "quota-definitions/sub-quota-duplicate-definitions-step.jinja"%}
+
+{% set page_subtitle = "Quota order number "~request.session.quota_order_number %}
+
+{% block content %}
+
+ {% block page_title_heading %}
+ {{ page_title }}
+ {% endblock %}
+
+ {% block page_subtitle %}
+ {{ page_subtitle }}
+ {% endblock %}
+
+
+
+ Changes can be made to individual definition periods on the review page before submission
+
+
+
+ {% 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/jinja2/quota-definitions/bulk-create-done.jinja b/quotas/jinja2/quota-definitions/bulk-create-done.jinja
new file mode 100644
index 000000000..8b4cf395b
--- /dev/null
+++ b/quotas/jinja2/quota-definitions/bulk-create-done.jinja
@@ -0,0 +1,43 @@
+{% extends "layouts/layout.jinja" %}
+{% from "components/panel/macro.njk" import govukPanel %}
+{% from "components/button/macro.njk" import govukButton %}
+{% set page_title = "Definitions created" %}
+
+{% set definition_count = request.session['staged_definition_data']|length %}
+
+{% macro panel_title() %}
+ {{ definition_count }} quota definition periods have been created and added to quota order number {{ request.session['quota_order_number']}}
+{% endmacro %}
+
+{% block content %}
+
+
+
+ {{ govukPanel({
+ "titleText": panel_title(),
+ "classes": "govuk-!-margin-bottom-7"
+ }) }}
+
Next steps
+
+
+ {{ govukButton({
+ "text": "Create other definition periods",
+ "href": url("quota_definition-ui-bulk-create"),
+ "classes": "govuk-button--primary"
+ }) }}
+ {{ govukButton({
+ "text": "View definition period data for this quota",
+ "href": url("quota_definition-ui-list", args=[quota_order_number.sid]),
+ "classes": "govuk-button--secondary"
+ }) }}
+
+
+
Further actions
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/quotas/jinja2/quota-definitions/bulk-create-review.jinja b/quotas/jinja2/quota-definitions/bulk-create-review.jinja
new file mode 100644
index 000000000..4b0d7c8f5
--- /dev/null
+++ b/quotas/jinja2/quota-definitions/bulk-create-review.jinja
@@ -0,0 +1,102 @@
+{% extends "quota-definitions/bulk-create-step.jinja"%}
+{% from "components/table/macro.njk" import govukTable %}
+{% from "components/details/macro.njk" import govukDetails %}
+{% from "components/summary-list/macro.njk" import govukSummaryList %}
+
+{% set page_title = "Review quota definition periods"%}
+{% set page_subtitle = "Quota order number "~request.session.quota_order_number %}
+
+{% block form %}
+
+
+ {% block page_title_heading %}
+ {{ page_title }}
+ {% endblock %}
+
+ {% block page_subtitle %}
+ {{ page_subtitle }}
+ {% endblock %}
+
+
+ {% set data = view.get_staged_definition_data() %}
+ {% set table_rows = [] %}
+ {% for definition in data %}
+ {% set formatted_start_date = view.format_date(definition.start_date)%}
+ {% set formatted_end_date = view.format_date(definition.end_date)%}
+ {% set edit_link -%}
+ Edit
+ {%- endset %}
+
+ {% set definition_details_html %}
+ {{
+ govukSummaryList({
+ "rows": [
+ {
+ "key": {"text": "Description"},
+ "value": {"text": definition.description if definition.description else "-"},
+ "actions": {"items": []}
+ },
+ {
+ "key": {"text": "Measurement unit"},
+ "value": {"text": definition.measurement_unit_abbreviation},
+ "actions": {"items": []}
+ },
+ {
+ "key": {"text": "Critical threshold"},
+ "value": {"text": definition.threshold ~"%"},
+ "actions": {"items": []}
+ },
+ {
+ "key": {"text": "Critical state"},
+ "value": {"text": "Yes" if definition.quota_critical else "No"},
+ "actions": {"items": []}
+ },
+ ]
+ })
+ }}
+ {% endset %}
+ {% set definition_details -%}
+ {{ govukDetails({
+ "summaryText": "Details",
+ "html": definition_details_html
+ }) }}
+ {% endset %}
+
+ {{ table_rows.append([
+ {"text": definition_details },
+ {"text": formatted_start_date },
+ {"text": formatted_end_date },
+ {"text": definition.volume },
+ {"text": definition.measurement_unit_abbreviation },
+ {"text": edit_link },
+ ]) or ""}}
+ {% endfor %}
+
+ Each row represents a Quota definition period to be created. These can be edited before being submitted.
+
+ {{ govukTable({
+ "head": [
+ {"text": "Details"},
+ {"text": "Start date"},
+ {"text": "End date"},
+ {"text": "Volume"},
+ {"text": "Unit"},
+ {"text": "Edit details"},
+ ],
+ "rows": table_rows
+ }) }}
+
+ Selecting 'Submit' will create the new definition periods.
+ Further edits to the definition periods can be made on the quota order number page through an additional workbasket transaction.
+
+ {{ govukButton({
+ "text": "Submit",
+ }) }}
+ {{ govukButton({
+ "text": "Back",
+ "classes": "govuk-button--secondary",
+ "href": '/quotas/quota_definitions/bulk_create/definition_period_info',
+ }) }}
+
+ Cancel
+{% endblock %}
\ No newline at end of file
diff --git a/quotas/jinja2/quota-definitions/bulk-create-start.jinja b/quotas/jinja2/quota-definitions/bulk-create-start.jinja
new file mode 100644
index 000000000..d4b1474c5
--- /dev/null
+++ b/quotas/jinja2/quota-definitions/bulk-create-start.jinja
@@ -0,0 +1,23 @@
+{% extends "quota-definitions/sub-quota-duplicate-definitions-step.jinja"%}
+
+{% block content %}
+
+ {% block page_title_heading %}
+ {{ page_title }}
+ {% endblock %}
+
+
+ You can create up to 20 quota definition periods for the same quota order number.
+ Subsequent periods will be calculated from the first date range entered and the data will be duplicated across all definition periods.
+
+
+ Changes can be made to individual definition periods on the review page before submission
+
+
+
+ {% 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/jinja2/quota-definitions/bulk-create-step.jinja b/quotas/jinja2/quota-definitions/bulk-create-step.jinja
new file mode 100644
index 000000000..583a3629d
--- /dev/null
+++ b/quotas/jinja2/quota-definitions/bulk-create-step.jinja
@@ -0,0 +1,17 @@
+{% extends "layouts/form.jinja" %}
+{% from "components/details/macro.njk" import govukDetails %}
+
+{% block content %}
+
+
+
+ {% 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/jinja2/quota-definitions/create.jinja b/quotas/jinja2/quota-definitions/create.jinja
index cd27ed1c6..399e65998 100644
--- a/quotas/jinja2/quota-definitions/create.jinja
+++ b/quotas/jinja2/quota-definitions/create.jinja
@@ -1,12 +1,28 @@
-{% extends "layouts/create.jinja" %}
+{% extends 'layouts/form.jinja' %}
{% set page_title = "Create a new quota definition period" %}
-
+{% set page_subtitle = "Quota order number: "~ quota_order_number %}
{% block breadcrumb %}
{{ breadcrumbs(request, [
{"text": "Find and edit quotas", "href": url("quota-ui-list")},
- {"text": "Quota order number", "href": url("quota-ui-detail", args=[view.kwargs.sid]) ~ "#definitions"},
+ {"text": "Quota order number", "href": url("quota-ui-detail", args=[quota_sid]) ~ "#definition-details"},
{"text": page_title}
])
}}
{% endblock %}
+{% block content %}
+
+ {% block page_title_heading %}
+ {{ page_title }}
+ {% endblock %}
+
+ {% block page_subtitle %}
+ {{ page_subtitle }}
+ {% endblock %}
+
+
+ You can create one new quota definition period using this form. Alternatively, you can create multiple quota definition periods for a chosen quota order number.
+ {% call django_form() %}
+ {{ crispy(form) }}
+ {% endcall %}
+{% endblock %}
diff --git a/quotas/serializers.py b/quotas/serializers.py
index 9d03d2ed8..daebfaaee 100644
--- a/quotas/serializers.py
+++ b/quotas/serializers.py
@@ -12,6 +12,7 @@
from common.validators import UpdateType
from geo_areas.serializers import GeographicalAreaSerializer
from measures.models.tracked_models import MeasurementUnit
+from measures.models.tracked_models import MeasurementUnitQualifier
from measures.unit_serializers import MeasurementUnitQualifierSerializer
from measures.unit_serializers import MeasurementUnitSerializer
from measures.unit_serializers import MonetaryUnitSerializer
@@ -271,7 +272,8 @@ class Meta:
def serialize_duplicate_data(selected_definition):
- # returns a JSON dictionary of serialized definition data
+ # returns a JSON dictionary of serialized definition data from a
+ # QuotaDefinition object
duplicate_data = {
"initial_volume": str(selected_definition.initial_volume),
"volume": str(selected_definition.volume),
@@ -307,3 +309,71 @@ def deserialize_definition_data(self, definition):
"quota_critical_threshold": 90,
}
return staged_data
+
+
+def serialize_definition_data(definition):
+ """Returns a JSON dictionary of serialized definition data, that can be
+ saved to session."""
+
+ definition_data = {
+ "id": str(definition["id"]),
+ "initial_volume": str(definition["initial_volume"]),
+ "volume": str(definition["volume"]),
+ "measurement_unit_code": definition["measurement_unit"].code,
+ "measurement_unit_abbreviation": definition["measurement_unit"].abbreviation,
+ "threshold": str(definition["quota_critical_threshold"]),
+ "quota_critical": definition["quota_critical"],
+ "start_date": serialize_date(definition["valid_between"].lower),
+ "end_date": serialize_date(definition["valid_between"].upper),
+ "maximum_precision": str(definition["maximum_precision"]),
+ }
+ if (
+ "measurement_unit_qualifier" in definition
+ and definition["measurement_unit_qualifier"] is not None
+ ):
+ definition_data["measurement_unit_qualifier"] = str(
+ definition["measurement_unit_qualifier"].pk,
+ )
+
+ if "description" in definition:
+ definition_data["description"] = definition["description"]
+ return definition_data
+
+
+def deserialize_bulk_create_definition_data(definition, order_number):
+ start_date = deserialize_date(definition["start_date"])
+ end_date = deserialize_date(definition["end_date"])
+ initial_volume = Decimal(definition["initial_volume"])
+ vol = Decimal(definition["volume"])
+ measurement_unit = MeasurementUnit.objects.get(
+ code=definition["measurement_unit_code"],
+ )
+ if "description" in definition:
+ description = definition["description"]
+ if "measurement_unit_qualifier" in definition:
+ measurement_unit_qualifier_pk = Decimal(
+ definition["measurement_unit_qualifier"],
+ )
+ measurement_unit_qualifier = MeasurementUnitQualifier.objects.get(
+ pk=measurement_unit_qualifier_pk,
+ )
+ else:
+ measurement_unit_qualifier = None
+ valid_between = TaricDateRange(start_date, end_date)
+ order_number_obj = models.QuotaOrderNumber.objects.get(
+ pk=order_number,
+ )
+ maximum_precision = Decimal(definition["maximum_precision"])
+ staged_data = {
+ "volume": vol,
+ "initial_volume": initial_volume,
+ "measurement_unit": measurement_unit,
+ "measurement_unit_qualifier": measurement_unit_qualifier,
+ "order_number": order_number_obj,
+ "valid_between": valid_between,
+ "update_type": UpdateType.CREATE,
+ "maximum_precision": maximum_precision,
+ "quota_critical_threshold": 90,
+ "description": description,
+ }
+ return staged_data
diff --git a/quotas/tests/test_forms.py b/quotas/tests/test_forms.py
index fa750291f..462048fc4 100644
--- a/quotas/tests/test_forms.py
+++ b/quotas/tests/test_forms.py
@@ -1,4 +1,5 @@
import datetime
+import decimal
import pytest
from bs4 import BeautifulSoup
@@ -130,7 +131,14 @@ def check_error_messages(form):
check_error_messages(form)
with override_current_transaction(tx):
- form = forms.QuotaDefinitionCreateForm(data=data)
+ form = forms.QuotaDefinitionCreateForm(
+ data=data,
+ buttons={
+ "submit": "Submit",
+ "link_text": "Cancel",
+ "link": "/workbaskets/current",
+ },
+ )
check_error_messages(form)
@@ -167,7 +175,14 @@ def test_quota_definition_volume_validation(date_ranges):
)
with override_current_transaction(tx):
- form = forms.QuotaDefinitionCreateForm(data=data)
+ form = forms.QuotaDefinitionCreateForm(
+ data=data,
+ buttons={
+ "submit": "Submit",
+ "link_text": "Cancel",
+ "link": "/workbaskets/current",
+ },
+ )
assert not form.is_valid()
assert (
form.errors["__all__"][0]
@@ -1069,3 +1084,340 @@ def test_quota_blocking_update_form_invalid(date_ranges):
f"The start and end date must sit within the selected quota definition's start and end date ({definition.valid_between.lower} - {definition.valid_between.upper})"
in form.errors["__all__"]
)
+
+
+@pytest.fixture
+def quota() -> models.QuotaOrderNumber:
+ """Provides a main quota order number for use across the fixtures and
+ following tests."""
+ return factories.QuotaOrderNumberFactory()
+
+
+@pytest.fixture
+def bulk_create_start_form(
+ session_request,
+) -> forms.BulkQuotaDefinitionCreateStartForm:
+ return forms.BulkQuotaDefinitionCreateStartForm(
+ request=session_request,
+ prefix="definition_period_info",
+ )
+
+
+@pytest.fixture
+def bulk_create_definition_form(
+ session_request,
+) -> forms.QuotaDefinitionBulkCreateDefinitionInformation:
+ return forms.QuotaDefinitionBulkCreateDefinitionInformation(
+ request=session_request,
+ prefix="definition_period_info",
+ )
+
+
+def test_quota_definition_bulk_create_definition_start_form(
+ session_request,
+ quota,
+ bulk_create_start_form,
+):
+
+ initial_data = {
+ "quota_order_number": quota,
+ }
+ with override_current_transaction(Transaction.objects.last()):
+ bulk_create_start_form.save_quota_order_number_to_session(initial_data)
+
+ assert session_request.session["quota_order_number_pk"] == quota.pk
+ assert session_request.session["quota_order_number"] == quota.order_number
+
+
+def test_quota_definition_bulk_create_start_is_valid(
+ quota,
+ session_request_with_workbasket,
+):
+ initial_data = {"quota_order_number": quota}
+ form = forms.BulkQuotaDefinitionCreateStartForm(
+ data=initial_data,
+ request=session_request_with_workbasket,
+ )
+ assert form.is_valid()
+
+ initial_data = {}
+
+ form = forms.BulkQuotaDefinitionCreateStartForm(
+ data=initial_data,
+ request=session_request_with_workbasket,
+ )
+ assert not form.is_valid()
+ assert f"A quota order number must be selected" in form.errors["__all__"]
+
+
+def test_quota_definition_bulk_create_definition_info_frequencies(
+ quota,
+ session_request,
+ bulk_create_start_form,
+ bulk_create_definition_form,
+):
+ measurement_unit = factories.MeasurementUnitFactory()
+ measurement_unit_qualifier = factories.MeasurementUnitQualifierFactory.create()
+ initial_data = {
+ "quota_order_number": quota,
+ }
+ form_data = {
+ "maximum_precision": 3,
+ "valid_between": TaricDateRange(
+ datetime.date(2025, 1, 1),
+ datetime.date(2025, 12, 31),
+ ),
+ "volume": 600.000,
+ "initial_volume": 500.000,
+ "measurement_unit": measurement_unit,
+ "quota_critical_threshold": 90,
+ "quota_critical": "False",
+ "instance_count": 3,
+ "frequency": 1,
+ "description": "This is a description",
+ "measurement_unit_qualifier": measurement_unit_qualifier,
+ }
+
+ # tests with annual recurrance
+ with override_current_transaction(Transaction.objects.last()):
+ bulk_create_start_form.save_quota_order_number_to_session(initial_data)
+ bulk_create_definition_form.save_definition_data_to_session(form_data)
+ assert session_request.session["quota_order_number"] == quota.order_number
+ # check that the length staged_definitions matches the initial_info['instance_count']
+ assert (
+ len(session_request.session["staged_definition_data"])
+ == form_data["instance_count"]
+ )
+ assert (
+ session_request.session["staged_definition_data"][1]["start_date"]
+ == "2026-01-01"
+ )
+ assert (
+ session_request.session["staged_definition_data"][1]["end_date"]
+ == "2026-12-31"
+ )
+ assert (
+ session_request.session["staged_definition_data"][2]["start_date"]
+ == "2027-01-01"
+ )
+ assert (
+ session_request.session["staged_definition_data"][2]["end_date"]
+ == "2027-12-31"
+ )
+ # tests bi-annual recurrance
+ form_data["frequency"] = 2
+ with override_current_transaction(Transaction.objects.last()):
+ bulk_create_definition_form.save_definition_data_to_session(form_data)
+ # check that the length staged_definitions still matches the initial_info['instance_count']
+ assert (
+ len(session_request.session["staged_definition_data"])
+ == form_data["instance_count"]
+ )
+ # check that the frequency is now biannual
+ assert (
+ session_request.session["staged_definition_data"][1]["start_date"]
+ == "2026-01-01"
+ )
+ assert (
+ session_request.session["staged_definition_data"][1]["end_date"]
+ == "2026-06-30"
+ )
+ assert (
+ session_request.session["staged_definition_data"][2]["start_date"]
+ == "2026-07-01"
+ )
+ assert (
+ session_request.session["staged_definition_data"][2]["end_date"]
+ == "2026-12-31"
+ )
+
+ # check quarterly recurrance
+ form_data["frequency"] = 3
+ with override_current_transaction(Transaction.objects.last()):
+ bulk_create_definition_form.save_definition_data_to_session(form_data)
+ # check that the length staged_definitions still matches the initial_info['instance_count']
+ assert (
+ len(session_request.session["staged_definition_data"])
+ == form_data["instance_count"]
+ )
+ assert (
+ session_request.session["staged_definition_data"][1]["start_date"]
+ == "2026-01-01"
+ )
+ assert (
+ session_request.session["staged_definition_data"][1]["end_date"]
+ == "2026-03-31"
+ )
+ assert (
+ session_request.session["staged_definition_data"][2]["start_date"]
+ == "2026-04-01"
+ )
+ assert (
+ session_request.session["staged_definition_data"][2]["end_date"]
+ == "2026-06-30"
+ )
+
+
+def test_bulk_create_update_definition_data_populates_parent_data(
+ quota,
+ session_request,
+ bulk_create_start_form,
+):
+ definition_form = forms.QuotaDefinitionBulkCreateDefinitionInformation(
+ request=session_request,
+ prefix="review",
+ )
+ # Set up the main definition, saving additional data to session
+ measurement_unit = factories.MeasurementUnitFactory()
+ initial_data = {
+ "quota_order_number": quota,
+ }
+ form_data = {
+ "maximum_precision": 3,
+ "valid_between": TaricDateRange(
+ datetime.date(2025, 1, 1),
+ datetime.date(2025, 12, 31),
+ ),
+ "volume": "600.000",
+ "initial_volume": "500.000",
+ "measurement_unit": measurement_unit,
+ "quota_critical_threshold": "90",
+ "quota_critical": "False",
+ "description": "This is a description",
+ "instance_count": 3,
+ "frequency": 1,
+ }
+
+ bulk_create_start_form.save_quota_order_number_to_session(initial_data)
+ definition_form.save_definition_data_to_session(form_data)
+ update_form = forms.BulkDefinitionUpdateData(
+ request=session_request,
+ pk=2,
+ buttons={
+ "submit": "Save and continue",
+ "link_text": "Discard changes",
+ "link": "/quotas/quota_definitions/bulk_create/review",
+ },
+ )
+
+ assert update_form.fields["volume"].initial == decimal.Decimal(form_data["volume"])
+ assert update_form.fields["initial_volume"].initial == decimal.Decimal(
+ form_data["initial_volume"],
+ )
+ assert (
+ update_form.fields["measurement_unit"].initial == form_data["measurement_unit"]
+ )
+ assert update_form.fields["description"].initial == form_data["description"]
+
+
+def test_bulk_create_update_definition_data_updates_data(
+ quota,
+ session_request,
+ bulk_create_start_form,
+ bulk_create_definition_form,
+):
+ measurement_unit = factories.MeasurementUnitFactory()
+ initial_data = {
+ "quota_order_number": quota,
+ }
+ definition_data = {
+ "maximum_precision": 3,
+ "valid_between": TaricDateRange(
+ datetime.date(2025, 1, 1),
+ datetime.date(2025, 12, 31),
+ ),
+ "volume": 600.000,
+ "initial_volume": 500.000,
+ "measurement_unit": measurement_unit,
+ "quota_critical_threshold": 90,
+ "quota_critical": "False",
+ "instance_count": 3,
+ "frequency": 1,
+ "description": "This is a description",
+ }
+
+ bulk_create_start_form.save_quota_order_number_to_session(initial_data)
+ bulk_create_definition_form.save_definition_data_to_session(definition_data)
+
+ update_form_data = {
+ "id": 1,
+ "maximum_precision": 3,
+ "valid_between": TaricDateRange(
+ datetime.date(2025, 1, 1),
+ datetime.date(2025, 12, 31),
+ ),
+ "volume": 500,
+ "initial_volume": 400,
+ "measurement_unit": measurement_unit,
+ "quota_critical_threshold": 91,
+ "quota_critical": "False",
+ "description": "This is a new description",
+ }
+
+ update_form = forms.BulkDefinitionUpdateData(
+ request=session_request,
+ pk=2,
+ buttons={
+ "submit": "Save and continue",
+ "link_text": "Discard changes",
+ "link": "/quotas/quota_definitions/bulk_create/review",
+ },
+ )
+ update_form.update_definition_data_in_session(cleaned_data=update_form_data)
+
+ assert session_request.session["staged_definition_data"][1]["volume"] == str(
+ update_form_data["volume"],
+ )
+ assert session_request.session["staged_definition_data"][1][
+ "initial_volume"
+ ] == str(update_form_data["initial_volume"])
+ assert (
+ session_request.session["staged_definition_data"][1]["description"]
+ == update_form_data["description"]
+ )
+
+
+def test_quota_definition_bulk_create_definition_is_valid(
+ session_request_with_workbasket,
+ date_ranges,
+):
+ measurement_unit = factories.MeasurementUnitFactory()
+ measurement_unit_qualifier = factories.MeasurementUnitQualifierFactory()
+
+ form_data = {
+ "maximum_precision": 3,
+ "start_date_0": date_ranges.normal.lower.day,
+ "start_date_1": date_ranges.normal.lower.month,
+ "start_date_2": date_ranges.normal.lower.year,
+ "end_date_0": date_ranges.normal.upper.day,
+ "end_date_1": date_ranges.normal.upper.month,
+ "end_date_2": date_ranges.normal.upper.year,
+ "volume": "600.000",
+ "initial_volume": "500.000",
+ "measurement_unit": measurement_unit,
+ "measurement_unit_qualifier": measurement_unit_qualifier,
+ "quota_critical_threshold": "90",
+ "quota_critical": "False",
+ "frequency": 1,
+ }
+
+ form = forms.QuotaDefinitionBulkCreateDefinitionInformation(
+ data=form_data,
+ request=session_request_with_workbasket,
+ )
+ with override_current_transaction(Transaction.objects.last()):
+ assert not form.is_valid()
+ assert (
+ form.errors["instance_count"][0]
+ == "Enter the number of definition periods to create"
+ )
+
+ form_data["instance_count"] = 3
+
+ form = forms.QuotaDefinitionBulkCreateDefinitionInformation(
+ data=form_data,
+ request=session_request_with_workbasket,
+ )
+
+ with override_current_transaction(Transaction.objects.last()):
+ assert form.is_valid()
diff --git a/quotas/tests/test_views.py b/quotas/tests/test_views.py
index febed9102..0a79aac8d 100644
--- a/quotas/tests/test_views.py
+++ b/quotas/tests/test_views.py
@@ -25,6 +25,9 @@
from quotas.forms.base import QuotaSuspensionType
from quotas.views import DuplicateDefinitionsWizard
from quotas.views import QuotaList
+from quotas.views.wizards import QuotaDefinitionBulkCreatorUpdateDefinitionData
+from quotas.views.wizards import QuotaDefinitionBulkCreatorWizard
+from quotas.wizard import QuotaDefinitionBulkCreatorSessionStorage
from quotas.wizard import QuotaDefinitionDuplicatorSessionStorage
pytestmark = pytest.mark.django_db
@@ -1293,6 +1296,9 @@ def test_create_new_quota_definition(
"start_date_0": date_ranges.later.lower.day,
"start_date_1": date_ranges.later.lower.month,
"start_date_2": date_ranges.later.lower.year,
+ "end_date_0": date_ranges.later.lower.day,
+ "end_date_1": date_ranges.later.lower.month,
+ "end_date_2": date_ranges.later.lower.year,
"description": "Lorem ipsum",
"volume": "1000000",
"initial_volume": "1000000",
@@ -1305,12 +1311,15 @@ def test_create_new_quota_definition(
# sanity check
assert not models.QuotaDefinition.objects.all()
-
url = reverse("quota_definition-ui-create", kwargs={"sid": quota.sid})
- response = client_with_current_workbasket.post(url, form_data)
- assert response.status_code == 302
+ response = client_with_current_workbasket.post(
+ f"{url}?order_number={quota.order_number}",
+ form_data,
+ )
+ assert response.status_code == 302
created_definition = models.QuotaDefinition.objects.last()
+
assert response.url == reverse(
"quota_definition-ui-confirm-create",
kwargs={"sid": created_definition.sid},
@@ -1351,6 +1360,9 @@ def test_create_new_quota_definition_business_rule_violation(
"start_date_0": date_ranges.earlier.lower.day,
"start_date_1": date_ranges.earlier.lower.month,
"start_date_2": date_ranges.earlier.lower.year,
+ "end_date_0": date_ranges.later.lower.day,
+ "end_date_1": date_ranges.later.lower.month,
+ "end_date_2": date_ranges.later.lower.year,
"description": "Lorem ipsum",
"volume": "1000000",
"initial_volume": "1000000",
@@ -1362,7 +1374,10 @@ def test_create_new_quota_definition_business_rule_violation(
}
url = reverse("quota_definition-ui-create", kwargs={"sid": quota.sid})
- response = client_with_current_workbasket.post(url, form_data)
+ response = client_with_current_workbasket.post(
+ f"{url}?order_number={quota.order_number}",
+ form_data,
+ )
assert response.status_code == 200
@@ -2249,7 +2264,6 @@ def test_definition_duplicator_creates_definition_and_association(
wizard.form_list = OrderedDict(wizard.form_list)
association_table_before = models.QuotaAssociation.objects.all()
- # assert 0
assert len(association_table_before) == 0
for definition in session_request_with_workbasket.session["staged_definition_data"]:
wizard.create_definition(definition)
@@ -2723,3 +2737,222 @@ def test_quota_definition_update_updates_association(
assert len(associations) == 2
assert associations[0].update_type == UpdateType.CREATE
assert associations[1].update_type == UpdateType.UPDATE
+
+
+def test_definition_bulk_create_form_wizard_start(client_with_current_workbasket):
+ url = reverse("quota_definition-ui-bulk-create", kwargs={"step": "start"})
+ response = client_with_current_workbasket.get(url)
+ assert response.status_code == 200
+
+
+@pytest.mark.parametrize(
+ ("step"),
+ [
+ ("start"),
+ ("definition_period_info"),
+ ("review"),
+ ],
+)
+def test_bulk_create_definitions_get_form_kwargs(
+ session_request,
+ step,
+):
+ quota_order_number = factories.QuotaOrderNumberFactory.create()
+ start_form_data = {
+ "quota_definition-ui-bulk-create": "start",
+ "start-quota_order_number": quota_order_number,
+ }
+
+ definition_info_data = {}
+ storage = QuotaDefinitionBulkCreatorSessionStorage(
+ request=session_request,
+ prefix="",
+ )
+ storage.set_step_data("start", start_form_data)
+ storage.set_step_data("definition_period_info", definition_info_data)
+ storage._set_current_step("review")
+
+ wizard = QuotaDefinitionBulkCreatorWizard(
+ request=session_request,
+ storage=storage,
+ )
+ wizard.form_list = OrderedDict(wizard.form_list)
+ with override_current_transaction(Transaction.objects.last()):
+ kwargs = wizard.get_form_kwargs(step)
+ assert kwargs["request"].session
+
+
+def test_bulk_create_get_staged_definition_data(
+ session_request,
+ date_ranges,
+):
+ quota_order_number = factories.QuotaOrderNumberFactory.create()
+ measurement_unit = factories.MeasurementUnitFactory()
+ start_form_data = {
+ "quota_definition-ui-bulk-create": "start",
+ "start-quota_order_number": quota_order_number,
+ }
+ storage = QuotaDefinitionBulkCreatorSessionStorage(
+ request=session_request,
+ prefix="",
+ )
+ storage.set_step_data("start", start_form_data)
+ staged_data = {
+ "start_date_0": date_ranges.normal.lower.day,
+ "start_date_1": date_ranges.normal.lower.month,
+ "start_date_2": date_ranges.normal.lower.year,
+ "end_date_0": date_ranges.normal.upper.day,
+ "end_date_1": date_ranges.normal.upper.month,
+ "end_date_2": date_ranges.normal.upper.year,
+ "description": "Lorem ipsum.",
+ "volume": "80601000.000",
+ "initial_volume": "80601000.000",
+ "measurement_unit": measurement_unit.pk,
+ "measurement_unit_qualifier": "",
+ "quota_critical_threshold": "90",
+ "quota_critical": "False",
+ }
+ session_request.session["staged_definition_data"] = staged_data
+ wizard = QuotaDefinitionBulkCreatorWizard(
+ request=session_request,
+ storage=storage,
+ )
+ wizard.form_list = OrderedDict(wizard.form_list)
+
+ assert wizard.get_staged_definition_data() == staged_data
+
+
+def test_bulk_create_format_date(session_request):
+ storage = QuotaDefinitionBulkCreatorSessionStorage(
+ request=session_request,
+ prefix="",
+ )
+ wizard = QuotaDefinitionBulkCreatorWizard(
+ request=session_request,
+ storage=storage,
+ )
+ date_str = "2021-01-01"
+ formatted_date = wizard.format_date(date_str)
+ assert formatted_date == "01 Jan 2021"
+
+
+def test_bulk_create_creates_definition(
+ session_request_with_workbasket,
+ date_ranges,
+):
+ quota_order_number = factories.QuotaOrderNumberFactory.create()
+ measurement_unit = factories.MeasurementUnitFactory.create()
+ measurement_unit_qualifier = factories.MeasurementUnitQualifierFactory.create()
+ storage = QuotaDefinitionBulkCreatorSessionStorage(
+ request=session_request_with_workbasket,
+ prefix="",
+ )
+ wizard = QuotaDefinitionBulkCreatorWizard(
+ request=session_request_with_workbasket,
+ storage=storage,
+ )
+ wizard.form_list = OrderedDict(wizard.form_list)
+ staged_data = {
+ "start_date": serialize_date(date_ranges.normal.lower),
+ "end_date": serialize_date(date_ranges.normal.upper),
+ "description": "Lorem ipsum.",
+ "volume": "80601000.000",
+ "initial_volume": "80601000.000",
+ "measurement_unit_code": measurement_unit.code,
+ "measurement_unit_qualifier": measurement_unit_qualifier.pk,
+ "quota_critical_threshold": "90",
+ "quota_critical": "False",
+ "maximum_precision": "3",
+ }
+ session_request_with_workbasket.session["staged_definition_data"] = staged_data
+ assert len(models.QuotaDefinition.objects.all()) == 0
+ with override_current_transaction(Transaction.objects.last()):
+ wizard.create_definition(
+ order_number=quota_order_number.pk,
+ definition=staged_data,
+ )
+ assert len(models.QuotaDefinition.objects.all()) == 1
+
+
+def test_bulk_create_done(
+ session_request_with_workbasket,
+ date_ranges,
+):
+ quota_order_number = factories.QuotaOrderNumberFactory.create()
+ measurement_unit = factories.MeasurementUnitFactory.create()
+ storage = QuotaDefinitionBulkCreatorSessionStorage(
+ request=session_request_with_workbasket,
+ prefix="",
+ )
+ wizard = QuotaDefinitionBulkCreatorWizard(
+ request=session_request_with_workbasket,
+ storage=storage,
+ )
+ wizard.form_list = OrderedDict(wizard.form_list)
+ staged_data = [
+ {
+ "start_date": serialize_date(date_ranges.normal.lower),
+ "end_date": serialize_date(date_ranges.normal.upper),
+ "description": "Lorem ipsum.",
+ "volume": "80601000.000",
+ "initial_volume": "80601000.000",
+ "measurement_unit_code": measurement_unit.code,
+ "quota_critical_threshold": "90",
+ "quota_critical": "False",
+ "maximum_precision": "3",
+ },
+ {
+ "start_date": serialize_date(date_ranges.normal.lower),
+ "end_date": serialize_date(date_ranges.normal.upper),
+ "description": "Lorem ipsum.",
+ "volume": "80601000.000",
+ "initial_volume": "80601000.000",
+ "measurement_unit_code": measurement_unit.code,
+ "quota_critical_threshold": "90",
+ "quota_critical": "False",
+ "maximum_precision": "3",
+ },
+ {
+ "start_date": serialize_date(date_ranges.normal.lower),
+ "end_date": serialize_date(date_ranges.normal.upper),
+ "description": "Lorem ipsum.",
+ "volume": "80601000.000",
+ "initial_volume": "80601000.000",
+ "measurement_unit_code": measurement_unit.code,
+ "quota_critical_threshold": "90",
+ "quota_critical": "False",
+ "maximum_precision": "3",
+ },
+ ]
+
+ session_request_with_workbasket.session["quota_order_number_pk"] = (
+ quota_order_number.pk
+ )
+ session_request_with_workbasket.session["staged_definition_data"] = staged_data
+ assert len(models.QuotaDefinition.objects.all()) == 0
+ with override_current_transaction(Transaction.objects.last()):
+ wizard.done(wizard.form_list)
+ assert len(models.QuotaDefinition.objects.all()) == 3
+
+
+def test_bulk_create_update_definition_get_form_kwargs(
+ session_request,
+):
+ request = session_request.post("quota_definition-ui-bulk-create-edit", args=1)
+ view = QuotaDefinitionBulkCreatorUpdateDefinitionData(
+ request=request,
+ kwargs={
+ "pk": 1,
+ "request": request,
+ "buttons": {
+ "submit": "Save and continue",
+ "link_text": "Discard changes",
+ "link": "/quotas/quota_definitions/bulk_create/review",
+ },
+ },
+ )
+ with override_current_transaction(Transaction.objects.last()):
+ kwargs = view.get_form_kwargs()
+ assert kwargs["pk"]
+ assert kwargs["request"]
+ assert kwargs["buttons"]
diff --git a/quotas/urls.py b/quotas/urls.py
index cab10fa77..48d988806 100644
--- a/quotas/urls.py
+++ b/quotas/urls.py
@@ -69,6 +69,32 @@
views.QuotaDefinitionCreate.as_view(),
name="quota_definition-ui-create",
),
+ path(
+ f"quotas/quota_definitions/bulk_create/",
+ views.QuotaDefinitionBulkCreatorWizard.as_view(
+ url_name="quota_definition-ui-bulk-create",
+ done_step_name="complete",
+ ),
+ name="quota_definition-ui-bulk-create",
+ ),
+ path(
+ f"quotas/quota_definitions/bulk_create/",
+ views.QuotaDefinitionBulkCreatorWizard.as_view(
+ url_name="quota_definition-ui-bulk-create",
+ done_step_name="complete",
+ ),
+ name="quota_definition-ui-bulk-create",
+ ),
+ path(
+ f"quotas/quota_definitions/bulk_create//edit",
+ views.QuotaDefinitionBulkCreatorUpdateDefinitionData.as_view(),
+ name="quota_definition-ui-bulk-create-edit",
+ ),
+ path(
+ f"quotas/bulk_create_success",
+ views.QuotaDefinitionBulkCreateSuccess.as_view(),
+ name="quota_definition-ui-bulk-create-success",
+ ),
path(
f"quotas/duplicate_quota_definitions/",
views.DuplicateDefinitionsWizard.as_view(
diff --git a/quotas/views/definitions.py b/quotas/views/definitions.py
index afdb7b7f9..162572c02 100644
--- a/quotas/views/definitions.py
+++ b/quotas/views/definitions.py
@@ -185,14 +185,41 @@ def get_result_object(self, form):
return definition_instance
-class QuotaDefinitionCreate(QuotaDefinitionUpdateMixin, CreateTaricCreateView):
+class QuotaDefinitionCreate(CreateTaricCreateView):
template_name = "quota-definitions/create.jinja"
form_class = forms.QuotaDefinitionCreateForm
+ permission_required = ["common.change_trackedmodel"]
+ model = models.QuotaOrderNumber
- 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)
+ validate_business_rules = (
+ business_rules.QD7,
+ business_rules.QD8,
+ business_rules.QD10,
+ business_rules.QD11,
+ UniqueIdentifyingFields,
+ UpdateValidity,
+ )
+
+ @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_order_number=self.request.GET["order_number"],
+ quota_sid=self.kwargs["sid"],
+ **kwargs,
+ )
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs["order_number"] = self.quota
+ kwargs["buttons"] = {
+ "submit": "Submit",
+ "link_text": "Cancel",
+ "link": "/workbaskets/current",
+ }
+ return kwargs
class QuotaDefinitionConfirmCreate(
diff --git a/quotas/views/wizards.py b/quotas/views/wizards.py
index 0472cc341..8035b8566 100644
--- a/quotas/views/wizards.py
+++ b/quotas/views/wizards.py
@@ -15,6 +15,7 @@
from common.views import BusinessRulesMixin
from quotas import forms
from quotas import models
+from quotas.serializers import deserialize_bulk_create_definition_data
from quotas.serializers import deserialize_definition_data
from settings.common import DATE_FORMAT
from workbaskets.models import WorkBasket
@@ -228,7 +229,7 @@ def get_form_kwargs(self):
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"]
+ context["quota_order_number_pk"] = self.kwargs["pk"]
return context
def get_main_definition(self):
@@ -271,3 +272,145 @@ def get_context_data(self, **kwargs):
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 QuotaDefinitionBulkCreatorWizard(
+ PermissionRequiredMixin,
+ NamedUrlSessionWizardView,
+):
+ """Multipart form wizard to bulk create QuotaDefinitions."""
+
+ storage_name = "quotas.wizard.QuotaDefinitionBulkCreatorSessionStorage"
+ permission_required = ["common.add_trackedmodel"]
+
+ START = "start"
+ DEFINITION_PERIOD_INFO = "definition_period_info"
+ REVIEW = "review"
+ COMPLETE = "complete"
+
+ form_list = [
+ (START, forms.BulkQuotaDefinitionCreateStartForm),
+ (DEFINITION_PERIOD_INFO, forms.QuotaDefinitionBulkCreateDefinitionInformation),
+ (REVIEW, forms.BulkQuotaDefinitionCreateReviewForm),
+ ]
+
+ templates = {
+ START: "quota-definitions/bulk-create-start.jinja",
+ DEFINITION_PERIOD_INFO: "quota-definitions/bulk-create-definition-info.jinja",
+ REVIEW: "quota-definitions/bulk-create-review.jinja",
+ COMPLETE: "quota-definitions/bulk-create-done.jinja",
+ }
+
+ step_metadata = {
+ START: {
+ "title": "Create multiple definition periods",
+ "link_text": "start",
+ },
+ DEFINITION_PERIOD_INFO: {
+ "title": "Enter quota definition period data",
+ "link_text": "Enter quota definition period data",
+ },
+ REVIEW: {
+ "title": "Review bulk creation information",
+ "link_text": "Review information",
+ },
+ 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/bulk-create-step.jinja",
+ )
+ return template
+
+ def get_form_kwargs(self, step):
+ kwargs = {}
+ kwargs["request"] = self.request
+ return kwargs
+
+ def get_staged_definition_data(self):
+ return self.request.session["staged_definition_data"]
+
+ 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 create_definition(self, order_number, definition):
+ transaction = self.workbasket.new_transaction()
+ staged_data = deserialize_bulk_create_definition_data(
+ definition,
+ order_number,
+ )
+ models.QuotaDefinition.objects.create(
+ **staged_data,
+ transaction=transaction,
+ )
+
+ def done(self, form_list, **kwargs):
+ order_number_pk = self.request.session["quota_order_number_pk"]
+ definition_data = self.request.session["staged_definition_data"]
+ with transaction.atomic():
+ for definition in definition_data:
+ self.create_definition(order_number_pk, definition)
+
+ return redirect("quota_definition-ui-bulk-create-success")
+
+
+class QuotaDefinitionBulkCreatorUpdateDefinitionData(
+ FormView,
+):
+ template_name = "quota-definitions/bulk-create-definition-edit.jinja"
+ permission_required = ["common.change_trackedmodel"]
+ form_class = forms.BulkDefinitionUpdateData
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs["pk"] = self.kwargs["pk"]
+ kwargs["request"] = self.request
+ kwargs["buttons"] = {
+ "submit": "Save and continue",
+ "link_text": "Discard changes",
+ "link": "/quotas/quota_definitions/bulk_create/review",
+ }
+ return kwargs
+
+ def get_context_data(self, *args, **kwargs):
+ context = super().get_context_data(*args, **kwargs)
+ context["page_title"] = "Update definition details"
+ quota_order_number = models.QuotaOrderNumber.objects.current().get(
+ pk=self.request.session["quota_order_number_pk"],
+ )
+ context["form"].fields["order_number"].initial = quota_order_number
+ return context
+
+ def form_valid(self, form):
+ return redirect(reverse("quota_definition-ui-bulk-create"))
+
+
+class QuotaDefinitionBulkCreateSuccess(TemplateView):
+ template_name = "quota-definitions/bulk-create-done.jinja"
+
+ def get_context_data(self, *args, **kwargs):
+ context = super().get_context_data(*args, **kwargs)
+ context["quota_order_number"] = models.QuotaOrderNumber.objects.current().get(
+ pk=self.request.session["quota_order_number_pk"],
+ )
+ return context
diff --git a/quotas/wizard.py b/quotas/wizard.py
index 2da160c4a..379e458dd 100644
--- a/quotas/wizard.py
+++ b/quotas/wizard.py
@@ -3,3 +3,7 @@
class QuotaDefinitionDuplicatorSessionStorage(SessionStorage):
pass
+
+
+class QuotaDefinitionBulkCreatorSessionStorage(SessionStorage):
+ pass
diff --git a/workbaskets/jinja2/workbaskets/edit-workbasket.jinja b/workbaskets/jinja2/workbaskets/edit-workbasket.jinja
index b11760b13..f51d3cb32 100644
--- a/workbaskets/jinja2/workbaskets/edit-workbasket.jinja
+++ b/workbaskets/jinja2/workbaskets/edit-workbasket.jinja
@@ -78,6 +78,7 @@
{{ workbasket_column("Quotas", [
{"text": "Create a new quota", "url": url('quota-ui-create')},
{"text": "Find and edit quotas", "url": url('quota-ui-list')},
+ {"text": "Create multiple quota definition periods", "url": url('quota_definition-ui-bulk-create')},
{"text": "Create quota associations", "url": url('sub_quota_definitions-ui-create')},
])
}}
From cc2a94060e7c0f977beca04e2d9c3a2f56ec2370 Mon Sep 17 00:00:00 2001
From: Paul Pepper <85895113+paulpepper-trade@users.noreply.github.com>
Date: Wed, 8 Jan 2025 11:02:13 +0000
Subject: [PATCH 4/8] TP2000-1639 new year envelope publishing failure (#1375)
* List by default, add flags for async and sync publishing
* Fix failing tests
* Correct function name. Call correct queryset filter.
* Set crontab to every two hours to publish to crown dependencies
* Correctly reference workbasket's envelope
---
.../management/commands/publish_to_api.py | 67 +++++++++++++------
publishing/models/packaged_workbasket.py | 38 ++++++-----
publishing/tests/test_publishing_commands.py | 10 +--
settings/common.py | 2 +-
4 files changed, 73 insertions(+), 44 deletions(-)
diff --git a/publishing/management/commands/publish_to_api.py b/publishing/management/commands/publish_to_api.py
index 9a29a0060..02c166200 100644
--- a/publishing/management/commands/publish_to_api.py
+++ b/publishing/management/commands/publish_to_api.py
@@ -8,28 +8,40 @@
class Command(BaseCommand):
- help = "Upload unpublished envelopes to the Tariff API."
+ help = (
+ "Manage envelope uploads to Tariff API. Without arguments, this "
+ "management command lists unpublished envelopes."
+ )
def add_arguments(self, parser):
parser.add_argument(
- "-l",
- "--list",
- dest="list",
+ "--publish-async",
action="store_true",
- help="List unpublished envelopes.",
+ help=(
+ "Asynchronously run (via a Celery task) the function to "
+ "upload unpublished envelopes to the tariffs-api service."
+ ),
+ )
+ parser.add_argument(
+ "--publish-now",
+ action="store_true",
+ help=(
+ "Immediately run (within the current terminal's process) the "
+ "function to upload unpublished envelopes to the tariffs-api "
+ "service."
+ ),
)
def get_incomplete_envelopes(self):
- incomplete = CrownDependenciesEnvelope.objects.unpublished()
- if not incomplete:
- return None
- return incomplete
+ return CrownDependenciesEnvelope.objects.unpublished()
def get_unpublished_envelopes(self):
- unpublished = PackagedWorkBasket.objects.get_unpublished_to_api()
- if not unpublished:
- return None
- return unpublished
+ return PackagedWorkBasket.objects.get_unpublished_to_api()
+
+ def print_envelope_details(self, position, envelope):
+ self.stdout.write(
+ f"position={position}, pk={envelope.pk}, envelope_id={envelope.envelope_id}",
+ )
def list_unpublished_envelopes(self):
incomplete = self.get_incomplete_envelopes()
@@ -39,22 +51,33 @@ def list_unpublished_envelopes(self):
f"{incomplete.count()} envelope(s) not completed publishing:",
)
for i, crowndependencies in enumerate(incomplete, start=1):
- self.stdout.write(
- f"{i}: {crowndependencies.packagedworkbaskets.last().envelope}",
+ self.print_envelope_details(
+ position=i,
+ envelope=crowndependencies.packagedworkbaskets.last().envelope,
)
if unpublished:
self.stdout.write(
f"{unpublished.count()} envelope(s) ready to be published in the following order:",
)
for i, packaged_work_basket in enumerate(unpublished, start=1):
- self.stdout.write(f"{i}: {packaged_work_basket.envelope}")
-
- def handle(self, *args, **options):
- if options["list"]:
- self.list_unpublished_envelopes()
- return
+ self.print_envelope_details(
+ position=i,
+ envelope=packaged_work_basket.envelope,
+ )
+ def publish(self, now: bool):
if self.get_unpublished_envelopes() or self.get_incomplete_envelopes():
- publish_to_api.apply()
+ if now:
+ self.stdout.write(f"Calling `publish_to_api()` now.")
+ publish_to_api()
+ else:
+ self.stdout.write(f"Calling `publish_to_api()` asynchronously.")
+ publish_to_api.apply()
else:
sys.exit("No unpublished envelopes")
+
+ def handle(self, *args, **options):
+ if options["publish_async"] or options["publish_now"]:
+ self.publish(now=options["publish_now"])
+ else:
+ self.list_unpublished_envelopes()
diff --git a/publishing/models/packaged_workbasket.py b/publishing/models/packaged_workbasket.py
index 93b240644..cc24edfa9 100644
--- a/publishing/models/packaged_workbasket.py
+++ b/publishing/models/packaged_workbasket.py
@@ -243,12 +243,9 @@ def get_unpublished_to_api(self) -> "PackagedWorkBasketQuerySet":
).order_by("envelope__envelope_id")
return unpublished
- def last_unpublished_envelope_id(self) -> "publishing_models.EnvelopeId":
- """Join PackagedWorkBasket with Envelope and CrownDependenciesEnvelope
- model selecting objects Where an Envelope model exists and the
- published_to_tariffs_api field is not null Or Where a
- CrownDependenciesEnvelope is not null Then select the max value for ther
- envelope_id field in the Envelope instance."""
+ def last_published_envelope_id(self) -> "publishing_models.EnvelopeId":
+ """Get the envelope_id of the last Envelope successfully published to
+ the Tariffs API service."""
return (
self.select_related(
@@ -409,20 +406,29 @@ def next_expected_to_api(self) -> bool:
(this means the envelope is the first to be published to the API)
"""
- previous_id = PackagedWorkBasket.objects.last_unpublished_envelope_id()
+ previous_id = PackagedWorkBasket.objects.last_published_envelope_id()
if self.envelope.envelope_id[2:] == settings.HMRC_PACKAGING_SEED_ENVELOPE_ID:
- year = int(self.envelope.envelope_id[:2])
- last_envelope = publishing_models.Envelope.objects.for_year(
- year=year - 1,
- ).last()
- # uses None if first envelope (no previous ones)
- expected_previous_id = last_envelope.envelope_id if last_envelope else None
- else:
- expected_previous_id = str(
- int(self.envelope.envelope_id) - 1,
+ # NOTE:
+ # Code in this conditional block, and therefore this function,
+ # wrongly assumes a new year has passed since the last envelope was
+ # successfully published to tariffs-api.
+ # See Jira ticket TP2000-1646 for details of the issue.
+
+ current_envelope_year = int(self.envelope.envelope_id[:2])
+ last_envelope_last_year = (
+ publishing_models.Envelope.objects.last_envelope_for_year(
+ year=current_envelope_year - 1,
+ )
+ )
+ expected_previous_id = (
+ last_envelope_last_year.envelope_id if last_envelope_last_year else None
)
+ else:
+ expected_previous_id = str(int(self.envelope.envelope_id) - 1)
+
if previous_id and previous_id != expected_previous_id:
return False
+
return True
# processing_state transition management.
diff --git a/publishing/tests/test_publishing_commands.py b/publishing/tests/test_publishing_commands.py
index 67c18a688..31d06039e 100644
--- a/publishing/tests/test_publishing_commands.py
+++ b/publishing/tests/test_publishing_commands.py
@@ -20,11 +20,11 @@ def test_publish_to_api_lists_unpublished_envelopes(
packaged_work_baskets = PackagedWorkBasket.objects.get_unpublished_to_api()
out = StringIO()
- call_command("publish_to_api", "--list", stdout=out)
+ call_command("publish_to_api", stdout=out)
output = out.getvalue()
for packaged_work_basket in packaged_work_baskets:
- assert str(packaged_work_basket.envelope) in output
+ assert f"envelope_id={packaged_work_basket.envelope.envelope_id}" in output
def test_publish_to_api_lists_no_envelopes(
@@ -34,7 +34,7 @@ def test_publish_to_api_lists_no_envelopes(
settings.ENABLE_PACKAGING_NOTIFICATIONS = False
out = StringIO()
- call_command("publish_to_api", "--list", stdout=out)
+ call_command("publish_to_api", stdout=out)
output = out.getvalue()
assert not output
@@ -46,7 +46,7 @@ def test_publish_to_api_exits_no_unpublished_envelopes():
assert CrownDependenciesEnvelope.objects.unpublished().count() == 0
with pytest.raises(SystemExit):
- call_command("publish_to_api")
+ call_command("publish_to_api", "--publish-async")
def test_publish_to_api_publishes_envelopes(successful_envelope_factory, settings):
@@ -57,6 +57,6 @@ def test_publish_to_api_publishes_envelopes(successful_envelope_factory, setting
assert PackagedWorkBasket.objects.get_unpublished_to_api().count() == 1
- call_command("publish_to_api")
+ call_command("publish_to_api", "--publish-async")
assert PackagedWorkBasket.objects.get_unpublished_to_api().count() == 0
diff --git a/settings/common.py b/settings/common.py
index 041d45eba..614f98346 100644
--- a/settings/common.py
+++ b/settings/common.py
@@ -633,7 +633,7 @@
CROWN_DEPENDENCIES_API_CRON = (
crontab(os.environ.get("CROWN_DEPENDENCIES_API_CRON"))
if os.environ.get("CROWN_DEPENDENCIES_API_CRON")
- else crontab(minute="0", hour="8-18/2", day_of_week="mon-fri")
+ else crontab(minute="0", hour="*/2")
)
CELERY_BEAT_SCHEDULE["crown_dependencies_api_publish"] = {
"task": "publishing.tasks.publish_to_api",
From a908527a14adbea4fc3180b0502a62425b820b42 Mon Sep 17 00:00:00 2001
From: Matthew McKenzie <97194636+mattjamc@users.noreply.github.com>
Date: Wed, 8 Jan 2025 15:07:19 +0000
Subject: [PATCH 5/8] Fix dropdown queryset (#1376)
---
quotas/forms/base.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/quotas/forms/base.py b/quotas/forms/base.py
index ca23fa4e0..5e3f6bb4c 100644
--- a/quotas/forms/base.py
+++ b/quotas/forms/base.py
@@ -251,8 +251,8 @@ def init_fields(self):
self.fields["quota_definition"].queryset = (
models.QuotaDefinition.objects.current()
.as_at_today_and_beyond()
- .filter(order_number=self.quota_order_number)
- .order_by("-sid")
+ .filter(order_number__sid=self.quota_order_number.sid)
+ .order_by("valid_between")
)
self.fields["quota_definition"].label_from_instance = (
lambda obj: f"{obj.sid} ({obj.valid_between.lower} - {obj.valid_between.upper})"
From 755fac69c5b5003dba2dad826edd63afa2fc8c08 Mon Sep 17 00:00:00 2001
From: Marya Shariq
Date: Thu, 9 Jan 2025 11:17:15 +0000
Subject: [PATCH 6/8] Tp2000 1542 workflow template admin support (#1374)
* added admin support for task workflow templates and task templates
* admin support for task workflow templates and task templates
* created ReadOnlyAdminMixIn for use with both taskworkflowtemplate admin and tasktemplate admin
---
tasks/admin.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 50 insertions(+)
diff --git a/tasks/admin.py b/tasks/admin.py
index 790685feb..04e2cfb9d 100644
--- a/tasks/admin.py
+++ b/tasks/admin.py
@@ -7,7 +7,9 @@
from tasks.models import Task
from tasks.models import TaskAssignee
from tasks.models import TaskLog
+from tasks.models import TaskTemplate
from tasks.models import TaskWorkflow
+from tasks.models import TaskWorkflowTemplate
class TaskAdminMixin:
@@ -115,6 +117,51 @@ class TaskLogAdmin(admin.ModelAdmin):
]
+class ReadOnlyAdminMixin:
+ def has_add_permission(self, request):
+ return False
+
+ def has_change_permission(self, request, obj=None):
+ return False
+
+ def has_delete_permission(self, request, obj=None):
+ return False
+
+
+class TaskWorkflowTemplateAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
+ list_display = (
+ "id",
+ "title",
+ "description",
+ "creator",
+ )
+
+
+class TaskTemplateAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
+ list_display = (
+ "id",
+ "title",
+ "description",
+ "taskworkflowtemplate_id",
+ )
+
+ @admin.display(description="Task Workflow Template")
+ def taskworkflowtemplate_id(self, obj):
+ if not obj.taskitemtemplate:
+ return "-"
+ return self.link_to_task_workflow_template(obj.taskitemtemplate)
+
+ def link_to_task_workflow_template(self, task_item_template):
+ workflow_template_pk = task_item_template.workflow_template.pk
+ task_workflow_template_url = reverse(
+ "admin:tasks_taskworkflowtemplate_change",
+ args=(workflow_template_pk,),
+ )
+ return mark_safe(
+ f'{workflow_template_pk}',
+ )
+
+
class TaskWorflowAdmin(admin.ModelAdmin):
search_fields = ["summary_task__title"]
list_display = [
@@ -135,4 +182,7 @@ class TaskWorflowAdmin(admin.ModelAdmin):
admin.site.register(TaskLog, TaskLogAdmin)
+admin.site.register(TaskWorkflowTemplate, TaskWorkflowTemplateAdmin)
+
+admin.site.register(TaskTemplate, TaskTemplateAdmin)
admin.site.register(TaskWorkflow, TaskWorflowAdmin)
From e608ccd0e3c0790c4b85711d3c357ad52a70ca83 Mon Sep 17 00:00:00 2001
From: Charlie Prichard <46421052+CPrich905@users.noreply.github.com>
Date: Fri, 10 Jan 2025 08:43:03 +0000
Subject: [PATCH 7/8] TP-2000 1647 UI changes to bulk create quota definitions
(#1377)
* Follow up changes to bulk create journey
* second round changes
---
quotas/forms/definitions.py | 24 ++++++----------
quotas/forms/wizards.py | 28 +++++++++++--------
.../bulk-create-definition-edit.jinja | 2 +-
.../bulk-create-definition-info.jinja | 5 ++--
.../bulk-create-review.jinja | 6 ++--
.../quota-definitions/bulk-create-start.jinja | 2 +-
6 files changed, 34 insertions(+), 33 deletions(-)
diff --git a/quotas/forms/definitions.py b/quotas/forms/definitions.py
index 8a03b880b..8e916d3f8 100644
--- a/quotas/forms/definitions.py
+++ b/quotas/forms/definitions.py
@@ -68,12 +68,12 @@ class Meta:
},
)
measurement_unit = forms.ModelChoiceField(
- queryset=MeasurementUnit.objects.current(),
+ queryset=MeasurementUnit.objects.current().order_by("code"),
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.",
+ 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",
@@ -82,7 +82,7 @@ class Meta:
)
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.",
+ 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(),
@@ -99,9 +99,6 @@ def clean(self):
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}"
)
@@ -179,7 +176,7 @@ class Meta:
},
)
measurement_unit = forms.ModelChoiceField(
- queryset=MeasurementUnit.objects.current(),
+ queryset=MeasurementUnit.objects.current().order_by("code"),
empty_label="Choose measurement unit",
error_messages={"required": "Select the measurement unit"},
)
@@ -187,7 +184,7 @@ class Meta:
quota_critical_threshold = forms.DecimalField(
label="Threshold",
widget=DecimalSuffix(suffix="%"),
- help_text="The point at which this quota definition period becomes critical, as a percentage of the total volume.",
+ help_text="The point at which this quota definition period becomes critical, as a percentage of the total volume",
error_messages={
"invalid": "Critical threshold must be a number",
"required": "Enter the critical threshold",
@@ -195,7 +192,7 @@ class Meta:
)
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.",
+ 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(),
@@ -231,9 +228,6 @@ def init_fields(self):
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}"
)
@@ -248,10 +242,10 @@ def init_fields(self):
self.fields["end_date"].help_text = ""
self.fields["end_date"].required = True
self.fields["measurement_unit_qualifier"].help_text = (
- "A measurement unit qualifier is not always required."
+ "A measurement unit qualifier is not always required"
)
self.fields["measurement_unit_qualifier"].empty_label = (
- "Choose measurement unit qualifier."
+ "Choose measurement unit qualifier"
)
def init_layout(self, *args, **kwargs):
@@ -309,7 +303,7 @@ def init_layout(self, *args, **kwargs):
HTML(
'Description
',
),
- HTML.p("Adding a description is optional."),
+ HTML.p("Adding a description is optional"),
Field("description", css_class="govuk-!-width-two-thirds"),
),
HTML(
diff --git a/quotas/forms/wizards.py b/quotas/forms/wizards.py
index 77eea97e1..0efa9fa4a 100644
--- a/quotas/forms/wizards.py
+++ b/quotas/forms/wizards.py
@@ -170,9 +170,6 @@ def init_layout(self, request):
self.helper.layout = Layout(
Div(
- HTML(
- 'Enter quota order number
',
- ),
Div(
"quota_order_number",
css_class="govuk-!-width-one-third",
@@ -250,14 +247,14 @@ class Meta:
measurement_unit = forms.ModelChoiceField(
empty_label="Choose measurement unit",
- queryset=MeasurementUnit.objects.current(),
+ queryset=MeasurementUnit.objects.current().order_by("code"),
error_messages={"required": "Select the measurement unit"},
)
quota_critical_threshold = forms.DecimalField(
label="Threshold",
widget=DecimalSuffix(suffix="%"),
- help_text="The point at which this quota definition period becomes critical, as a percentage of the total volume.",
+ help_text="The point at which this quota definition period becomes critical, as a percentage of the total volume",
error_messages={
"invalid": "Critical threshold must be a number",
"required": "Enter the critical threshold",
@@ -265,7 +262,7 @@ class Meta:
)
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.",
+ 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(),
@@ -292,6 +289,9 @@ def init_fields(self):
self.fields["maximum_precision"].initial = 3
self.fields["end_date"].help_text = ""
self.fields["end_date"].required = True
+ self.fields["measurement_unit"].label_from_instance = (
+ lambda obj: f"{obj.code} - {obj.description}"
+ )
self.fields["measurement_unit_qualifier"].help_text = (
"A measurement unit qualifier is not always required"
)
@@ -413,8 +413,11 @@ def init_layout(self):
'First definition period
',
),
Div(
- HTML(
- 'Enter the dates for the first definition period you are creating. Subsequent definition period dates will be calculated based on the dates entered for this first period
',
+ Div(
+ HTML(
+ 'Enter the dates for the first definition period you are creating. Subsequent definition period dates will be calculated based on the dates entered for this first period.
',
+ ),
+ css_class="govuk-!-width-two-thirds",
),
"start_date",
"end_date",
@@ -426,7 +429,7 @@ def init_layout(self):
),
Div(
HTML(
- 'Select the frequency at which the subsequent definition periods should be duplicated
',
+ 'Select the frequency at which the subsequent definition periods should be duplicated.
',
),
"frequency",
Field(
@@ -550,6 +553,9 @@ def init_fields(self):
fields["measurement_unit"].initial = MeasurementUnit.objects.get(
code=definition_data["measurement_unit_code"],
)
+ self.fields["measurement_unit"].label_from_instance = (
+ lambda obj: f"{obj.code} - {obj.description}"
+ )
if "description" in definition_data:
fields["description"].initial = definition_data["description"]
@@ -564,14 +570,14 @@ def init_fields(self):
)
else:
self.fields["measurement_unit_qualifier"].empty_label = (
- "Choose measurement unit qualifier."
+ "Choose measurement unit qualifier"
)
fields["quota_critical_threshold"].initial = decimal.Decimal(
definition_data["threshold"],
)
fields["quota_critical"].initial = definition_data["quota_critical"]
self.fields["measurement_unit_qualifier"].help_text = (
- "A measurement unit qualifier is not always required."
+ "A measurement unit qualifier is not always required"
)
def update_definition_data_in_session(self, cleaned_data):
diff --git a/quotas/jinja2/quota-definitions/bulk-create-definition-edit.jinja b/quotas/jinja2/quota-definitions/bulk-create-definition-edit.jinja
index dfee6114c..435ba3c1d 100644
--- a/quotas/jinja2/quota-definitions/bulk-create-definition-edit.jinja
+++ b/quotas/jinja2/quota-definitions/bulk-create-definition-edit.jinja
@@ -7,7 +7,7 @@
{% block page_title_heading %}
{{ page_title }}
{% endblock %}
-
+
{% block page_subtitle %}
{{ page_subtitle }}
{% endblock %}
diff --git a/quotas/jinja2/quota-definitions/bulk-create-definition-info.jinja b/quotas/jinja2/quota-definitions/bulk-create-definition-info.jinja
index 5bf18eac3..170a6ebd6 100644
--- a/quotas/jinja2/quota-definitions/bulk-create-definition-info.jinja
+++ b/quotas/jinja2/quota-definitions/bulk-create-definition-info.jinja
@@ -7,14 +7,15 @@
{% block page_title_heading %}
{{ page_title }}
{% endblock %}
-
+
+
{% block page_subtitle %}
{{ page_subtitle }}
{% endblock %}
- Changes can be made to individual definition periods on the review page before submission
+ Changes can be made to individual definition periods on the review page before submission.
diff --git a/quotas/jinja2/quota-definitions/bulk-create-review.jinja b/quotas/jinja2/quota-definitions/bulk-create-review.jinja
index 4b0d7c8f5..57a59dcae 100644
--- a/quotas/jinja2/quota-definitions/bulk-create-review.jinja
+++ b/quotas/jinja2/quota-definitions/bulk-create-review.jinja
@@ -12,7 +12,7 @@
{% block page_title_heading %}
{{ page_title }}
{% endblock %}
-
+
{% block page_subtitle %}
{{ page_subtitle }}
{% endblock %}
@@ -72,7 +72,7 @@
]) or ""}}
{% endfor %}
- Each row represents a Quota definition period to be created. These can be edited before being submitted.
+ Each row represents a quota definition period to be created. These can be edited before being submitted.
{{ govukTable({
"head": [
@@ -87,7 +87,7 @@
}) }}
Selecting 'Submit' will create the new definition periods.
- Further edits to the definition periods can be made on the quota order number page through an additional workbasket transaction.
+ Further edits to the definition periods can be made on the quota order number page.