diff --git a/.gitignore b/.gitignore index 76fce04d9..073eab4be 100644 --- a/.gitignore +++ b/.gitignore @@ -166,3 +166,4 @@ _dumped_cache.pkl # Database dumps *.sql +/.vscode/settings.json diff --git a/README.rst b/README.rst index 2faa6e295..4bc614354 100644 --- a/README.rst +++ b/README.rst @@ -398,6 +398,21 @@ This tool is available as an importer alternative found within the web front end This tool addresses several short falls that the current importer has. +Reference document data import +------------------------------ + +WARNING: this feature is in alpha : do not use in production until this feature has been +fully tested. + +In order to populate the reference document data from extracted data from an external tool +you can use the management command ref_doc_csv_importer + +Example: + + .. code:: sh + + $ python manage.py ref_doc_csv_importer "/absolute/path/to/duties.csv" "/absolute/path/to/quotas.csv" + Using the exporter ------------------ diff --git a/common/forms.py b/common/forms.py index c92840090..00b90d511 100644 --- a/common/forms.py +++ b/common/forms.py @@ -249,6 +249,32 @@ def compress(self, data_list): return None +class DateInputFieldTakesParameters(DateInputField): + def __init__(self, day, month, year, **kwargs): + error_messages = { + "required": "Enter the day, month and year", + "incomplete": "Enter the day, month and year", + } + fields = (day, month, year) + + forms.MultiValueField.__init__( + self, + error_messages=error_messages, + fields=fields, + **kwargs, + ) + + def compress(self, data_list): + day, month, year = data_list or [None, None, None] + if day and month and year: + try: + return date(day=int(day), month=int(month), year=int(year)) + except ValueError as e: + raise ValidationError(str(e).capitalize()) from e + else: + return None + + class GovukDateRangeField(DateRangeField): base_field = DateInputFieldFixed diff --git a/common/jinja2/common/edit.jinja b/common/jinja2/common/edit.jinja index 49225868d..4319321bd 100644 --- a/common/jinja2/common/edit.jinja +++ b/common/jinja2/common/edit.jinja @@ -13,7 +13,7 @@ {% endblock %} {% block form %} - {% call django_form(action=object.get_url("edit")) %} + {% call django_form() %} {{ crispy(form) }} {% endcall %} {% endblock %} diff --git a/common/static/common/js/addNewQuotaDefinitionForm.js b/common/static/common/js/addNewQuotaDefinitionForm.js new file mode 100644 index 000000000..ef48201e2 --- /dev/null +++ b/common/static/common/js/addNewQuotaDefinitionForm.js @@ -0,0 +1,55 @@ +import { removeQuotaDefinitionForm } from "./removeQuotaDefinitionForm.js" + +let formCounter = 1 + +const addNewForm = (event) => { + event.preventDefault(); + + let fieldset = document.querySelector(".quota-definition-row"); + let formset = fieldset.parentNode; + let newForm = fieldset.cloneNode(true); + + let new_class = "quota-definition-row-" + formCounter + newForm.classList.add(new_class) + + newForm.innerHTML = newForm.innerHTML.replaceAll('name="volume_0"', 'name="volume_' + formCounter + '"'); + newForm.innerHTML = newForm.innerHTML.replaceAll('name="start_date_0_0"', 'name="start_date_' + formCounter + '_0"'); + newForm.innerHTML = newForm.innerHTML.replaceAll('name="start_date_0_1"', 'name="start_date_' + formCounter + '_1"'); + newForm.innerHTML = newForm.innerHTML.replaceAll('name="start_date_0_2"', 'name="start_date_' + formCounter + '_2"'); + newForm.innerHTML = newForm.innerHTML.replaceAll('name="end_date_0_0"', 'name="end_date_' + formCounter + '_0"'); + newForm.innerHTML = newForm.innerHTML.replaceAll('name="end_date_0_1"', 'name="end_date_' + formCounter + '_1"'); + newForm.innerHTML = newForm.innerHTML.replaceAll('name="end_date_0_2"', 'name="end_date_' + formCounter + '_2"'); + + let removeFormID = "form-remove_" + formCounter; + newForm.insertAdjacentHTML("beforeend", `
`); + let remove_button = newForm.lastChild + remove_button.addEventListener("click", removeQuotaDefinitionForm); + + let formFields = newForm.querySelectorAll("input"); + for (let field of formFields.values()) { + field.value = ""; + } + + let buttonGroup = document.querySelector('.govuk-button-group'); + formset.insertBefore(newForm, buttonGroup); + + let numForms = document.querySelectorAll(".quota-definition-row").length; + + let addNewButton = document.querySelector("#add-new-definition"); + addNewButton.scrollIntoView(false); + formCounter += 1; + if (numForms >= 10) { + addNewButton.style.display = "none"; + } + + } + +const initAddNewDefinition = () => { + const btn = document.querySelector("#add-new-definition"); + + if (btn) { + btn.addEventListener("click", addNewForm); + } + } + +export { initAddNewDefinition } diff --git a/common/static/common/js/application.js b/common/static/common/js/application.js index 2815d5164..75291a948 100644 --- a/common/static/common/js/application.js +++ b/common/static/common/js/application.js @@ -5,6 +5,7 @@ require.context('govuk-frontend/govuk/assets'); import showHideCheckboxes from './showHideCheckboxes'; import { initAutocomplete } from './autocomplete'; import { initAutocompleteProgressiveEnhancement } from './autocompleteProgressiveEnhancement'; +import { initAddNewDefinition } from './addNewQuotaDefinitionForm'; import { initAddNewEnhancement } from './addNewForm'; import { initCopyToNextDuties } from './copyDuties'; import { initAll } from 'govuk-frontend'; @@ -21,6 +22,7 @@ showHideCheckboxes(); // Initialise accessible-autocomplete components without a `name` attr in order // to avoid the "dummy" autocomplete field being submitted as part of the form // to the server. +initAddNewDefinition(); initAddNewEnhancement(); initAutocomplete(false); initCopyToNextDuties(); diff --git a/common/static/common/js/removeQuotaDefinitionForm.js b/common/static/common/js/removeQuotaDefinitionForm.js new file mode 100644 index 000000000..74ea0a282 --- /dev/null +++ b/common/static/common/js/removeQuotaDefinitionForm.js @@ -0,0 +1,19 @@ +const removeQuotaDefinitionForm = (event) => { + event.preventDefault(); + let remove_id = event.target.id; + let remove_number = remove_id.split('_').at(-1) + + let fieldset_id = 'quota-definition-row-' + remove_number + + let fieldset_to_remove = document.querySelector(".quota-definition-row-" + remove_number) + fieldset_to_remove.remove() + + let addNewButton = document.querySelector("#add-new-definition"); + let numForms = document.querySelectorAll(".quota-definition-row").length; + if (numForms < 10) { + addNewButton.style.display = "inline" + } + + } + +export { removeQuotaDefinitionForm } diff --git a/common/static/common/scss/application.scss b/common/static/common/scss/application.scss index 4bda561ef..8e3030161 100644 --- a/common/static/common/scss/application.scss +++ b/common/static/common/scss/application.scss @@ -27,6 +27,7 @@ $govuk-image-url-function: frontend-image-url; @import "publishing"; @import "regulations"; @import "workbaskets"; +@import "reference_documents"; @import "versions"; @import "fake-link"; @import "violations"; diff --git a/pyproject.toml b/pyproject.toml index ec3ae3bda..28bf40391 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,5 +97,6 @@ norecursedirs = [ "venv", ] markers = [ - "importer_v2" + "importer_v2", + "reference_documents" ] diff --git a/reference_documents/__init__.py b/reference_documents/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/reference_documents/admin.py b/reference_documents/admin.py new file mode 100644 index 000000000..846f6b406 --- /dev/null +++ b/reference_documents/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/reference_documents/alignment_checks.py b/reference_documents/alignment_checks.py new file mode 100644 index 000000000..bda1123c1 --- /dev/null +++ b/reference_documents/alignment_checks.py @@ -0,0 +1,102 @@ +from commodities.models import GoodsNomenclature +from geo_areas.models import GeographicalArea +from datetime import date + +from commodities.models import GoodsNomenclature +from geo_areas.models import GeographicalArea +from geo_areas.models import GeographicalAreaDescription +from reference_documents.models import PreferentialRate + + +class BaseCheck: + def run_check(self): + raise NotImplemented("Please implement on child classes") + + +class BasePreferentialRateCheck(BaseCheck): + def __init__(self, preferential_rate: PreferentialRate): + self.preferential_rate = preferential_rate + + def comm_code(self): + goods = GoodsNomenclature.objects.latest_approved().filter( + item_id=self.preferential_rate.commodity_code, + valid_between__contains=self.ref_doc_version_eif_date(), + suffix=80, + ) + + if len(goods) == 0: + return None + + return goods.first() + + def geo_area(self): + return ( + GeographicalArea.objects.latest_approved() + .filter( + area_id=self.preferential_rate.reference_document_version.reference_document.area_id, + ) + .first() + ) + + def geo_area_description(self): + geo_area_desc = ( + GeographicalAreaDescription.objects.latest_approved() + .filter(described_geographicalarea=self.geo_area()) + .last() + ) + return geo_area_desc.description + + def ref_doc_version_eif_date(self): + eif_date = ( + self.preferential_rate.reference_document_version.entry_into_force_date + ) + + # todo : make sure EIf dates are all populated correctly - and remove this + if eif_date is None: + eif_date = date.today() + + return eif_date + + def related_measures(self): + return ( + self.comm_code() + .measures.latest_approved() + .filter( + geographical_area=self.geo_area(), + valid_between__contains=self.ref_doc_version_eif_date(), + ) + ) + + def run_check(self): + raise NotImplementedError("Please implement on child classes") + + +class CheckPreferentialRateCommCode(BasePreferentialRateCheck): + def run_check(self): + # comm code live on EIF date + if not self.comm_code(): + print( + "FAIL", + self.preferential_rate.commodity_code, + self.geo_area_description(), + "comm code not live", + ) + return False + + measures = self.related_measures() + + if len(measures) == 1: + print("PASS", self.comm_code(), self.geo_area_description()) + return True + + if len(measures) == 0: + print("FAIL", self.comm_code(), self.geo_area_description()) + return False + + if len(measures) > 1: + print( + "WARNING - multiple measures", + self.comm_code(), + self.geo_area_description(), + ) + return True diff --git a/reference_documents/apps.py b/reference_documents/apps.py new file mode 100644 index 000000000..19f1d5436 --- /dev/null +++ b/reference_documents/apps.py @@ -0,0 +1,5 @@ +from common.app_config import CommonConfig + + +class ReferenceDocumentsConfig(CommonConfig): + name = "reference_documents" diff --git a/reference_documents/checks/base.py b/reference_documents/checks/base.py new file mode 100644 index 000000000..dda97f610 --- /dev/null +++ b/reference_documents/checks/base.py @@ -0,0 +1,193 @@ +from datetime import date + +from commodities.models import GoodsNomenclature +from commodities.models.dc import CommodityCollectionLoader +from commodities.models.dc import CommodityTreeSnapshot +from commodities.models.dc import SnapshotMoment +from common.models import Transaction +from geo_areas.models import GeographicalArea +from geo_areas.models import GeographicalAreaDescription +from quotas.models import QuotaOrderNumber +from reference_documents.models import PreferentialQuota +from reference_documents.models import PreferentialQuotaOrderNumber +from reference_documents.models import PreferentialRate + + +class BaseCheck: + def __init__(self): + self.dependent_on_passing_check = None + + def run_check(self): + raise NotImplemented("Please implement on child classes") + + +class BasePreferentialQuotaCheck(BaseCheck): + def __init__(self, preferential_quota: PreferentialQuota): + super().__init__() + self.preferential_quota = preferential_quota + + +class BasePreferentialQuotaOrderNumberCheck(BaseCheck): + def __init__(self, preferential_quota_order_number: PreferentialQuotaOrderNumber): + super().__init__() + self.preferential_quota_order_number = preferential_quota_order_number + + def order_number(self): + try: + order_number = QuotaOrderNumber.objects.all().get( + order_number=self.preferential_quota_order_number.quota_order_number, + valid_between=self.preferential_quota_order_number.valid_between, + ) + return order_number + except QuotaOrderNumber.DoesNotExist: + return None + + +class BasePreferentialRateCheck(BaseCheck): + def __init__(self, preferential_rate: PreferentialRate): + super().__init__() + self.preferential_rate = preferential_rate + + def get_snapshot(self) -> CommodityTreeSnapshot: + # not liking having to use CommodityTreeSnapshot, but it does to the job + item_id = self.comm_code().item_id + while item_id[-2:] == "00": + item_id = item_id[0 : len(item_id) - 2] + + commodities_collection = CommodityCollectionLoader( + prefix=item_id, + ).load(current_only=True) + + latest_transaction = Transaction.objects.order_by("created_at").last() + + snapshot = CommodityTreeSnapshot( + commodities=commodities_collection.commodities, + moment=SnapshotMoment(transaction=latest_transaction), + ) + + return snapshot + + def comm_code(self): + goods = GoodsNomenclature.objects.latest_approved().filter( + item_id=self.preferential_rate.commodity_code, + valid_between__contains=self.ref_doc_version_eif_date(), + suffix=80, + ) + + if len(goods) == 0: + return None + + return goods.first() + + def geo_area(self): + return ( + GeographicalArea.objects.latest_approved() + .filter( + area_id=self.preferential_rate.reference_document_version.reference_document.area_id, + ) + .first() + ) + + def geo_area_description(self): + geo_area_desc = ( + GeographicalAreaDescription.objects.latest_approved() + .filter(described_geographicalarea=self.geo_area()) + .last() + ) + return geo_area_desc.description + + def ref_doc_version_eif_date(self): + eif_date = ( + self.preferential_rate.reference_document_version.entry_into_force_date + ) + + # todo : make sure EIf dates are all populated correctly - and remove this + if eif_date is None: + eif_date = date.today() + + return eif_date + + def related_measures(self, comm_code_item_id=None): + if comm_code_item_id: + good = GoodsNomenclature.objects.latest_approved().filter( + item_id=comm_code_item_id, + valid_between__contains=self.ref_doc_version_eif_date(), + suffix=80, + ) + + if len(good) == 1: + return ( + good.first() + .measures.latest_approved() + .filter( + geographical_area=self.geo_area(), + valid_between__contains=self.ref_doc_version_eif_date(), + measure_type__sid__in=[ + 142, + 143, + ], # note : these are the measure types used to identify preferential tariffs + ) + ) + else: + return [] + else: + return ( + self.comm_code() + .measures.latest_approved() + .filter( + geographical_area=self.geo_area(), + valid_between__contains=self.ref_doc_version_eif_date(), + measure_type__sid__in=[ + 142, + 143, + ], # note : these are the measure types used to identify preferential tariffs + ) + ) + + def recursive_comm_code_check( + self, + snapshot: CommodityTreeSnapshot, + parent_item_id, + parent_item_suffix, + level=1, + ): + # find comm code from snapshot + child_commodities = [] + for commodity in snapshot.commodities: + if ( + commodity.item_id == parent_item_id + and commodity.suffix == parent_item_suffix + ): + child_commodities = snapshot.get_children(commodity) + break + + if len(child_commodities) == 0: + print(f'{"-" * level} no more children') + return False + + results = [] + for child_commodity in child_commodities: + related_measures = self.related_measures(child_commodity.item_id) + + if len(related_measures) == 0: + print(f'{"-" * level} FAIL : {child_commodity.item_id}') + results.append( + self.recursive_comm_code_check( + snapshot, + child_commodity.item_id, + child_commodity.suffix, + level + 1, + ), + ) + elif len(related_measures) == 1: + print(f'{"-" * level} PASS : {child_commodity.item_id}') + results.append(True) + else: + # Multiple measures + print(f'{"-" * level} PASS : multiple : {child_commodity.item_id}') + results.append(True) + + return False in results + + def run_check(self): + raise NotImplementedError("Please implement on child classes") diff --git a/reference_documents/checks/check_runner.py b/reference_documents/checks/check_runner.py new file mode 100644 index 000000000..cd0ba0cdb --- /dev/null +++ b/reference_documents/checks/check_runner.py @@ -0,0 +1,63 @@ +from reference_documents.checks.base import BasePreferentialQuotaCheck +from reference_documents.checks.base import BasePreferentialQuotaOrderNumberCheck +from reference_documents.checks.base import BasePreferentialRateCheck +from reference_documents.checks.preferential_quota_order_numbers import * # noqa +from reference_documents.checks.preferential_quotas import * # noqa +from reference_documents.checks.preferential_rates import * # noqa +from reference_documents.checks.utils import Utils +from reference_documents.models import AlignmentReport +from reference_documents.models import AlignmentReportCheck +from reference_documents.models import ReferenceDocumentVersion + + +class Checks: + def __init__(self, reference_document_version: ReferenceDocumentVersion): + self.reference_document_version = reference_document_version + self.alignment_report = AlignmentReport.objects.create( + reference_document_version=self.reference_document_version, + ) + + @staticmethod + def get_checks_for(check_class): + return Utils().get_child_checks(check_class) + + def run(self): + for check in Checks.get_checks_for(BasePreferentialRateCheck): + for pref_rate in self.reference_document_version.preferential_rates.all(): + self.capture_check_result(check(pref_rate), pref_rate=pref_rate) + + for check in Checks.get_checks_for(BasePreferentialQuotaOrderNumberCheck): + for ( + pref_quota_order_number + ) in self.reference_document_version.preferential_quota_order_numbers.all(): + self.capture_check_result( + check(pref_quota_order_number), + pref_quota_order_number=pref_quota_order_number, + ) + for sub_check in Checks.get_checks_for(BasePreferentialQuotaCheck): + for pref_quota in pref_quota_order_number.preferential_quotas.all(): + self.capture_check_result( + sub_check(pref_quota), + pref_quota=pref_quota, + ) + + def capture_check_result( + self, + check, + pref_rate=None, + pref_quota=None, + pref_quota_order_number=None, + ): + status, message = check.run_check() + + kwargs = { + "alignment_report": self.alignment_report, + "check_name": check.__class__.__name__, + "preferential_rate": pref_rate, + "preferential_quota": pref_quota, + "preferential_quota_order_number": pref_quota_order_number, + "status": status, + "message": message, + } + + AlignmentReportCheck.objects.create(**kwargs) diff --git a/reference_documents/checks/preferential_quota_order_numbers.py b/reference_documents/checks/preferential_quota_order_numbers.py new file mode 100644 index 000000000..26a4f9d0c --- /dev/null +++ b/reference_documents/checks/preferential_quota_order_numbers.py @@ -0,0 +1,13 @@ +from reference_documents.checks.base import BasePreferentialQuotaOrderNumberCheck +from reference_documents.models import AlignmentReportCheckStatus + + +class OrderNumberExists(BasePreferentialQuotaOrderNumberCheck): + def run_check(self): + if not self.order_number(): + message = f"order number not found" + print("FAIL", message) + return AlignmentReportCheckStatus.FAIL, message + else: + print(f"PASS - order number {self.order_number()} found") + return AlignmentReportCheckStatus.PASS, "" diff --git a/reference_documents/checks/preferential_quotas.py b/reference_documents/checks/preferential_quotas.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/reference_documents/checks/preferential_quotas.py @@ -0,0 +1 @@ + diff --git a/reference_documents/checks/preferential_rates.py b/reference_documents/checks/preferential_rates.py new file mode 100644 index 000000000..840b33b21 --- /dev/null +++ b/reference_documents/checks/preferential_rates.py @@ -0,0 +1,45 @@ +from reference_documents.checks.base import BasePreferentialRateCheck +from reference_documents.models import AlignmentReportCheckStatus + + +class MeasureExists(BasePreferentialRateCheck): + def run_check(self): + # comm code live on EIF date + if not self.comm_code(): + message = f"{self.preferential_rate.commodity_code} {self.geo_area_description()} comm code not live" + print("FAIL", message) + return AlignmentReportCheckStatus.FAIL, message + + # query measures + measures = self.related_measures() + + # this is ok - there is a single measure matching the expected query + if len(measures) == 1: + return AlignmentReportCheckStatus.PASS, "" + + # this is not inline with expected measures presence - check comm code children + if len(measures) == 0: + # check 1 level down for presence of measures + match = self.recursive_comm_code_check( + self.get_snapshot(), + self.preferential_rate.commodity_code, + 80, + ) + + message = f"{self.comm_code()}, {self.geo_area_description()}" + + if match: + message += f"\nmatched with children" + print("PASS", message) + + return AlignmentReportCheckStatus.PASS, message + else: + message += f"\nno expected measures found on good code or children" + print("FAIL", message) + + return AlignmentReportCheckStatus.FAIL, message + + if len(measures) > 1: + message = f"multiple measures match {self.comm_code()}, {self.geo_area_description()}" + print("WARNING", message) + return AlignmentReportCheckStatus.WARNING, message diff --git a/reference_documents/checks/utils.py b/reference_documents/checks/utils.py new file mode 100644 index 000000000..d688cb1af --- /dev/null +++ b/reference_documents/checks/utils.py @@ -0,0 +1,21 @@ +from reference_documents.checks.base import BaseCheck + + +class Utils: + def get_child_checks(self, check_class: BaseCheck.__class__): + result = [] + + check_classes = self.subclasses_for(check_class) + for check_class in check_classes: + result.append(check_class) + + return result + + def subclasses_for(self, cls) -> list: + all_subclasses = [] + + for subclass in cls.__subclasses__(): + all_subclasses.append(subclass) + all_subclasses.extend(self.subclasses_for(subclass)) + + return all_subclasses diff --git a/reference_documents/forms/preferential_quota_forms.py b/reference_documents/forms/preferential_quota_forms.py new file mode 100644 index 000000000..868bf2c1e --- /dev/null +++ b/reference_documents/forms/preferential_quota_forms.py @@ -0,0 +1,441 @@ +from datetime import date + +from crispy_forms_gds.helper import FormHelper +from crispy_forms_gds.layout import Button +from crispy_forms_gds.layout import Div +from crispy_forms_gds.layout import Field +from crispy_forms_gds.layout import Fieldset +from crispy_forms_gds.layout import Fixed +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 DateInputFieldFixed +from common.forms import DateInputFieldTakesParameters +from common.forms import GovukDateRangeField +from common.forms import ValidityPeriodForm +from common.util import TaricDateRange +from reference_documents.models import PreferentialQuota +from reference_documents.models import PreferentialQuotaOrderNumber +from reference_documents.validators import commodity_code_validator + + +class PreferentialQuotaCreateUpdateForm( + ValidityPeriodForm, + forms.ModelForm, +): + class Meta: + model = PreferentialQuota + fields = [ + "preferential_quota_order_number", + "commodity_code", + "quota_duty_rate", + "volume", + "measurement", + "valid_between", + ] + + def __init__( + self, + reference_document_version, + preferential_quota_order_number, + *args, + **kwargs, + ): + super().__init__(*args, **kwargs) + + if preferential_quota_order_number: + self.initial[ + "preferential_quota_order_number" + ] = preferential_quota_order_number + + self.fields[ + "preferential_quota_order_number" + ].queryset = reference_document_version.preferential_quota_order_numbers.all() + + self.reference_document_version = reference_document_version + self.quota_order_number = preferential_quota_order_number + self.helper = FormHelper(self) + self.helper.label_size = Size.SMALL + self.helper.legend_size = Size.SMALL + self.helper.layout = Layout( + "preferential_quota_order_number", + Field.text( + "commodity_code", + field_width=Fixed.TEN, + ), + Field.text( + "quota_duty_rate", + field_width=Fixed.THIRTY, + ), + Field.text( + "volume", + field_width=Fixed.TWENTY, + ), + Field.text( + "measurement", + field_width=Fixed.TWENTY, + ), + "start_date", + "end_date", + Submit( + "submit", + "Save", + data_module="govuk-button", + data_prevent_double_click="true", + ), + ) + + def clean_quota_duty_rate(self): + error_message = "Quota duty Rate is not valid - it must have a value" + + if "quota_duty_rate" in self.cleaned_data.keys(): + data = self.cleaned_data["quota_duty_rate"] + if len(data) < 1: + raise ValidationError(error_message) + else: + raise ValidationError(error_message) + + return data + + def clean_preferential_quota_order_number(self): + error_message = "Quota Order Number is not valid - it must have a value" + + if "preferential_quota_order_number" in self.cleaned_data.keys(): + data = self.cleaned_data["preferential_quota_order_number"] + if not data: + raise ValidationError(error_message) + else: + raise ValidationError(error_message) + + return data + + commodity_code = forms.CharField( + max_length=10, + help_text="Enter the 10 digit commodity code", + validators=[commodity_code_validator], + error_messages={ + "invalid": "Commodity code should be 10 digits", + "required": "Enter the commodity code", + }, + ) + + quota_duty_rate = forms.CharField( + help_text="Quota Duty Rate", + validators=[], + error_messages={ + "invalid": "Duty rate is invalid", + "required": "Duty rate is required", + }, + ) + + quota_order_number = forms.ModelChoiceField( + label="Quota Order Number", + help_text="Select Quota order number", + queryset=PreferentialQuotaOrderNumber.objects.all(), + validators=[], + error_messages={ + "invalid": "Quota Order number is invalid", + }, + required=False, + widget=forms.Select(attrs={"class": "form-control"}), + ) + + volume = forms.CharField( + help_text="Volume", + validators=[], + error_messages={ + "invalid": "Volume invalid", + "required": "Volume is required", + }, + ) + + measurement = forms.CharField( + help_text="Measurement", + validators=[], + error_messages={ + "invalid": "Measurement invalid", + "required": "Measurement is required", + }, + ) + + end_date = DateInputFieldFixed( + label="End date", + ) + + +class PreferentialQuotaBulkCreateForm(forms.Form): + commodity_codes = forms.CharField( + label="Commodity codes", + widget=forms.Textarea, + help_text="Enter one or more commodity codes with each one on a new line.", + error_messages={ + "invalid": "Commodity code should be 10 digits", + "required": "Commodity code is required", + }, + ) + + quota_duty_rate = forms.CharField( + validators=[], + error_messages={ + "invalid": "Duty rate is invalid", + "required": "Duty rate is required", + }, + ) + + preferential_quota_order_number = forms.ModelChoiceField( + help_text="If the quota order number does not appear, you must first create it for this reference document version.", + queryset=PreferentialQuotaOrderNumber.objects.all(), # Modified in init + error_messages={ + "invalid": "Quota Order Number is invalid", + "required": "Quota Order Number is required", + }, + ) + + measurement = forms.CharField( + validators=[], + error_messages={ + "invalid": "Measurement invalid", + "required": "Measurement is required", + }, + ) + + def get_variant_index(self, post_data): + """Looks through post data to see how many validity date / volume + combinations have been submitted and returns the index value of each + combination in a list.""" + result = [0] + if "data" in post_data.keys(): + for key in post_data["data"].keys(): + if key.startswith("start_date_"): + variant_index = int(key.replace("start_date_", "").split("_")[0]) + result.append(variant_index) + result = list(set(result)) + result.sort() + return result + + def __init__( + self, + reference_document_version, + preferential_quota_order_number=None, + *args, + **kwargs, + ): + super().__init__(*args, **kwargs) + self.variant_indices = self.get_variant_index(kwargs) + # Populate order number box if already specified by the URL + if preferential_quota_order_number: + self.initial[ + "preferential_quota_order_number" + ] = preferential_quota_order_number + # Add fields for the first mandatory date and volume fieldset + self.fields["start_date_0"] = DateInputFieldFixed( + label="Start date", + required=True, + ) + self.fields["end_date_0"] = DateInputFieldFixed(label="End date", required=True) + self.fields["volume_0"] = forms.CharField( + error_messages={ + "invalid": "Volume invalid", + "required": "Volume is required", + }, + help_text="Are you sure you want to permanently delete reference document {{ object.area_id }}?
+ + {{ govukWarningText({ + "text": "Deleted reference documents can not be recovered.", + "iconFallbackText": "Warning" + }) }} + + ++ + Create a new version of this reference document + +
++ + Create a new reference document + +
++ Are you sure you want to permanently delete preferential quota order number {{ object.quota_order_number }}, + reference document {{ object.reference_document_version.reference_document.area_id }}? +
+ + {{ govukWarningText({ + "text": "Deleted preferential quota order number cannot be recovered.", + "iconFallbackText": "Warning" + }) }} + + ++ Are you sure you want to permanently delete preferential quota {{ object.commodity_code }} for order number {{ object.preferential_quota_order_number.quota_order_number }}, + reference document {{ object.preferential_quota_order_number.reference_document_version.reference_document.area_id }}? +
+ + {{ govukWarningText({ + "text": "Deleted preferential quota order number cannot be recovered.", + "iconFallbackText": "Warning" + }) }} + + +Are you sure you want to permanently delete preferential duty rate for commodity {{ object.commodity_code }} reference document {{ object.reference_document_version.reference_document.area_id }}?
+ + {{ govukWarningText({ + "text": "Deleted preferential rates cannot be recovered.", + "iconFallbackText": "Warning" + }) }} + + +Editing preferential rate for reference document {{ object.reference_document_version.reference_document.area_id }} version {{ object.reference_document_version.version }}.
+ {% call django_form() %} + {{ crispy(form) }} + {% endcall %} +{% endblock %} diff --git a/reference_documents/jinja2/reference_documents/reference_document_examples/details.jinja b/reference_documents/jinja2/reference_documents/reference_document_examples/details.jinja new file mode 100644 index 000000000..dda4bcc16 --- /dev/null +++ b/reference_documents/jinja2/reference_documents/reference_document_examples/details.jinja @@ -0,0 +1,41 @@ +{% extends "layouts/layout.jinja" %} + +{% from "components/table/macro.njk" import govukTable %} +{% from "components/tabs/macro.njk" import govukTabs %} + +{% set page_title = "Preferential duty rates" %} + +{% set core_data_tab_html %}{% include "includes/tabs/core_data.jinja" %}{% endset %} +{% set tariff_quotas_html %}{% include "includes/tabs/tariff_quotas.jinja" %}{% endset %} + +{% set tabs = { + "items": [ + { + "label": "Preferential duty rates", + "id": "core-data", + "panel": { + "html": core_data_tab_html + } + }, + { + "label": "Tariff quotas", + "id": "tariff-quotas", + "panel": { + "html": tariff_quotas_html + } + }, + ] + }%} + +{% block content %} +{{ref_doc.name}}
+{{ref_doc.version}}
+{{ref_doc.date_published}}
+ + {{ govukTabs(tabs) }} + +{% endblock %} diff --git a/reference_documents/jinja2/reference_documents/reference_document_examples/index.jinja b/reference_documents/jinja2/reference_documents/reference_document_examples/index.jinja new file mode 100644 index 000000000..e7ea9cc94 --- /dev/null +++ b/reference_documents/jinja2/reference_documents/reference_document_examples/index.jinja @@ -0,0 +1,37 @@ +{% extends "layouts/layout.jinja" %} + +{% from "components/table/macro.njk" import govukTable %} + +{% set page_title = "View reference documents" %} + +{% block content %} +Are you sure you want to permanently delete reference document version {{ object.reference_document.area_id }} version {{ object.version }}?
+ + {{ govukWarningText({ + "text": "Deleted reference document versions can not be recovered.", + "iconFallbackText": "Warning" + }) }} + + +{{ ref_doc_title }}
+{{ object.version }}
+{{ object.published_date }}
+{{ object.entry_into_force_date or 'unknown' }}
+ + {{ govukTabs(tabs) }} + +{% endblock %} diff --git a/reference_documents/jinja2/reference_documents/reference_document_versions/edit.jinja b/reference_documents/jinja2/reference_documents/reference_document_versions/edit.jinja new file mode 100644 index 000000000..747b29495 --- /dev/null +++ b/reference_documents/jinja2/reference_documents/reference_document_versions/edit.jinja @@ -0,0 +1,19 @@ +{% extends "layouts/form.jinja" %} +{% from "components/breadcrumbs/macro.njk" import govukBreadcrumbs %} + +{% set page_title = "Edit reference document " ~ object.reference_document.area_id ~ " version " ~ object.version %} + +{% block breadcrumb %} + {{ govukBreadcrumbs({ + "items": [{"text": "Home", "href": url("home")}, + {"text": "View reference documents", "href": url("reference_documents:index")}, + {"text": "Reference document " ~ object.reference_document.area_id, "href": url("reference_documents:details", kwargs={"pk":object.reference_document.pk})}, + {"text": page_title}] + }) }} +{% endblock %} + +{% block form %} + {% call django_form() %} + {{ crispy(form) }} + {% endcall %} +{% endblock %} diff --git a/reference_documents/jinja2/reference_documents/update.jinja b/reference_documents/jinja2/reference_documents/update.jinja new file mode 100644 index 000000000..121b96e22 --- /dev/null +++ b/reference_documents/jinja2/reference_documents/update.jinja @@ -0,0 +1,18 @@ +{% extends "layouts/form.jinja" %} +{% from "components/breadcrumbs/macro.njk" import govukBreadcrumbs %} + +{% set page_title = "Edit reference document " ~ object.area_id ~ " details" %} + +{% block breadcrumb %} + {{ govukBreadcrumbs({ + "items": [{"text": "Home", "href": url("home")}, + {"text": "View reference documents", "href": url("reference_documents:index")}, + {"text": page_title}] + }) }} +{% endblock %} + +{% block form %} + {% call django_form() %} + {{ crispy(form) }} + {% endcall %} +{% endblock %} diff --git a/reference_documents/management/__init__.py b/reference_documents/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/reference_documents/management/commands/__init__.py b/reference_documents/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/reference_documents/management/commands/ref_doc_csv_importer.py b/reference_documents/management/commands/ref_doc_csv_importer.py new file mode 100644 index 000000000..24049a218 --- /dev/null +++ b/reference_documents/management/commands/ref_doc_csv_importer.py @@ -0,0 +1,269 @@ +import os +from datetime import date + +import pandas as pd +from django.core.management import BaseCommand + +from common.util import TaricDateRange +from reference_documents.models import PreferentialQuota +from reference_documents.models import PreferentialQuotaOrderNumber +from reference_documents.models import PreferentialRate +from reference_documents.models import ReferenceDocument +from reference_documents.models import ReferenceDocumentVersion +from reference_documents.models import ReferenceDocumentVersionStatus + + +class Command(BaseCommand): + help = "Basic HELP .. todo" + + def add_arguments(self, parser) -> None: + parser.add_argument( + "duties_csv_path", + type=str, + help="The absolute path to the duties csv file to import", + ) + parser.add_argument( + "quotas_csv_path", + type=str, + help="The absolute path to the quotas csv file to import", + ) + + return super().add_arguments(parser) + + def handle(self, *args, **options): + # TODO: remove all ref doc data - temp while testing + PreferentialQuota.objects.all().delete() + PreferentialRate.objects.all().delete() + PreferentialQuotaOrderNumber.objects.all().delete() + ReferenceDocumentVersion.objects.all().delete() + ReferenceDocument.objects.all().delete() + + # verify each file exists + if not os.path.isfile(options["duties_csv_path"]): + raise FileNotFoundError(options["duties_csv_path"]) + + if not os.path.isfile(options["quotas_csv_path"]): + raise FileNotFoundError(options["duties_csv_path"]) + + self.duties_csv_path = options["duties_csv_path"] + self.quotas_csv_path = options["quotas_csv_path"] + + self.quotas_df = self.load_quotas_csv() + self.duties_df = self.load_duties_csv() + + self.create_ref_docs_and_versions() + + def load_duties_csv(self): + df = pd.read_csv( + self.duties_csv_path, + dtype={ + "Standardised Commodity Code": "object", + "Valid From": "object", + "Valid To": "object", + }, + ) + return df + + def load_quotas_csv(self): + df = pd.read_csv( + self.quotas_csv_path, + dtype={ + "Standardised Commodity Code": "object", + }, + ) + return df + + def add_pt_quota_if_no_exists( + self, + df_row, + order, + order_number, + reference_document_version, + ): + if len(order_number) != 6: + print(f"skipping wonky order number : -{order_number}-") + return + + comm_code = df_row["Standardised Commodity Code"] + comm_code = comm_code + ("0" * (len(comm_code) - 10)) + quota_duty_rate = df_row["Quota Duty Rate"] + volume = df_row["Quota Volume"].replace(",", "") + units = df_row["Units"] + + # no data contains valid dates, just create a single record - can be edited later in UI + quota_definition_valid_between = None + + if reference_document_version.entry_into_force_date: + order_number_valid_between = TaricDateRange( + reference_document_version.entry_into_force_date, + ) + else: + order_number_valid_between = None + + # Check order number + order_number_record = ( + reference_document_version.preferential_quota_order_numbers.filter( + quota_order_number=order_number, + ).first() + ) + + if not order_number_record: + # add a new one + order_number_record = PreferentialQuotaOrderNumber.objects.create( + quota_order_number=order_number, + reference_document_version_id=reference_document_version.id, + valid_between=order_number_valid_between, + coefficient=None, + main_order_number=None, + ) + + order_number_record.save() + + # check quota definition + quota = order_number_record.preferential_quotas.filter( + commodity_code=comm_code, + ).first() + + if not quota: + # add a new one + quota = PreferentialQuota.objects.create( + commodity_code=comm_code, + preferential_quota_order_number=order_number_record, + quota_duty_rate=quota_duty_rate, + order=order, + volume=volume, + valid_between=quota_definition_valid_between, + measurement=units, + ) + + quota.save() + + def add_pt_duty_if_no_exist(self, df_row, df_row_index, reference_document_version): + # check for existing entry for comm code + comm_code = df_row["Standardised Commodity Code"] + comm_code = comm_code + ("0" * (len(comm_code) - 10)) + + pref_rate = reference_document_version.preferential_rates.filter( + commodity_code=comm_code, + ).first() + + if not pref_rate: + # add a new one + pref_rate = PreferentialRate.objects.create( + commodity_code=comm_code, + duty_rate=df_row["Preferential Duty Rate"], + order=df_row_index, + reference_document_version=reference_document_version, + valid_between=None, + ) + + pref_rate.save() + + # Create base documents + # Load duties, get unique countries and create base document for each + + def create_ref_docs_and_versions(self): + areas = pd.unique(self.duties_df["area_id"].values) + + for area in areas: + # # isolating mexico + # if area != 'MX': + # continue + + print(area) + ref_doc = ( + ReferenceDocument.objects.all() + .filter( + title=f"Reference document for {area}", + area_id=area, + ) + .first() + ) + + # Create records + if not ref_doc: + ref_doc = ReferenceDocument.objects.create( + title=f"Reference document for {area}", + area_id=area, + ) + ref_doc.save() + + versions = pd.unique( + self.duties_df[self.duties_df["area_id"] == area][ + "Document Version" + ].values, + ) + + for version in versions: + print(f" -- {version}") + # try and find existing + ref_doc_version = ref_doc.reference_document_versions.filter( + version=float(version), + ).first() + if ( + self.duties_df[self.duties_df["area_id"] == area][ + "Document Date" + ].values[0] + == "empty_cell" + ): + document_publish_date = None + else: + doc_date_string = str( + self.duties_df[self.duties_df["area_id"] == area][ + "Document Date" + ].values[0], + ) + document_publish_date = date( + int(doc_date_string[:4]), + int(doc_date_string[4:6]), + int(doc_date_string[6:]), + ) + + if not ref_doc_version: + # Create version + ref_doc_version = ReferenceDocumentVersion.objects.create( + reference_document=ref_doc, + version=float(version), + published_date=document_publish_date, + entry_into_force_date=None, + status=ReferenceDocumentVersionStatus.EDITING, + ) + + ref_doc_version.save() + + # Add duties + + # get duties for area + df_area_duties = self.duties_df.loc[self.duties_df["area_id"] == area] + for index, row in df_area_duties.iterrows(): + print(f' -- -- {row["Standardised Commodity Code"]}') + self.add_pt_duty_if_no_exist(row, index, ref_doc_version) + + # Quotas + + # Filter by area_id and document version + quotas_df = self.quotas_df[self.quotas_df["area_id"] == area] + quotas_df = self.quotas_df[ + self.quotas_df["Document Version"] == version + ] + + add_to_index = 1 + for index, row in quotas_df.iterrows(): + # split order numbers + order_number = row["Quota Number"] + order_number = order_number.replace(".", "") + + if len(order_number) > 6: + order_numbers = order_number.split(" ") + else: + order_numbers = [order_number] + + for on in order_numbers: + print(f' -- -- {on} - {row["Standardised Commodity Code"]}') + self.add_pt_quota_if_no_exists( + row, + index + add_to_index, + on, + ref_doc_version, + ) + add_to_index += 1 diff --git a/reference_documents/migrations/0001_initial.py b/reference_documents/migrations/0001_initial.py new file mode 100644 index 000000000..ae20a7f04 --- /dev/null +++ b/reference_documents/migrations/0001_initial.py @@ -0,0 +1,257 @@ +# Generated by Django 3.2.23 on 2024-02-23 12:20 + +import django.db.models.deletion +import django_fsm +from django.db import migrations +from django.db import models + +import common.fields + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="AlignmentReport", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name="ReferenceDocument", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "title", + models.CharField( + db_index=True, + help_text="Short name for this workbasket", + max_length=255, + unique=True, + ), + ), + ("area_id", models.CharField(db_index=True, max_length=4)), + ], + ), + migrations.CreateModel( + name="ReferenceDocumentVersion", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("version", models.FloatField()), + ("published_date", models.DateField(blank=True, null=True)), + ("entry_into_force_date", models.DateField(blank=True, null=True)), + ( + "status", + django_fsm.FSMField( + choices=[ + ("EDITING", "Editing"), + ("IN_REVIEW", "In Review"), + ("PUBLISHED", "Published"), + ], + db_index=True, + default="EDITING", + editable=False, + max_length=50, + ), + ), + ( + "reference_document", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="reference_document_versions", + to="reference_documents.referencedocument", + ), + ), + ], + ), + migrations.CreateModel( + name="PreferentialRate", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("commodity_code", models.CharField(db_index=True, max_length=10)), + ("duty_rate", models.CharField(max_length=255)), + ("order", models.IntegerField()), + ( + "valid_between", + common.fields.TaricDateRangeField( + blank=True, + db_index=True, + default=None, + null=True, + ), + ), + ( + "reference_document_version", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="preferential_rates", + to="reference_documents.referencedocumentversion", + ), + ), + ], + ), + migrations.CreateModel( + name="PreferentialQuota", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("quota_order_number", models.CharField(db_index=True, max_length=6)), + ("commodity_code", models.CharField(db_index=True, max_length=10)), + ("quota_duty_rate", models.CharField(max_length=255)), + ("volume", models.CharField(max_length=255)), + ( + "coefficient", + models.DecimalField( + blank=True, + decimal_places=4, + default=None, + max_digits=6, + null=True, + ), + ), + ( + "valid_between", + common.fields.TaricDateRangeField( + blank=True, + db_index=True, + default=None, + null=True, + ), + ), + ("measurement", models.CharField(max_length=255)), + ("order", models.IntegerField()), + ( + "main_quota", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="sub_quotas", + to="reference_documents.preferentialquota", + ), + ), + ( + "reference_document_version", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="preferential_quotas", + to="reference_documents.referencedocumentversion", + ), + ), + ], + ), + migrations.CreateModel( + name="AlignmentReportCheck", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("check_name", models.CharField(max_length=255)), + ( + "status", + django_fsm.FSMField( + choices=[ + ("PASS", "Passing"), + ("FAIL", "Failed"), + ("WARNING", "Warning"), + ], + db_index=True, + default="FAIL", + editable=False, + max_length=50, + ), + ), + ("message", models.TextField(null=True)), + ( + "alignment_report", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="alignment_report_checks", + to="reference_documents.alignmentreport", + ), + ), + ( + "preferential_quota", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="preferential_quota_checks", + to="reference_documents.preferentialquota", + ), + ), + ( + "preferential_rate", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="preferential_rate_checks", + to="reference_documents.preferentialrate", + ), + ), + ], + ), + migrations.AddField( + model_name="alignmentreport", + name="reference_document_version", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="alignment_reports", + to="reference_documents.referencedocumentversion", + ), + ), + ] diff --git a/reference_documents/migrations/0002_alter_referencedocument_area_id.py b/reference_documents/migrations/0002_alter_referencedocument_area_id.py new file mode 100644 index 000000000..62552ae52 --- /dev/null +++ b/reference_documents/migrations/0002_alter_referencedocument_area_id.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.24 on 2024-02-28 15:17 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("reference_documents", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="referencedocument", + name="area_id", + field=models.CharField(db_index=True, max_length=4, unique=True), + ), + ] diff --git a/reference_documents/migrations/0003_auto_20240307_0848.py b/reference_documents/migrations/0003_auto_20240307_0848.py new file mode 100644 index 000000000..993618183 --- /dev/null +++ b/reference_documents/migrations/0003_auto_20240307_0848.py @@ -0,0 +1,109 @@ +# Generated by Django 3.2.24 on 2024-03-07 08:48 + +import django.db.models.deletion +from django.db import migrations +from django.db import models + +import common.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("reference_documents", "0002_alter_referencedocument_area_id"), + ] + + operations = [ + migrations.CreateModel( + name="PreferentialQuotaOrderNumber", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("quota_order_number", models.CharField(db_index=True, max_length=6)), + ( + "coefficient", + models.DecimalField( + blank=True, + decimal_places=4, + default=None, + max_digits=6, + null=True, + ), + ), + ( + "valid_between", + common.fields.TaricDateRangeField( + blank=True, + db_index=True, + default=None, + null=True, + ), + ), + ], + ), + migrations.RemoveField( + model_name="preferentialquota", + name="coefficient", + ), + migrations.RemoveField( + model_name="preferentialquota", + name="main_quota", + ), + migrations.RemoveField( + model_name="preferentialquota", + name="quota_order_number", + ), + migrations.RemoveField( + model_name="preferentialquota", + name="reference_document_version", + ), + migrations.RemoveField( + model_name="preferentialrate", + name="reference_document_version", + ), + migrations.AddConstraint( + model_name="referencedocumentversion", + constraint=models.UniqueConstraint( + fields=("version", "reference_document"), + name="unique_versions", + ), + ), + migrations.AddField( + model_name="preferentialquotaordernumber", + name="main_order_number", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="sub_order_number", + to="reference_documents.preferentialquotaordernumber", + ), + ), + migrations.AddField( + model_name="preferentialquotaordernumber", + name="reference_document_version", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="preferential_quota_order_numbers", + to="reference_documents.referencedocumentversion", + ), + ), + migrations.AddField( + model_name="preferentialquota", + name="preferential_quota_order_number", + field=models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="preferential_quota_order_number", + to="reference_documents.preferentialquotaordernumber", + ), + ), + ] diff --git a/reference_documents/migrations/0004_preferentialrate_reference_document_version.py b/reference_documents/migrations/0004_preferentialrate_reference_document_version.py new file mode 100644 index 000000000..67aab22a9 --- /dev/null +++ b/reference_documents/migrations/0004_preferentialrate_reference_document_version.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.24 on 2024-03-07 08:52 + +import django.db.models.deletion +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("reference_documents", "0003_auto_20240307_0848"), + ] + + operations = [ + migrations.AddField( + model_name="preferentialrate", + name="reference_document_version", + field=models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="preferential_rates", + to="reference_documents.referencedocumentversion", + ), + ), + ] diff --git a/reference_documents/migrations/0005_alter_preferentialquota_preferential_quota_order_number.py b/reference_documents/migrations/0005_alter_preferentialquota_preferential_quota_order_number.py new file mode 100644 index 000000000..8fa1f6978 --- /dev/null +++ b/reference_documents/migrations/0005_alter_preferentialquota_preferential_quota_order_number.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.24 on 2024-03-07 09:16 + +import django.db.models.deletion +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("reference_documents", "0004_preferentialrate_reference_document_version"), + ] + + operations = [ + migrations.AlterField( + model_name="preferentialquota", + name="preferential_quota_order_number", + field=models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="preferential_quota_order_numbers", + to="reference_documents.preferentialquotaordernumber", + ), + ), + ] diff --git a/reference_documents/migrations/0006_alter_preferentialquota_preferential_quota_order_number.py b/reference_documents/migrations/0006_alter_preferentialquota_preferential_quota_order_number.py new file mode 100644 index 000000000..1c85d8624 --- /dev/null +++ b/reference_documents/migrations/0006_alter_preferentialquota_preferential_quota_order_number.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.24 on 2024-03-07 09:17 + +import django.db.models.deletion +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "reference_documents", + "0005_alter_preferentialquota_preferential_quota_order_number", + ), + ] + + operations = [ + migrations.AlterField( + model_name="preferentialquota", + name="preferential_quota_order_number", + field=models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="preferential_quotas", + to="reference_documents.preferentialquotaordernumber", + ), + ), + ] diff --git a/reference_documents/migrations/0007_alignmentreportcheck_preferential_quota_order_number.py b/reference_documents/migrations/0007_alignmentreportcheck_preferential_quota_order_number.py new file mode 100644 index 000000000..77a69c488 --- /dev/null +++ b/reference_documents/migrations/0007_alignmentreportcheck_preferential_quota_order_number.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.24 on 2024-03-07 14:54 + +import django.db.models.deletion +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "reference_documents", + "0006_alter_preferentialquota_preferential_quota_order_number", + ), + ] + + operations = [ + migrations.AddField( + model_name="alignmentreportcheck", + name="preferential_quota_order_number", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="preferential_quota_order_number_checks", + to="reference_documents.preferentialquotaordernumber", + ), + ), + ] diff --git a/reference_documents/migrations/__init__.py b/reference_documents/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/reference_documents/models.py b/reference_documents/models.py new file mode 100644 index 000000000..afdbbd496 --- /dev/null +++ b/reference_documents/models.py @@ -0,0 +1,238 @@ +from django.db import models +from django.db.models import fields +from django_fsm import FSMField + +from common.fields import TaricDateRangeField + + +class ReferenceDocumentVersionStatus(models.TextChoices): + # Reference document version can be edited + EDITING = "EDITING", "Editing" + # Reference document version ius locked and in review + IN_REVIEW = "IN_REVIEW", "In Review" + # reference document version has been approved and published + PUBLISHED = "PUBLISHED", "Published" + + +class AlignmentReportCheckStatus(models.TextChoices): + # Reference document version can be edited + PASS = "PASS", "Passing" + # Reference document version ius locked and in review + FAIL = "FAIL", "Failed" + # reference document version has been approved and published + WARNING = "WARNING", "Warning" + + +class ReferenceDocument(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + + title = models.CharField( + max_length=255, + help_text="Short name for this workbasket", + db_index=True, + unique=True, + ) + + area_id = models.CharField( + max_length=4, + db_index=True, + unique=True, + ) + + def get_area_name_by_area_id(self): + from geo_areas.models import GeographicalAreaDescription + + description = ( + GeographicalAreaDescription.objects.latest_approved() + .filter(described_geographicalarea__area_id=self.area_id) + .order_by("-validity_start") + .first() + ) + if description: + return description.description + else: + return f"{self.area_id} (unknown description)" + + +class ReferenceDocumentVersion(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + version = models.FloatField() + published_date = models.DateField(blank=True, null=True) + entry_into_force_date = models.DateField(blank=True, null=True) + + reference_document = models.ForeignKey( + "reference_documents.ReferenceDocument", + on_delete=models.PROTECT, + related_name="reference_document_versions", + ) + status = FSMField( + default=ReferenceDocumentVersionStatus.EDITING, + choices=ReferenceDocumentVersionStatus.choices, + db_index=True, + protected=False, + editable=False, + ) + + class Meta: + # TODO: Add violation_error_message to this constraint once we have Django 4.1 + constraints = [ + models.UniqueConstraint( + fields=["version", "reference_document"], + name="unique_versions", + ), + ] + + def preferential_quotas(self): + order_numbers = self.preferential_quota_order_numbers.all() + return PreferentialQuota.objects.all().filter( + preferential_quota_order_number__in=order_numbers, + ) + + +class PreferentialQuotaOrderNumber(models.Model): + reference_document_version = models.ForeignKey( + "reference_documents.ReferenceDocumentVersion", + on_delete=models.PROTECT, + related_name="preferential_quota_order_numbers", + ) + + quota_order_number = models.CharField( + max_length=6, + db_index=True, + ) + coefficient = models.DecimalField( + max_digits=6, + decimal_places=4, + blank=True, + null=True, + default=None, + ) + main_order_number = models.ForeignKey( + "self", + related_name="sub_order_number", + blank=True, + null=True, + on_delete=models.PROTECT, + ) + valid_between = TaricDateRangeField( + db_index=True, + null=True, + blank=True, + default=None, + ) + + def __str__(self): + return f"{self.quota_order_number}" + + +class PreferentialQuota(models.Model): + preferential_quota_order_number = models.ForeignKey( + "reference_documents.PreferentialQuotaOrderNumber", + on_delete=models.PROTECT, + related_name="preferential_quotas", + null=True, + blank=True, + default=None, + ) + commodity_code = models.CharField( + max_length=10, + db_index=True, + ) + quota_duty_rate = models.CharField( + max_length=255, + ) + volume = models.CharField( + max_length=255, + ) + valid_between = TaricDateRangeField( + db_index=True, + null=True, + blank=True, + default=None, + ) + measurement = models.CharField( + max_length=255, + ) + order = models.IntegerField() + + +class PreferentialRate(models.Model): + reference_document_version = models.ForeignKey( + "reference_documents.ReferenceDocumentVersion", + on_delete=models.PROTECT, + related_name="preferential_rates", + null=True, + blank=True, + default=None, + ) + commodity_code = models.CharField( + max_length=10, + db_index=True, + ) + duty_rate = models.CharField( + max_length=255, + ) + order = models.IntegerField() + valid_between = TaricDateRangeField( + db_index=True, + null=True, + blank=True, + default=None, + ) + + +class AlignmentReport(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + reference_document_version = models.ForeignKey( + "reference_documents.ReferenceDocumentVersion", + on_delete=models.PROTECT, + related_name="alignment_reports", + ) + + +class AlignmentReportCheck(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + + alignment_report = models.ForeignKey( + "reference_documents.AlignmentReport", + on_delete=models.PROTECT, + related_name="alignment_report_checks", + ) + + check_name = fields.CharField(max_length=255) + """A string identifying the type of check carried out.""" + + status = FSMField( + default=AlignmentReportCheckStatus.FAIL, + choices=AlignmentReportCheckStatus.choices, + db_index=True, + protected=False, + editable=False, + ) + message = fields.TextField(null=True) + """The text content returned by the check, if any.""" + + preferential_quota = models.ForeignKey( + "reference_documents.PreferentialQuota", + on_delete=models.PROTECT, + related_name="preferential_quota_checks", + blank=True, + null=True, + ) + + preferential_quota_order_number = models.ForeignKey( + "reference_documents.PreferentialQuotaOrderNumber", + on_delete=models.PROTECT, + related_name="preferential_quota_order_number_checks", + blank=True, + null=True, + ) + + preferential_rate = models.ForeignKey( + "reference_documents.PreferentialRate", + on_delete=models.PROTECT, + related_name="preferential_rate_checks", + blank=True, + null=True, + ) diff --git a/reference_documents/static/reference_documents/scss/_reference_documents.scss b/reference_documents/static/reference_documents/scss/_reference_documents.scss new file mode 100644 index 000000000..4b746e19c --- /dev/null +++ b/reference_documents/static/reference_documents/scss/_reference_documents.scss @@ -0,0 +1,11 @@ +.check-passing { + color: #1d640f; + font-weight: bold; +} +.check-failing { + color: #671111; + font-weight: bold; +} +.order_number_link { + padding-right: 10px; +} \ No newline at end of file diff --git a/reference_documents/tests/__init__.py b/reference_documents/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/reference_documents/tests/factories.py b/reference_documents/tests/factories.py new file mode 100644 index 000000000..0ac84f4bd --- /dev/null +++ b/reference_documents/tests/factories.py @@ -0,0 +1,235 @@ +import string +from datetime import date +from datetime import datetime +from datetime import timedelta +from random import randint + +import factory +from factory.fuzzy import FuzzyDecimal +from factory.fuzzy import FuzzyInteger +from factory.fuzzy import FuzzyText + +from common.util import TaricDateRange +from reference_documents.models import AlignmentReportCheckStatus +from reference_documents.models import ReferenceDocumentVersionStatus + + +def get_random_date(start_date, end_date): + days_diff = abs((end_date - start_date).days) + return start_date + timedelta(days=randint(0, days_diff)) + + +class ReferenceDocumentFactory(factory.django.DjangoModelFactory): + class Meta: + model = "reference_documents.ReferenceDocument" + + area_id = FuzzyText("", 2, "", string.ascii_uppercase) + created_at = get_random_date( + date(2008, 1, 1), + date.today(), + ) + title = FuzzyText("Reference Document for ", 5, "", string.ascii_uppercase) + + +class ReferenceDocumentVersionFactory(factory.django.DjangoModelFactory): + class Meta: + model = "reference_documents.ReferenceDocumentVersion" + + created_at = get_random_date(datetime(2020, 1, 1), datetime.now()) + updated_at = get_random_date(datetime(2020, 1, 1), datetime.now()) + version = FuzzyDecimal(1.0, 5.0, 1) + published_date = get_random_date(datetime(2022, 1, 1), datetime.now()) + entry_into_force_date = get_random_date( + datetime(2022, 1, 1), + datetime.now(), + ) + + reference_document = factory.SubFactory(ReferenceDocumentFactory) + + status = ReferenceDocumentVersionStatus.EDITING + + class Params: + in_review = factory.Trait( + status=ReferenceDocumentVersionStatus.IN_REVIEW, + ) + published = factory.Trait( + status=ReferenceDocumentVersionStatus.PUBLISHED, + ) + editing = factory.Trait( + status=ReferenceDocumentVersionStatus.EDITING, + ) + + +class PreferentialRateFactory(factory.django.DjangoModelFactory): + class Meta: + model = "reference_documents.PreferentialRate" + + commodity_code = FuzzyText(length=6, chars=string.digits, suffix="0000") + + duty_rate = FuzzyText(length=2, chars=string.digits, suffix="%") + + order = FuzzyInteger(0, 100, 1) + + reference_document_version = factory.SubFactory(ReferenceDocumentVersionFactory) + + valid_between = TaricDateRange( + get_random_date( + date.today() + timedelta(days=-(365 * 2)), + date.today() + timedelta(days=-365), + ), + get_random_date( + date.today() + timedelta(days=-364), + date.today(), + ), + ) + + class Params: + valid_between_current = factory.Trait( + valid_between=TaricDateRange( + date.today() + timedelta(days=-200), + date.today() + timedelta(days=165), + ), + ) + valid_between_current_open_ended = factory.Trait( + valid_between=TaricDateRange( + date.today() + timedelta(days=-200), + None, + ), + ) + valid_between_in_past = factory.Trait( + valid_between=TaricDateRange( + date.today() + timedelta(days=-375), + date.today() + timedelta(days=-10), + ), + ) + valid_between_in_future = factory.Trait( + valid_between=TaricDateRange( + date.today() + timedelta(days=10), + date.today() + timedelta(days=375), + ), + ) + + +class PreferentialQuotaOrderNumberFactory(factory.django.DjangoModelFactory): + class Meta: + model = "reference_documents.PreferentialQuotaOrderNumber" + + quota_order_number = FuzzyText(prefix="054", length=3, chars=string.digits) + + coefficient = None + main_order_number = None + reference_document_version = factory.SubFactory(ReferenceDocumentVersionFactory) + + valid_between = TaricDateRange( + get_random_date( + date.today() + timedelta(days=-(365 * 2)), + date.today() + timedelta(days=-365), + ), + get_random_date( + date.today() + timedelta(days=-364), + date.today(), + ), + ) + + +class PreferentialQuotaFactory(factory.django.DjangoModelFactory): + class Meta: + model = "reference_documents.PreferentialQuota" + + commodity_code = FuzzyText(length=6, chars=string.digits, suffix="0000") + + quota_duty_rate = FuzzyText(length=2, chars=string.digits, suffix="%") + + preferential_quota_order_number = factory.SubFactory( + PreferentialQuotaOrderNumberFactory, + ) + + volume = FuzzyDecimal(100.0, 10000.0, 1) + + measurement = "tonnes" + + order = FuzzyInteger(0, 100, 1) + + valid_between = TaricDateRange( + get_random_date( + date.today() + timedelta(days=-(365 * 2)), + date.today() + timedelta(days=-365), + ), + get_random_date( + date.today() + timedelta(days=-364), + date.today(), + ), + ) + + class Params: + valid_between_current = factory.Trait( + valid_between=TaricDateRange( + date.today() + timedelta(days=-200), + date.today() + timedelta(days=165), + ), + ) + valid_between_current_open_ended = factory.Trait( + valid_between=TaricDateRange( + date.today() + timedelta(days=-200), + None, + ), + ) + valid_between_in_past = factory.Trait( + valid_between=TaricDateRange( + date.today() + timedelta(days=-375), + date.today() + timedelta(days=-10), + ), + ) + valid_between_in_future = factory.Trait( + valid_between=TaricDateRange( + date.today() + timedelta(days=10), + date.today() + timedelta(days=375), + ), + ) + + +class AlignmentReportFactory(factory.django.DjangoModelFactory): + class Meta: + model = "reference_documents.AlignmentReport" + + created_at = get_random_date(date(2020, 1, 1), date.today()) + reference_document_version = factory.SubFactory(ReferenceDocumentVersionFactory) + + +class AlignmentReportCheckFactory(factory.django.DjangoModelFactory): + class Meta: + model = "reference_documents.AlignmentReportCheck" + + created_at = get_random_date(date(2020, 1, 1), date.today()) + alignment_report = factory.SubFactory(AlignmentReportFactory) + + check_name = FuzzyText( + prefix="SomeClassName ", + length=5, + chars=string.ascii_uppercase, + ) + + status = (AlignmentReportCheckStatus.FAIL,) + + message = FuzzyText( + prefix="Some Random Message ", + length=5, + chars=string.ascii_uppercase, + ) + + preferential_quota = None + preferential_rate = None + + class Params: + with_quota = factory.Trait( + preferential_quota=factory.SubFactory(PreferentialQuotaFactory), + ) + with_rate = factory.Trait( + preferential_rate=factory.SubFactory(PreferentialRateFactory), + ) + passing = factory.Trait( + status=AlignmentReportCheckStatus.PASS, + ) + warning = factory.Trait( + status=AlignmentReportCheckStatus.WARNING, + ) diff --git a/reference_documents/tests/test_alignment_reports_forms.py b/reference_documents/tests/test_alignment_reports_forms.py new file mode 100644 index 000000000..a7057c2cc --- /dev/null +++ b/reference_documents/tests/test_alignment_reports_forms.py @@ -0,0 +1,3 @@ +import pytest + +pytestmark = pytest.mark.django_db diff --git a/reference_documents/tests/test_alignment_reports_models.py b/reference_documents/tests/test_alignment_reports_models.py new file mode 100644 index 000000000..3a4a8ac96 --- /dev/null +++ b/reference_documents/tests/test_alignment_reports_models.py @@ -0,0 +1,28 @@ +import pytest + +from reference_documents.tests.factories import AlignmentReportCheckFactory +from reference_documents.tests.factories import AlignmentReportFactory + +pytestmark = pytest.mark.django_db + + +@pytest.mark.reference_documents +class TestAlignmentReport: + def test_create_with_defaults(self): + target = AlignmentReportFactory() + assert target.created_at is not None + assert target.reference_document_version is not None + + +@pytest.mark.reference_documents +class TestAlignmentReportCheck: + def test_create_with_defaults(self): + target = AlignmentReportCheckFactory() + + assert target.created_at is not None + assert target.alignment_report is not None + assert target.check_name is not None + assert target.status is not None + assert target.message is not None + assert target.preferential_quota is None + assert target.preferential_rate is None diff --git a/reference_documents/tests/test_preferential_quota_order_number_forms.py b/reference_documents/tests/test_preferential_quota_order_number_forms.py new file mode 100644 index 000000000..44181c3aa --- /dev/null +++ b/reference_documents/tests/test_preferential_quota_order_number_forms.py @@ -0,0 +1,238 @@ +from decimal import Decimal + +import pytest +from django.core.exceptions import ValidationError + +from reference_documents.forms.preferential_quota_order_number_forms import ( + PreferentialQuotaOrderNumberCreateUpdateForm, +) +from reference_documents.forms.preferential_quota_order_number_forms import ( + PreferentialQuotaOrderNumberDeleteForm, +) +from reference_documents.models import PreferentialQuotaOrderNumber +from reference_documents.tests import factories + +pytestmark = pytest.mark.django_db + + +@pytest.mark.reference_documents +class TestPreferentialQuotaOrderNumberCreateUpdateForm: + def test_init(self): + pref_quota_order_number = factories.PreferentialQuotaOrderNumberFactory() + + target = PreferentialQuotaOrderNumberCreateUpdateForm( + pref_quota_order_number.reference_document_version, + instance=pref_quota_order_number, + ) + + # it sets initial values + assert ( + target.reference_document_version + == pref_quota_order_number.reference_document_version + ) + assert target.Meta.model == PreferentialQuotaOrderNumber + assert target.Meta.fields == [ + "quota_order_number", + "coefficient", + "main_order_number", + "valid_between", + ] + + def test_clean_coefficient_pass_valid(self): + pref_quota_order_number = factories.PreferentialQuotaOrderNumberFactory() + + data = { + "coefficient": "1.6", + } + + target = PreferentialQuotaOrderNumberCreateUpdateForm( + pref_quota_order_number.reference_document_version, + instance=pref_quota_order_number, + data=data, + ) + + assert not target.is_valid() + assert target.instance.coefficient == Decimal("1.6") + + def test_clean_coefficient_fail_invalid(self): + pref_quota_order_number = factories.PreferentialQuotaOrderNumberFactory() + + data = { + "coefficient": "zz", + } + + target = PreferentialQuotaOrderNumberCreateUpdateForm( + pref_quota_order_number.reference_document_version, + instance=pref_quota_order_number, + data=data, + ) + + assert not target.is_valid() + assert target.errors["coefficient"] == ["Coefficient not a valid number"] + + def test_clean_coefficient_pass_blank(self): + pref_quota_order_number = factories.PreferentialQuotaOrderNumberFactory() + + data = { + "coefficient": "", + } + + target = PreferentialQuotaOrderNumberCreateUpdateForm( + pref_quota_order_number.reference_document_version, + instance=pref_quota_order_number, + data=data, + ) + + assert not target.is_valid() + assert "coefficient" not in target.errors.keys() + + def test_clean_coefficient_pass_not_provided(self): + pref_quota_order_number = factories.PreferentialQuotaOrderNumberFactory() + + data = {} + + target = PreferentialQuotaOrderNumberCreateUpdateForm( + pref_quota_order_number.reference_document_version, + instance=pref_quota_order_number, + data=data, + ) + + assert not target.is_valid() + assert "coefficient" not in target.errors.keys() + + def test_clean_quota_order_number_valid_adding(self): + pref_quota_order_number = factories.PreferentialQuotaOrderNumberFactory() + + data = { + "quota_order_number": "054333", + } + + target = PreferentialQuotaOrderNumberCreateUpdateForm( + pref_quota_order_number.reference_document_version, + data=data, + ) + + assert not target.is_valid() + assert "quota_order_number" not in target.errors.keys() + + def test_clean_quota_order_number_invalid_already_exists_adding(self): + pref_quota_order_number = factories.PreferentialQuotaOrderNumberFactory( + quota_order_number="054333", + ) + + data = { + "quota_order_number": "054333", + } + + target = PreferentialQuotaOrderNumberCreateUpdateForm( + pref_quota_order_number.reference_document_version, + data=data, + ) + + assert not target.is_valid() + assert "quota_order_number" in target.errors.keys() + + def test_clean_quota_order_number_invalid_order_number_adding(self): + ref_doc_ver = factories.ReferenceDocumentVersionFactory() + + data = { + "quota_order_number": "zzaabb", + } + + target = PreferentialQuotaOrderNumberCreateUpdateForm( + ref_doc_ver, + data=data, + ) + + assert not target.is_valid() + assert "quota_order_number" in target.errors.keys() + + def test_clean_coefficient_no_main_order(self): + factories.PreferentialQuotaOrderNumberFactory() + pref_quota_order_number = factories.PreferentialQuotaOrderNumberFactory() + + data = { + "quota_order_number": pref_quota_order_number.quota_order_number, + "coefficient": "1.0", + "valid_between": pref_quota_order_number.valid_between, + } + + target = PreferentialQuotaOrderNumberCreateUpdateForm( + pref_quota_order_number.reference_document_version, + data=data, + ) + + target.is_valid() + + with pytest.raises(ValidationError) as ve: + target.clean() + assert "Coefficient specified without main order number" in str(ve.value) + + def test_clean_main_order_no_coefficient(self): + pref_quota_order_number_main = factories.PreferentialQuotaOrderNumberFactory() + pref_quota_order_number = factories.PreferentialQuotaOrderNumberFactory() + + data = { + "main_order_number_id": pref_quota_order_number_main.id, + "quota_order_number": pref_quota_order_number.quota_order_number, + "valid_between": pref_quota_order_number.valid_between, + } + + target = PreferentialQuotaOrderNumberCreateUpdateForm( + pref_quota_order_number.reference_document_version, + data=data, + ) + + target.is_valid() + + with pytest.raises(ValidationError) as ve: + target.clean() + + assert "Main order number specified without coefficient" in str(ve.value) + + +@pytest.mark.reference_documents +class TestPreferentialQuotaOrderNumberDeleteForm: + def test_init(self): + pref_quota_order_number = factories.PreferentialQuotaOrderNumberFactory() + + target = PreferentialQuotaOrderNumberDeleteForm( + instance=pref_quota_order_number, + ) + + assert target.instance == pref_quota_order_number + assert target.Meta.fields == [] + assert target.Meta.model == PreferentialQuotaOrderNumber + + def test_clean_with_child_records(self): + pref_quota = factories.PreferentialQuotaFactory() + + target = PreferentialQuotaOrderNumberDeleteForm( + instance=pref_quota.preferential_quota_order_number, + data={}, + ) + + assert not target.is_valid() + + with pytest.raises(ValidationError) as ve: + target.clean() + + expected_string = ( + f"Quota Order Number {pref_quota.preferential_quota_order_number} " + f"cannot be deleted as it has associated Preferential Quotas." + ) + + assert expected_string in str(ve) + + def test_clean_with_no_child_records(self): + pref_quota_order_number = factories.PreferentialQuotaOrderNumberFactory() + + target = PreferentialQuotaOrderNumberDeleteForm( + instance=pref_quota_order_number, + data={}, + ) + + assert target.is_valid() + target.clean() + + assert len(target.errors) == 0 diff --git a/reference_documents/tests/test_preferential_quota_order_number_models.py b/reference_documents/tests/test_preferential_quota_order_number_models.py new file mode 100644 index 000000000..f1f7bb5a8 --- /dev/null +++ b/reference_documents/tests/test_preferential_quota_order_number_models.py @@ -0,0 +1,22 @@ +import pytest + +from reference_documents.models import PreferentialQuotaOrderNumber +from reference_documents.tests.factories import PreferentialQuotaOrderNumberFactory + +pytestmark = pytest.mark.django_db + + +@pytest.mark.reference_documents +class TestPreferentialQuotaOrderNumber: + def test_init(self): + target = PreferentialQuotaOrderNumber() + + assert target.quota_order_number == "" + assert target.coefficient is None + assert target.main_order_number is None + assert target.valid_between is None + + def test_str(self): + target = PreferentialQuotaOrderNumberFactory.create() + + assert str(target) == f"{target.quota_order_number}" diff --git a/reference_documents/tests/test_preferential_quota_order_number_views.py b/reference_documents/tests/test_preferential_quota_order_number_views.py new file mode 100644 index 000000000..e9849a0ab --- /dev/null +++ b/reference_documents/tests/test_preferential_quota_order_number_views.py @@ -0,0 +1,311 @@ +from datetime import date +from decimal import Decimal + +import pytest +from django.urls import reverse + +from reference_documents.models import PreferentialQuotaOrderNumber +from reference_documents.tests import factories + +pytestmark = pytest.mark.django_db + + +@pytest.mark.reference_documents +class TestPreferentialQuotaOrderNumberEditView: + def test_get_without_permissions(self, valid_user_client): + pref_quota_order_number = factories.PreferentialQuotaOrderNumberFactory() + + resp = valid_user_client.get( + reverse( + "reference_documents:preferential_quota_order_number_edit", + kwargs={"pk": pref_quota_order_number.pk}, + ), + ) + assert resp.status_code == 403 + + def test_get_with_permissions(self, superuser_client): + pref_quota_order_number = factories.PreferentialQuotaOrderNumberFactory() + + resp = superuser_client.get( + reverse( + "reference_documents:preferential_quota_order_number_edit", + kwargs={"pk": pref_quota_order_number.pk}, + ), + ) + assert resp.status_code == 200 + + def test_post_with_permission_pass_not_sub_quota(self, superuser_client): + factories.PreferentialQuotaOrderNumberFactory.create() + pref_quota_order_number = factories.PreferentialQuotaOrderNumberFactory.create() + + post_data = { + "quota_order_number": "012345", + "start_date_0": 1, + "start_date_1": 1, + "start_date_2": 2022, + "end_date_0": 1, + "end_date_1": 1, + "end_date_2": 2023, + "coefficient": "", + "main_order_number_id": "", + "reference_document_version_id": pref_quota_order_number.reference_document_version.pk, + } + + resp = superuser_client.post( + reverse( + "reference_documents:preferential_quota_order_number_edit", + kwargs={"pk": pref_quota_order_number.pk}, + ), + post_data, + ) + + assert resp.status_code == 302 + + pref_quota_order_number.refresh_from_db() + + # check the update was applied + assert pref_quota_order_number.valid_between.lower == date(2022, 1, 1) + assert pref_quota_order_number.valid_between.upper == date(2023, 1, 1) + assert pref_quota_order_number.coefficient is None + assert pref_quota_order_number.quota_order_number == "012345" + assert pref_quota_order_number.main_order_number_id is None + + def test_post_with_permission_pass_with_sub_quota(self, superuser_client): + pref_quota_order_number_main = ( + factories.PreferentialQuotaOrderNumberFactory.create() + ) + pref_quota_order_number = factories.PreferentialQuotaOrderNumberFactory.create() + + post_data = { + "quota_order_number": "012345", + "start_date_0": 1, + "start_date_1": 1, + "start_date_2": 2022, + "end_date_0": 1, + "end_date_1": 1, + "end_date_2": 2023, + "coefficient": "1.2", + "main_order_number_id": pref_quota_order_number_main.pk, + "reference_document_version_id": pref_quota_order_number.reference_document_version.pk, + } + + resp = superuser_client.post( + reverse( + "reference_documents:preferential_quota_order_number_edit", + kwargs={"pk": pref_quota_order_number.pk}, + ), + post_data, + ) + + assert resp.status_code == 302 + + pref_quota_order_number.refresh_from_db() + + # check the update was applied + assert pref_quota_order_number.valid_between.lower == date(2022, 1, 1) + assert pref_quota_order_number.valid_between.upper == date(2023, 1, 1) + assert pref_quota_order_number.coefficient == Decimal("1.2") + assert pref_quota_order_number.quota_order_number == "012345" + assert pref_quota_order_number.main_order_number_id == None + + +@pytest.mark.reference_documents +class TestPreferentialQuotaOrderNumberCreateView: + def test_get_without_permissions(self, valid_user_client): + ref_doc_version = factories.ReferenceDocumentVersionFactory.create() + + resp = valid_user_client.get( + reverse( + "reference_documents:preferential_quota_order_number_create", + kwargs={"pk": ref_doc_version.pk}, + ), + ) + assert resp.status_code == 403 + + def test_get_with_permissions(self, superuser_client): + ref_doc_version = factories.ReferenceDocumentVersionFactory.create() + + resp = superuser_client.get( + reverse( + "reference_documents:preferential_quota_order_number_create", + kwargs={"pk": ref_doc_version.pk}, + ), + ) + assert resp.status_code == 200 + + def test_post_with_permission_pass_not_sub_quota(self, superuser_client): + ref_doc_version = factories.ReferenceDocumentVersionFactory.create() + + post_data = { + "quota_order_number": "012345", + "start_date_0": 1, + "start_date_1": 1, + "start_date_2": 2022, + "end_date_0": 1, + "end_date_1": 1, + "end_date_2": 2023, + "coefficient": "", + "main_order_number_id": "", + "reference_document_version_id": ref_doc_version.pk, + } + + resp = superuser_client.post( + reverse( + "reference_documents:preferential_quota_order_number_create", + kwargs={"pk": ref_doc_version.pk}, + ), + post_data, + ) + + assert resp.status_code == 302 + + pref_quota_order_number = ( + ref_doc_version.preferential_quota_order_numbers.all().last() + ) + + # check the update was applied + assert pref_quota_order_number.valid_between.lower == date(2022, 1, 1) + assert pref_quota_order_number.valid_between.upper == date(2023, 1, 1) + assert pref_quota_order_number.coefficient is None + assert pref_quota_order_number.quota_order_number == "012345" + assert pref_quota_order_number.main_order_number_id is None + + def test_post_with_permission_pass_with_sub_quota(self, superuser_client): + pref_quota_order_number_main = ( + factories.PreferentialQuotaOrderNumberFactory.create() + ) + ref_doc_version = factories.ReferenceDocumentVersionFactory.create() + + post_data = { + "quota_order_number": "012345", + "start_date_0": 1, + "start_date_1": 1, + "start_date_2": 2022, + "end_date_0": 1, + "end_date_1": 1, + "end_date_2": 2023, + "coefficient": "1.2", + "main_order_number_id": pref_quota_order_number_main.pk, + "reference_document_version_id": ref_doc_version.pk, + } + + resp = superuser_client.post( + reverse( + "reference_documents:preferential_quota_order_number_create", + kwargs={"pk": ref_doc_version.pk}, + ), + post_data, + ) + + assert resp.status_code == 302 + + pref_quota_order_number = ( + ref_doc_version.preferential_quota_order_numbers.all().last() + ) + + # check the update was applied + assert pref_quota_order_number.valid_between.lower == date(2022, 1, 1) + assert pref_quota_order_number.valid_between.upper == date(2023, 1, 1) + assert pref_quota_order_number.coefficient == Decimal("1.2") + assert pref_quota_order_number.quota_order_number == "012345" + assert pref_quota_order_number.main_order_number_id == None + + +@pytest.mark.reference_documents +class TestPreferentialQuotaOrderNumberDeleteView: + def test_get_without_permissions(self, valid_user_client): + pref_quota_order_number = factories.PreferentialQuotaOrderNumberFactory.create() + ref_doc_version = pref_quota_order_number.reference_document_version + + resp = valid_user_client.get( + reverse( + "reference_documents:preferential_quota_order_number_delete", + kwargs={ + "pk": pref_quota_order_number.pk, + "version_pk": ref_doc_version.pk, + }, + ), + ) + assert resp.status_code == 403 + + def test_get_with_permissions(self, superuser_client): + pref_quota_order_number = factories.PreferentialQuotaOrderNumberFactory.create() + ref_doc_version = pref_quota_order_number.reference_document_version + + resp = superuser_client.get( + reverse( + "reference_documents:preferential_quota_order_number_delete", + kwargs={ + "pk": pref_quota_order_number.pk, + "version_pk": ref_doc_version.pk, + }, + ), + ) + + assert resp.status_code == 200 + + def test_post_with_permission(self, superuser_client): + pref_quota_order_number = factories.PreferentialQuotaOrderNumberFactory.create() + ref_doc_version = pref_quota_order_number.reference_document_version + pref_quota_order_number_id = pref_quota_order_number.id + resp = superuser_client.post( + reverse( + "reference_documents:preferential_quota_order_number_delete", + kwargs={ + "pk": pref_quota_order_number.pk, + "version_pk": ref_doc_version.pk, + }, + ), + {}, + ) + + assert resp.status_code == 302 + results = PreferentialQuotaOrderNumber.objects.all().filter( + id=pref_quota_order_number_id, + ) + assert len(results) == 0 + + def test_post_without_permission(self, valid_user_client): + pref_quota_order_number = factories.PreferentialQuotaOrderNumberFactory.create() + ref_doc_version = pref_quota_order_number.reference_document_version + pref_quota_order_number_id = pref_quota_order_number.id + resp = valid_user_client.post( + reverse( + "reference_documents:preferential_quota_order_number_delete", + kwargs={ + "pk": pref_quota_order_number.pk, + "version_pk": ref_doc_version.pk, + }, + ), + {}, + ) + + assert resp.status_code == 403 + results = PreferentialQuotaOrderNumber.objects.all().filter( + id=pref_quota_order_number_id, + ) + assert len(results) == 1 + + def test_post_with_permission_with_sub_records(self, superuser_client): + pref_quota_order_number = ( + factories.PreferentialQuotaFactory.create().preferential_quota_order_number + ) + ref_doc_version = pref_quota_order_number.reference_document_version + pref_quota_order_number_id = pref_quota_order_number.id + + resp = superuser_client.post( + reverse( + "reference_documents:preferential_quota_order_number_delete", + kwargs={ + "pk": pref_quota_order_number.pk, + "version_pk": ref_doc_version.pk, + }, + ), + {}, + ) + + assert resp.status_code == 200 + results = PreferentialQuotaOrderNumber.objects.all().filter( + id=pref_quota_order_number_id, + ) + assert len(results) == 1 diff --git a/reference_documents/tests/test_preferential_quotas_forms.py b/reference_documents/tests/test_preferential_quotas_forms.py new file mode 100644 index 000000000..ce3951b87 --- /dev/null +++ b/reference_documents/tests/test_preferential_quotas_forms.py @@ -0,0 +1,238 @@ +import pytest +from django.core.exceptions import ValidationError + +from reference_documents.forms.preferential_quota_forms import ( + PreferentialQuotaBulkCreateForm, +) +from reference_documents.forms.preferential_quota_forms import ( + PreferentialQuotaCreateUpdateForm, +) +from reference_documents.forms.preferential_quota_forms import ( + PreferentialQuotaDeleteForm, +) +from reference_documents.models import PreferentialQuota +from reference_documents.tests import factories + +pytestmark = pytest.mark.django_db + + +@pytest.mark.reference_documents +class TestPreferentialQuotaCreateUpdateForm: + def test_init(self): + pref_quota = factories.PreferentialQuotaFactory() + + target = PreferentialQuotaCreateUpdateForm( + pref_quota.preferential_quota_order_number.reference_document_version, + pref_quota.preferential_quota_order_number, + instance=pref_quota, + ) + + # it sets initial values + assert ( + target.initial["preferential_quota_order_number"] + == pref_quota.preferential_quota_order_number + ) + assert ( + target.reference_document_version + == pref_quota.preferential_quota_order_number.reference_document_version + ) + assert target.Meta.fields == [ + "preferential_quota_order_number", + "commodity_code", + "quota_duty_rate", + "volume", + "measurement", + "valid_between", + ] + + def test_clean_quota_duty_rate_pass(self): + pref_quota = factories.PreferentialQuotaFactory() + + data = { + "quota_duty_rate": "10%", + } + + target = PreferentialQuotaCreateUpdateForm( + pref_quota.preferential_quota_order_number.reference_document_version, + pref_quota.preferential_quota_order_number, + instance=pref_quota, + data=data, + ) + + assert not target.is_valid() + assert target.clean_quota_duty_rate() == "10%" + + def test_clean_quota_duty_rate_fail(self): + pref_quota = factories.PreferentialQuotaFactory() + + data = { + "quota_duty_rate": "", + } + + target = PreferentialQuotaCreateUpdateForm( + pref_quota.preferential_quota_order_number.reference_document_version, + pref_quota.preferential_quota_order_number, + instance=pref_quota, + data=data, + ) + + assert not target.is_valid() + + with pytest.raises(ValidationError) as ve: + target.clean_quota_duty_rate() + + assert "Quota duty Rate is not valid - it must have a value" in str(ve) + + def test_clean_preferential_quota_order_number_pass(self): + pref_quota = factories.PreferentialQuotaFactory() + + data = { + "preferential_quota_order_number": pref_quota.preferential_quota_order_number.pk, + } + + target = PreferentialQuotaCreateUpdateForm( + pref_quota.preferential_quota_order_number.reference_document_version, + pref_quota.preferential_quota_order_number, + instance=pref_quota, + data=data, + ) + + assert not target.is_valid() + + assert target.clean_preferential_quota_order_number() is not None + + def test_preferential_quota_order_number_fail(self): + pref_quota = factories.PreferentialQuotaFactory() + + data = { + "preferential_quota_order_number": None, + } + + target = PreferentialQuotaCreateUpdateForm( + pref_quota.preferential_quota_order_number.reference_document_version, + pref_quota.preferential_quota_order_number, + instance=pref_quota, + data=data, + ) + + assert not target.is_valid() + + with pytest.raises(ValidationError) as ve: + target.clean_preferential_quota_order_number() + + assert "Quota Order Number is not valid - it must have a value" in str(ve) + + +@pytest.mark.reference_documents +class TestPreferentialQuotaDeleteForm: + def test_init(self): + pref_quota = factories.PreferentialQuotaFactory() + + target = PreferentialQuotaDeleteForm( + instance=pref_quota, + ) + + assert target.instance == pref_quota + assert target.Meta.fields == [] + assert target.Meta.model == PreferentialQuota + + +@pytest.mark.reference_documents +def test_preferential_quota_bulk_create_valid_data(): + """Test that preferential quota bulk create is valid when completed + correctly.""" + ref_doc_version = factories.ReferenceDocumentVersionFactory.create() + preferential_quota_order_number = ( + factories.PreferentialQuotaOrderNumberFactory.create( + reference_document_version=ref_doc_version, + ) + ) + data = { + "preferential_quota_order_number": preferential_quota_order_number.pk, + "commodity_codes": "1234567890\r\n2345678901", + "quota_duty_rate": "5%", + "measurement": "KG", + "start_date_0_0": "1", + "start_date_0_1": "1", + "start_date_0_2": "2023", + "end_date_0_0": "31", + "end_date_0_1": "12", + "end_date_0_2": "2023", + "volume_0": "500", + "start_date_1_0": "1", + "start_date_1_1": "1", + "start_date_1_2": "2024", + "end_date_1_0": "31", + "end_date_1_1": "12", + "end_date_1_2": "2024", + "volume_1": "400", + "start_date_2_0": "1", + "start_date_2_1": "1", + "start_date_2_2": "2025", + "end_date_2_0": "31", + "end_date_2_1": "12", + "end_date_2_2": "2025", + "volume_2": "300", + } + + form = PreferentialQuotaBulkCreateForm( + data=data, + reference_document_version=ref_doc_version, + ) + assert form.is_valid() + + +@pytest.mark.reference_documents +def test_preferential_quota_bulk_create_invalid_data(): + """Test that preferential quota bulk create is invalid when completed + incorrectly.""" + ref_doc_version = factories.ReferenceDocumentVersionFactory.create() + preferential_quota_order_number = ( + factories.PreferentialQuotaOrderNumberFactory.create( + reference_document_version=ref_doc_version, + ) + ) + data = { + "preferential_quota_order_number": preferential_quota_order_number, + "commodity_codes": "1234567890\r\n2345678901\r\n12345678910", + "quota_duty_rate": "", + "measurement": "", + "start_date_0_0": "1", + "start_date_0_1": "1", + "start_date_0_2": "2023", + "end_date_0_0": "31", + "end_date_0_1": "12", + "end_date_0_2": "2023", + "volume_0": "500", + "start_date_1_0": "1", + "start_date_1_1": "1", + "start_date_1_2": "2024", + "end_date_1_0": "31", + "end_date_1_1": "12", + "end_date_1_2": "2022", + "volume_1": "400", + "start_date_2_0": "", + "start_date_2_1": "1", + "start_date_2_2": "2025", + "end_date_2_0": "31", + "end_date_2_1": "12", + "end_date_2_2": "2025", + "volume_2": "300", + } + + form = PreferentialQuotaBulkCreateForm( + data=data, + reference_document_version=ref_doc_version, + ) + assert not form.is_valid() + assert ( + "Ensure all commodity codes are 10 digits and each on a new line" + in form.errors["commodity_codes"] + ) + assert "Duty rate is required" in form.errors["quota_duty_rate"] + assert "Measurement is required" in form.errors["measurement"] + assert ( + "The end date must be the same as or after the start date." + in form.errors["end_date_1"] + ) + assert "Enter the day, month and year" in form.errors["start_date_2"] diff --git a/reference_documents/tests/test_preferential_quotas_models.py b/reference_documents/tests/test_preferential_quotas_models.py new file mode 100644 index 000000000..63e6ae414 --- /dev/null +++ b/reference_documents/tests/test_preferential_quotas_models.py @@ -0,0 +1,19 @@ +import pytest + +from reference_documents.models import PreferentialQuota + +pytestmark = pytest.mark.django_db + + +@pytest.mark.reference_documents +class TestPreferentialQuota: + def test_init(self): + target = PreferentialQuota() + + assert target.preferential_quota_order_number is None + assert target.commodity_code == "" + assert target.quota_duty_rate == "" + assert target.volume == "" + assert target.valid_between is None + assert target.measurement == "" + assert target.order is None diff --git a/reference_documents/tests/test_preferential_quotas_views.py b/reference_documents/tests/test_preferential_quotas_views.py new file mode 100644 index 000000000..cf92fce75 --- /dev/null +++ b/reference_documents/tests/test_preferential_quotas_views.py @@ -0,0 +1,540 @@ +import pytest +from bs4 import BeautifulSoup +from django.contrib.auth.models import Permission +from django.urls import reverse + +from reference_documents.models import PreferentialQuota +from reference_documents.tests import factories + +pytestmark = pytest.mark.django_db + + +@pytest.mark.reference_documents +class TestPreferentialQuotaEditView: + def test_get_without_permissions(self, valid_user_client): + pref_quota = factories.PreferentialQuotaFactory.create() + + resp = valid_user_client.get( + reverse( + "reference_documents:preferential_quotas_edit", + kwargs={"pk": pref_quota.pk}, + ), + ) + assert resp.status_code == 403 + + def test_get_with_permissions(self, superuser_client): + pref_quota = factories.PreferentialQuotaFactory.create() + + resp = superuser_client.get( + reverse( + "reference_documents:preferential_quotas_edit", + kwargs={"pk": pref_quota.pk}, + ), + ) + assert resp.status_code == 200 + + def test_post_with_permissions(self, superuser_client): + pref_quota = factories.PreferentialQuotaFactory.create() + + post_data = { + "preferential_quota_order_number": pref_quota.preferential_quota_order_number.pk, + "commodity_code": pref_quota.commodity_code, + "quota_duty_rate": "33%", + "volume": 3000, + "measurement": "tonnes", + "start_date_0": 1, + "start_date_1": 1, + "start_date_2": 2022, + "end_date_0": 1, + "end_date_1": 1, + "end_date_2": 2023, + } + + resp = superuser_client.post( + reverse( + "reference_documents:preferential_quotas_edit", + kwargs={"pk": pref_quota.pk}, + ), + data=post_data, + ) + + assert resp.status_code == 302 + + pref_quota.refresh_from_db() + + assert pref_quota.volume == "3000" + assert pref_quota.measurement == "tonnes" + assert pref_quota.quota_duty_rate == "33%" + + def test_post_without_permissions(self, valid_user_client): + pref_quota = factories.PreferentialQuotaFactory.create() + + post_data = { + "preferential_quota_order_number": pref_quota.preferential_quota_order_number.pk, + "commodity_code": pref_quota.commodity_code, + "quota_duty_rate": "33%", + "volume": 3000, + "measurement": "tonnes", + "start_date_0": 1, + "start_date_1": 1, + "start_date_2": 2022, + "end_date_0": 1, + "end_date_1": 1, + "end_date_2": 2023, + } + + resp = valid_user_client.post( + reverse( + "reference_documents:preferential_quotas_edit", + kwargs={"pk": pref_quota.pk}, + ), + data=post_data, + ) + + assert resp.status_code == 403 + + +@pytest.mark.reference_documents +class TestPreferentialQuotaCreate: + def test_get_without_permissions(self, valid_user_client): + ref_doc_version = factories.ReferenceDocumentVersionFactory.create() + + resp = valid_user_client.get( + reverse( + "reference_documents:preferential_quotas_create", + kwargs={"version_pk": ref_doc_version.pk}, + ), + ) + + assert resp.status_code == 403 + + def test_get_without_permissions_with_order_number(self, valid_user_client): + pref_order_number = factories.PreferentialQuotaOrderNumberFactory.create() + + resp = valid_user_client.get( + reverse( + "reference_documents:preferential_quotas_create_for_order", + kwargs={ + "version_pk": pref_order_number.reference_document_version.pk, + "order_pk": pref_order_number.pk, + }, + ), + ) + + assert resp.status_code == 403 + + def test_get_with_permissions(self, superuser_client): + ref_doc_version = factories.ReferenceDocumentVersionFactory.create() + + resp = superuser_client.get( + reverse( + "reference_documents:preferential_quotas_create", + kwargs={"version_pk": ref_doc_version.pk}, + ), + ) + + assert resp.status_code == 200 + + def test_get_with_permissions_with_order_number(self, superuser_client): + pref_order_number = factories.PreferentialQuotaOrderNumberFactory.create() + + resp = superuser_client.get( + reverse( + "reference_documents:preferential_quotas_create_for_order", + kwargs={ + "version_pk": pref_order_number.reference_document_version.pk, + "order_pk": pref_order_number.pk, + }, + ), + ) + + assert resp.status_code == 200 + + def test_post_without_permissions(self, valid_user_client): + pref_quota_order_number = factories.PreferentialQuotaOrderNumberFactory.create() + + post_data = { + "preferential_quota_order_number": pref_quota_order_number.pk, + "commodity_code": "1231231230", + "quota_duty_rate": "33%", + "volume": 3000, + "measurement": "tonnes", + "start_date_0": 1, + "start_date_1": 1, + "start_date_2": 2022, + "end_date_0": 1, + "end_date_1": 1, + "end_date_2": 2023, + } + + resp = valid_user_client.post( + reverse( + "reference_documents:preferential_quotas_create", + kwargs={"version_pk": pref_quota_order_number.pk}, + ), + data=post_data, + ) + + assert resp.status_code == 403 + + def test_post_without_permissions_with_order_number(self, valid_user_client): + pref_order_number = factories.PreferentialQuotaOrderNumberFactory.create() + + post_data = { + "preferential_quota_order_number": pref_order_number.pk, + "commodity_code": "1231231230", + "quota_duty_rate": "33%", + "volume": 3000, + "measurement": "tonnes", + "start_date_0": 1, + "start_date_1": 1, + "start_date_2": 2022, + "end_date_0": 1, + "end_date_1": 1, + "end_date_2": 2023, + } + + resp = valid_user_client.post( + reverse( + "reference_documents:preferential_quotas_create_for_order", + kwargs={ + "version_pk": pref_order_number.reference_document_version.pk, + "order_pk": pref_order_number.pk, + }, + ), + data=post_data, + ) + + assert resp.status_code == 403 + + def test_post_with_permissions(self, superuser_client): + pref_quota_order_number = factories.PreferentialQuotaOrderNumberFactory.create() + + post_data = { + "preferential_quota_order_number": pref_quota_order_number.pk, + "commodity_code": "1231231230", + "quota_duty_rate": "33%", + "volume": 3000, + "measurement": "tonnes", + "start_date_0": 1, + "start_date_1": 1, + "start_date_2": 2022, + "end_date_0": 1, + "end_date_1": 1, + "end_date_2": 2023, + } + + resp = superuser_client.post( + reverse( + "reference_documents:preferential_quotas_create", + kwargs={ + "version_pk": pref_quota_order_number.reference_document_version.pk, + }, + ), + data=post_data, + ) + + assert resp.status_code == 302 + + def test_post_with_permissions_with_order_number(self, superuser_client): + pref_order_number = factories.PreferentialQuotaOrderNumberFactory.create() + + post_data = { + "preferential_quota_order_number": pref_order_number.pk, + "commodity_code": "1231231230", + "quota_duty_rate": "33%", + "volume": 3000, + "measurement": "tonnes", + "start_date_0": 1, + "start_date_1": 1, + "start_date_2": 2022, + "end_date_0": 1, + "end_date_1": 1, + "end_date_2": 2023, + } + + resp = superuser_client.post( + reverse( + "reference_documents:preferential_quotas_create_for_order", + kwargs={ + "version_pk": pref_order_number.reference_document_version.pk, + "order_pk": pref_order_number.pk, + }, + ), + data=post_data, + ) + + assert resp.status_code == 302 + + +@pytest.mark.reference_documents +class TestPreferentialQuotaBulkCreate: + def test_quota_bulk_create_creates_object_and_redirects(self, valid_user, client): + """Test that posting the bulk create from creates all preferential + quotas and redirects.""" + valid_user.user_permissions.add( + Permission.objects.get(codename="add_preferentialquota"), + ) + client.force_login(valid_user) + + ref_doc_version = factories.ReferenceDocumentVersionFactory.create() + preferential_quota_order_number = ( + factories.PreferentialQuotaOrderNumberFactory.create( + reference_document_version=ref_doc_version, + ) + ) + assert not ref_doc_version.preferential_quotas() + + data = { + "preferential_quota_order_number": preferential_quota_order_number.pk, + "commodity_codes": "1234567890\r\n2345678901", + "quota_duty_rate": "5%", + "measurement": "KG", + "start_date_0_0": "1", + "start_date_0_1": "1", + "start_date_0_2": "2023", + "end_date_0_0": "31", + "end_date_0_1": "12", + "end_date_0_2": "2023", + "volume_0": "500", + "start_date_1_0": "1", + "start_date_1_1": "1", + "start_date_1_2": "2024", + "end_date_1_0": "31", + "end_date_1_1": "12", + "end_date_1_2": "2024", + "volume_1": "400", + "start_date_2_0": "1", + "start_date_2_1": "1", + "start_date_2_2": "2025", + "end_date_2_0": "31", + "end_date_2_1": "12", + "end_date_2_2": "2025", + "volume_2": "300", + } + + create_url = reverse( + "reference_documents:preferential_quotas_bulk_create_for_order", + kwargs={ + "pk": ref_doc_version.pk, + "order_pk": preferential_quota_order_number.pk, + }, + ) + resp = client.get(create_url) + assert resp.status_code == 200 + + resp = client.post(create_url, data) + assert resp.status_code == 302 + new_preferential_quotas = ref_doc_version.preferential_quotas() + assert len(new_preferential_quotas) == 6 + assert ( + resp.url + == reverse( + "reference_documents:version-details", + args=[ref_doc_version.pk], + ) + + "#tariff-quotas" + ) + + def test_quota_bulk_create_invalid(self, valid_user, client): + """Test that posting the bulk create form with invalid data fails and + reloads the form with errors.""" + valid_user.user_permissions.add( + Permission.objects.get(codename="add_preferentialquota"), + ) + client.force_login(valid_user) + + ref_doc_version = factories.ReferenceDocumentVersionFactory.create() + preferential_quota_order_number = ( + factories.PreferentialQuotaOrderNumberFactory.create( + reference_document_version=ref_doc_version, + ) + ) + assert not ref_doc_version.preferential_quotas() + + data = { + "preferential_quota_order_number": preferential_quota_order_number.pk, + "commodity_codes": "1234567890\r\n2345678901\r\n12345678910", + "quota_duty_rate": "", + "measurement": "", + "start_date_0_0": "1", + "start_date_0_1": "1", + "start_date_0_2": "2023", + "end_date_0_0": "31", + "end_date_0_1": "12", + "end_date_0_2": "2023", + "volume_0": "500", + "start_date_1_0": "1", + "start_date_1_1": "1", + "start_date_1_2": "2024", + "end_date_1_0": "31", + "end_date_1_1": "12", + "end_date_1_2": "2022", + "volume_1": "400", + "start_date_2_0": "", + "start_date_2_1": "1", + "start_date_2_2": "2025", + "end_date_2_0": "31", + "end_date_2_1": "12", + "end_date_2_2": "2025", + "volume_2": "300", + } + + create_url = reverse( + "reference_documents:preferential_quotas_bulk_create", + kwargs={"pk": ref_doc_version.pk}, + ) + + resp = client.post(create_url, data) + assert resp.status_code == 200 + soup = BeautifulSoup(resp.content.decode(resp.charset), "html.parser") + error_messages = soup.select("ul.govuk-list.govuk-error-summary__list a") + + assert "Duty rate is required" == error_messages[0].text + assert "Measurement is required" in error_messages[1].text + assert "Enter the day, month and year" in error_messages[2].text + assert ( + "Ensure all commodity codes are 10 digits and each on a new line" + in error_messages[3].text + ) + assert ( + "The end date must be the same as or after the start date" + in error_messages[4].text + ) + + new_preferential_quotas = ref_doc_version.preferential_quotas() + assert len(new_preferential_quotas) == 0 + + def test_quota_bulk_create_without_permission(self, valid_user_client): + """Test that posting the bulk create form without relevant user + permissions does not work.""" + ref_doc_version = factories.ReferenceDocumentVersionFactory.create() + preferential_quota_order_number = ( + factories.PreferentialQuotaOrderNumberFactory.create( + reference_document_version=ref_doc_version, + ) + ) + assert not ref_doc_version.preferential_quotas() + + data = { + "preferential_quota_order_number": preferential_quota_order_number.pk, + "commodity_codes": "1234567890\r\n2345678901", + "quota_duty_rate": "5%", + "measurement": "KG", + "start_date_0_0": "1", + "start_date_0_1": "1", + "start_date_0_2": "2023", + "end_date_0_0": "31", + "end_date_0_1": "12", + "end_date_0_2": "2023", + "volume_0": "500", + "start_date_1_0": "1", + "start_date_1_1": "1", + "start_date_1_2": "2024", + "end_date_1_0": "31", + "end_date_1_1": "12", + "end_date_1_2": "2024", + "volume_1": "400", + "start_date_2_0": "1", + "start_date_2_1": "1", + "start_date_2_2": "2025", + "end_date_2_0": "31", + "end_date_2_1": "12", + "end_date_2_2": "2025", + "volume_2": "300", + } + + create_url = reverse( + "reference_documents:preferential_quotas_bulk_create", + kwargs={"pk": ref_doc_version.pk}, + ) + + resp = valid_user_client.post(create_url, data) + assert resp.status_code == 403 + assert not ref_doc_version.preferential_quotas() + + +@pytest.mark.reference_documents +class TestPreferentialQuotaDelete: + def test_get_without_permissions(self, valid_user_client): + pref_quota = factories.PreferentialQuotaFactory.create() + ref_doc_version = ( + pref_quota.preferential_quota_order_number.reference_document_version + ) + + resp = valid_user_client.get( + reverse( + "reference_documents:preferential_quotas_delete", + kwargs={ + "pk": pref_quota.pk, + "version_pk": ref_doc_version.pk, + }, + ), + ) + assert resp.status_code == 403 + + def test_get_with_permissions(self, superuser_client): + pref_quota = factories.PreferentialQuotaFactory.create() + ref_doc_version = ( + pref_quota.preferential_quota_order_number.reference_document_version + ) + + resp = superuser_client.get( + reverse( + "reference_documents:preferential_quotas_delete", + kwargs={ + "pk": pref_quota.pk, + "version_pk": ref_doc_version.pk, + }, + ), + ) + + assert resp.status_code == 200 + + def test_post_with_permission(self, superuser_client): + pref_quota = factories.PreferentialQuotaFactory.create() + pref_quota_id = pref_quota.pk + ref_doc_version = ( + pref_quota.preferential_quota_order_number.reference_document_version + ) + + resp = superuser_client.post( + reverse( + "reference_documents:preferential_quotas_delete", + kwargs={ + "pk": pref_quota.pk, + "version_pk": ref_doc_version.pk, + }, + ), + {}, + ) + + assert resp.status_code == 302 + results = PreferentialQuota.objects.all().filter( + id=pref_quota_id, + ) + assert len(results) == 0 + + def test_post_without_permission(self, valid_user_client): + pref_quota = factories.PreferentialQuotaFactory.create() + pref_quota_id = pref_quota.pk + ref_doc_version = ( + pref_quota.preferential_quota_order_number.reference_document_version + ) + + resp = valid_user_client.post( + reverse( + "reference_documents:preferential_quotas_delete", + kwargs={ + "pk": pref_quota.pk, + "version_pk": ref_doc_version.pk, + }, + ), + {}, + ) + + assert resp.status_code == 403 + results = PreferentialQuota.objects.all().filter( + id=pref_quota_id, + ) + assert len(results) == 1 diff --git a/reference_documents/tests/test_preferential_rates_forms.py b/reference_documents/tests/test_preferential_rates_forms.py new file mode 100644 index 000000000..750d86fe5 --- /dev/null +++ b/reference_documents/tests/test_preferential_rates_forms.py @@ -0,0 +1,59 @@ +import pytest + +from reference_documents.forms.preferential_rate_forms import ( + PreferentialRateCreateUpdateForm, +) +from reference_documents.forms.preferential_rate_forms import PreferentialRateDeleteForm +from reference_documents.models import PreferentialRate +from reference_documents.tests import factories + +pytestmark = pytest.mark.django_db + + +@pytest.mark.reference_documents +class TestPreferentialRateCreateUpdateForm: + def test_validation_valid(self): + form = PreferentialRateCreateUpdateForm( + data={ + "commodity_code": "0100000000", + "duty_rate": "10%", + "start_date_0": "1", + "start_date_1": "1", + "start_date_2": "2024", + "end_date": None, + }, + ) + + assert form.is_valid() + + def test_validation_no_comm_code(self): + form = PreferentialRateCreateUpdateForm( + data={ + "commodity_code": "", + "duty_rate": "", + "start_date_0": "1", + "start_date_1": "1", + "start_date_2": "2024", + "end_date": None, + }, + ) + + assert not form.is_valid() + assert "commodity_code" in form.errors.as_data().keys() + assert "duty_rate" in form.errors.as_data().keys() + assert "start_date" not in form.errors.as_data().keys() + assert "end_date" not in form.errors.as_data().keys() + + +@pytest.mark.reference_documents +class TestPreferentialRateDeleteForm: + def test_init(self): + pref_rate = factories.PreferentialRateFactory() + + target = PreferentialRateDeleteForm( + instance=pref_rate, + ) + + assert target.instance == pref_rate + assert target.Meta.fields == [] + assert target.Meta.model == PreferentialRate diff --git a/reference_documents/tests/test_preferential_rates_models.py b/reference_documents/tests/test_preferential_rates_models.py new file mode 100644 index 000000000..d178352b2 --- /dev/null +++ b/reference_documents/tests/test_preferential_rates_models.py @@ -0,0 +1,16 @@ +import pytest + +from reference_documents.models import PreferentialRate + +pytestmark = pytest.mark.django_db + + +@pytest.mark.reference_documents +class TestPreferentialRate: + def test_init(self): + target = PreferentialRate() + assert target.commodity_code == "" + assert target.duty_rate == "" + assert target.order is None + assert target.reference_document_version is None + assert target.valid_between is None diff --git a/reference_documents/tests/test_preferential_rates_views.py b/reference_documents/tests/test_preferential_rates_views.py new file mode 100644 index 000000000..f4fa20c00 --- /dev/null +++ b/reference_documents/tests/test_preferential_rates_views.py @@ -0,0 +1,239 @@ +import pytest +from django.urls import reverse + +from reference_documents.forms.preferential_rate_forms import ( + PreferentialRateCreateUpdateForm, +) +from reference_documents.tests import factories +from reference_documents.views.preferential_rate_views import PreferentialRateCreate +from reference_documents.views.preferential_rate_views import PreferentialRateEdit + +pytestmark = pytest.mark.django_db + + +@pytest.mark.reference_documents +class TestPreferentialRateEditView: + @pytest.mark.parametrize( + "user_type, expected_http_status", + [ + ("regular", 403), + ("superuser", 200), + ], + ) + def test_get( + self, + valid_user, + superuser, + client, + user_type, + expected_http_status, + ): + if user_type == "superuser": + user = superuser + else: + user = valid_user + + client.force_login(user) + pref_rate = factories.PreferentialRateFactory.create() + + resp = client.get( + reverse( + "reference_documents:preferential_rates_edit", + kwargs={"pk": pref_rate.pk}, + ), + ) + + assert resp.status_code == expected_http_status + + def test_success_url(self): + pref_rate = factories.PreferentialRateFactory.create() + + target = PreferentialRateEdit() + target.object = pref_rate + assert target.get_success_url() == reverse( + "reference_documents:version-details", + args=[target.object.reference_document_version.pk], + ) + + def test_form_valid(self): + pref_rate = factories.PreferentialRateFactory.create() + target = PreferentialRateEdit() + target.object = pref_rate + + form = PreferentialRateCreateUpdateForm( + data={ + "start_date_0": 1, + "start_date_1": 1, + "start_date_2": 2024, + "commodity_code": "0100000000", + "duty_rate": "10%", + }, + instance=target.object, + ) + + assert form.is_valid() + assert target.form_valid(form) + + def test_form_invalid(self): + pref_rate = factories.PreferentialRateFactory.create() + target = PreferentialRateEdit() + target.object = pref_rate + + form = PreferentialRateCreateUpdateForm( + data={ + "start_date_0": 1, + "start_date_1": 1, + "start_date_2": 2024, + "commodity_code": "", + "duty_rate": "", + }, + instance=target.object, + ) + + assert not form.is_valid() + + with pytest.raises(ValueError): + target.form_valid(form) + + +@pytest.mark.reference_documents +class TestPreferentialRateCreate: + @pytest.mark.parametrize( + "user_type, expected_http_status", + [ + ("regular", 403), + ("superuser", 200), + ], + ) + def test_get( + self, + valid_user, + superuser, + client, + user_type, + expected_http_status, + ): + if user_type == "superuser": + user = superuser + else: + user = valid_user + + client.force_login(user) + ref_doc_ver = factories.ReferenceDocumentVersionFactory.create() + + resp = client.get( + reverse( + "reference_documents:preferential_rates_create", + kwargs={"version_pk": ref_doc_ver.pk}, + ), + ) + + assert resp.status_code == expected_http_status + + def test_success_url(self): + pref_rate = factories.PreferentialRateFactory.create() + target = PreferentialRateCreate() + target.object = pref_rate + assert target.get_success_url() == reverse( + "reference_documents:version-details", + args=[target.object.reference_document_version.pk], + ) + + @pytest.mark.parametrize( + "user_type, expected_http_status", + [ + ("regular", 403), + ("superuser", 302), + ], + ) + def test_post( + self, + valid_user, + superuser, + client, + user_type, + expected_http_status, + ): + if user_type == "superuser": + user = superuser + else: + user = valid_user + + client.force_login(user) + ref_doc_ver = factories.ReferenceDocumentVersionFactory.create() + + post_data = { + "reference_document_version": ref_doc_ver.pk, + "start_date_0": 1, + "start_date_1": 1, + "start_date_2": 2024, + "commodity_code": "1231231230", + "duty_rate": "12.5%", + } + + resp = client.post( + reverse( + "reference_documents:preferential_rates_create", + kwargs={"version_pk": ref_doc_ver.pk}, + ), + data=post_data, + ) + + assert resp.status_code == expected_http_status + + +@pytest.mark.reference_documents +class TestPreferentialRateDeleteView: + @pytest.mark.parametrize( + "http_method, expected_http_status", + [ + ("get", 200), + ("post", 302), + ], + ) + def test_get_without_permissions( + self, + superuser_client, + http_method, + expected_http_status, + ): + pref_rate = factories.PreferentialRateFactory.create() + + client = superuser_client + + resp = getattr(client, http_method)( + reverse( + "reference_documents:preferential_rates_delete", + kwargs={ + "pk": pref_rate.pk, + }, + ), + ) + assert resp.status_code == expected_http_status + + @pytest.mark.parametrize( + "http_method, expected_http_status", + [ + ("get", 403), + ("post", 403), + ], + ) + def test_regular_user_get_post( + self, + valid_user_client, + http_method, + expected_http_status, + ): + pref_rate = factories.PreferentialRateFactory.create() + + client = valid_user_client + + resp = getattr(client, http_method)( + reverse( + "reference_documents:preferential_rates_delete", + kwargs={ + "pk": pref_rate.pk, + }, + ), + ) + assert resp.status_code == expected_http_status diff --git a/reference_documents/tests/test_reference_document_versions_forms.py b/reference_documents/tests/test_reference_document_versions_forms.py new file mode 100644 index 000000000..c8840efb4 --- /dev/null +++ b/reference_documents/tests/test_reference_document_versions_forms.py @@ -0,0 +1,90 @@ +import pytest + +from reference_documents.forms.reference_document_version_forms import ( + ReferenceDocumentVersionDeleteForm, +) +from reference_documents.forms.reference_document_version_forms import ( + ReferenceDocumentVersionsCreateUpdateForm, +) +from reference_documents.tests import factories + +pytestmark = pytest.mark.django_db + + +@pytest.mark.reference_documents +def test_ref_doc_version_create_update_valid_data(): + """Test that ReferenceDocumentVersionCreateEditForm is valid when completed + correctly.""" + ref_doc = factories.ReferenceDocumentFactory.create() + data = { + "reference_document": ref_doc, + "version": "2.0", + "published_date_0": "11", + "published_date_1": "1", + "published_date_2": "2024", + "entry_into_force_date_0": "1", + "entry_into_force_date_1": "1", + "entry_into_force_date_2": "2024", + } + + form = ReferenceDocumentVersionsCreateUpdateForm(data=data) + assert form.is_valid() + + +@pytest.mark.reference_documents +def test_ref_doc_version_create_update_invalid_data(): + """Test that ReferenceDocumentVersionCreateEditForm is invalid when not + complete correctly.""" + form = ReferenceDocumentVersionsCreateUpdateForm(data={}) + assert not form.is_valid() + assert "A version number is required" in form.errors["version"] + assert "A published date is required" in form.errors["published_date"] + assert ( + "An entry into force date is required" in form.errors["entry_into_force_date"] + ) + + # Test that it fails if a version of a higher number already exists + ref_doc = factories.ReferenceDocumentFactory.create() + factories.ReferenceDocumentVersionFactory.create( + reference_document=ref_doc, + version=3.0, + ) + data = { + "reference_document": ref_doc, + "version": "2.0", + "published_date_0": "11", + "published_date_1": "1", + "published_date_2": "2024", + "entry_into_force_date_0": "1", + "entry_into_force_date_1": "1", + "entry_into_force_date_2": "2024", + } + form = ReferenceDocumentVersionsCreateUpdateForm(data=data) + assert not form.is_valid() + assert ( + "New versions of this reference document must be a higher number than previous versions" + in form.errors["__all__"] + ) + + +@pytest.mark.reference_documents +def test_ref_doc_version_delete_valid(): + """Test that ReferenceDocumentVersionDeleteForm is valid for a reference + document with no versions.""" + version = factories.ReferenceDocumentVersionFactory.create() + form = ReferenceDocumentVersionsCreateUpdateForm(data={}, instance=version) + assert not form.is_valid() + + +@pytest.mark.reference_documents +def test_ref_doc_version_delete_invalid(): + """Test that ReferenceDocumentVersionDeleteForm is invalid for a reference + document with versions.""" + version = factories.ReferenceDocumentVersionFactory.create() + factories.PreferentialRateFactory.create(reference_document_version=version) + form = ReferenceDocumentVersionDeleteForm(data={}, instance=version) + assert not form.is_valid() + assert ( + f"Reference document version {version.version} cannot be deleted as it has current preferential duty rates or tariff quotas" + in form.errors["__all__"] + ) diff --git a/reference_documents/tests/test_reference_document_versions_models.py b/reference_documents/tests/test_reference_document_versions_models.py new file mode 100644 index 000000000..20ed54c5e --- /dev/null +++ b/reference_documents/tests/test_reference_document_versions_models.py @@ -0,0 +1,28 @@ +import pytest + +from reference_documents.tests import factories + +pytestmark = pytest.mark.django_db + + +@pytest.mark.reference_documents +class TestReferenceDocumentVersion: + def test_create_with_defaults(self): + target = factories.ReferenceDocumentVersionFactory() + + assert target.created_at is not None + assert target.updated_at is not None + assert target.version is not None + assert target.published_date is not None + assert target.entry_into_force_date is not None + assert target.reference_document is not None + assert target.status is not None + + def test_preferential_quotas(self): + target = factories.ReferenceDocumentVersionFactory.create() + # add a pref quota + factories.PreferentialQuotaFactory.create( + preferential_quota_order_number__reference_document_version=target, + ) + + assert len(target.preferential_quotas()) == 1 diff --git a/reference_documents/tests/test_reference_document_versions_views.py b/reference_documents/tests/test_reference_document_versions_views.py new file mode 100644 index 000000000..f68537e36 --- /dev/null +++ b/reference_documents/tests/test_reference_document_versions_views.py @@ -0,0 +1,268 @@ +import datetime + +import pytest +from bs4 import BeautifulSoup +from django.contrib.auth.models import Permission +from django.urls import reverse + +from common.tests.factories import QuotaOrderNumberFactory +from common.tests.factories import SimpleGoodsNomenclatureFactory +from common.tests.factories import date_ranges +from reference_documents.models import ReferenceDocument +from reference_documents.models import ReferenceDocumentVersion +from reference_documents.tests import factories + +pytestmark = pytest.mark.django_db + + +@pytest.mark.reference_documents +def test_ref_doc_version_create_creates_object_and_redirects(valid_user, client): + """Tests that posting the reference document version create form adds the + new version to the database and redirects to the confirm-create page.""" + valid_user.user_permissions.add( + Permission.objects.get(codename="add_referencedocumentversion"), + ) + client.force_login(valid_user) + ref_doc = factories.ReferenceDocumentFactory.create() + + create_url = reverse( + "reference_documents:version-create", + kwargs={"pk": ref_doc.pk}, + ) + + resp = client.get(create_url) + assert resp.status_code == 200 + + form_data = { + "reference_document": ref_doc.pk, + "version": "2.0", + "published_date_0": "11", + "published_date_1": "1", + "published_date_2": "2024", + "entry_into_force_date_0": "1", + "entry_into_force_date_1": "1", + "entry_into_force_date_2": "2024", + } + resp = client.post(create_url, form_data) + assert resp.status_code == 302 + + ref_doc = ReferenceDocumentVersion.objects.get( + reference_document=ref_doc, + ) + assert ref_doc + assert resp.url == reverse( + "reference_documents:version-confirm-create", + kwargs={"pk": ref_doc.pk}, + ) + + +@pytest.mark.reference_documents +def test_ref_doc_version_edit_updates_ref_doc_object(client, valid_user): + """Tests that posting the reference document version edit form updates the + reference document and redirects to the confirm-update page.""" + valid_user.user_permissions.add( + Permission.objects.get(codename="change_referencedocumentversion"), + ) + client.force_login(valid_user) + ref_doc_version = factories.ReferenceDocumentVersionFactory.create() + + edit_url = reverse( + "reference_documents:version-edit", + kwargs={ + "pk": ref_doc_version.pk, + "ref_doc_pk": ref_doc_version.reference_document.pk, + }, + ) + form_data = { + "reference_document": ref_doc_version.reference_document.pk, + "version": "6.0", + "published_date_0": "1", + "published_date_1": "1", + "published_date_2": "2024", + "entry_into_force_date_0": "1", + "entry_into_force_date_1": "1", + "entry_into_force_date_2": "2024", + } + resp = client.get(edit_url) + assert resp.status_code == 200 + assert ref_doc_version.version != 6.0 + + resp = client.post(edit_url, form_data) + assert resp.status_code == 302 + assert resp.url == reverse( + "reference_documents:version-confirm-update", + kwargs={"pk": ref_doc_version.pk}, + ) + ref_doc_version.refresh_from_db() + assert ref_doc_version.version == 6.0 + assert ref_doc_version.published_date == datetime.date(2024, 1, 1) + assert ref_doc_version.entry_into_force_date == datetime.date(2024, 1, 1) + + +@pytest.mark.reference_documents +def test_successfully_delete_ref_doc_version(valid_user, client): + """Tests that posting the reference document version delete form deletes the + reference document and redirects to the confirm-delete page.""" + valid_user.user_permissions.add( + Permission.objects.get(codename="delete_referencedocumentversion"), + ) + client.force_login(valid_user) + ref_doc = factories.ReferenceDocumentFactory.create(area_id="XY") + ref_doc_version = factories.ReferenceDocumentVersionFactory.create( + reference_document=ref_doc, + version=3.0, + ) + ref_doc_pk = ref_doc.pk + area_id = ref_doc.area_id + assert ReferenceDocumentVersion.objects.filter(pk=ref_doc_version.pk) + delete_url = reverse( + "reference_documents:version-delete", + kwargs={"pk": ref_doc_version.pk, "ref_doc_pk": ref_doc_pk}, + ) + resp = client.get(delete_url) + page = BeautifulSoup(resp.content, "html.parser") + assert resp.status_code == 200 + assert ( + f"Delete reference document {area_id} version {ref_doc_version.version}" + in page.select("main h1")[0].text + ) + resp = client.post(delete_url) + assert resp.status_code == 302 + assert resp.url == reverse( + "reference_documents:version-confirm-delete", + kwargs={"deleted_pk": ref_doc_version.pk}, + ) + assert not ReferenceDocumentVersion.objects.filter(pk=ref_doc_version.pk) + resp = client.get(resp.url) + assert ( + f"Reference document {area_id} version {ref_doc_version.version} has been deleted" + in str(resp.content) + ) + + +@pytest.mark.reference_documents +def test_delete_ref_doc_version_invalid(valid_user, client): + """Test that deleting a reference document version with preferential rates + does not work.""" + valid_user.user_permissions.add( + Permission.objects.get(codename="delete_referencedocumentversion"), + ) + client.force_login(valid_user) + + preferential_rate = factories.PreferentialRateFactory.create() + ref_doc_version = preferential_rate.reference_document_version + ref_doc = ref_doc_version.reference_document + + delete_url = reverse( + "reference_documents:version-delete", + kwargs={"pk": ref_doc_version.pk, "ref_doc_pk": ref_doc.pk}, + ) + resp = client.get(delete_url) + assert resp.status_code == 200 + + resp = client.post(delete_url) + assert resp.status_code == 200 + assert ( + f"Reference document version {ref_doc_version.version} cannot be deleted as it has current preferential duty rates or tariff quotas" + in str(resp.content) + ) + assert ReferenceDocument.objects.filter(pk=ref_doc.pk) + + +@pytest.mark.reference_documents +def test_ref_doc_crud_without_permission(valid_user_client): + # TODO: potentially update this if the permissions for reference doc behaviour changes + ref_doc_version = factories.ReferenceDocumentVersionFactory.create() + ref_doc = ref_doc_version.reference_document + create_url = reverse( + "reference_documents:version-create", + kwargs={"pk": ref_doc_version.pk}, + ) + edit_url = reverse( + "reference_documents:version-edit", + kwargs={"pk": ref_doc_version.pk, "ref_doc_pk": ref_doc.pk}, + ) + delete_url = reverse( + "reference_documents:version-delete", + kwargs={"pk": ref_doc_version.pk, "ref_doc_pk": ref_doc.pk}, + ) + form_data = { + "reference_document": ref_doc.pk, + "version": "2.0", + "published_date_0": "11", + "published_date_1": "1", + "published_date_2": "2024", + "entry_into_force_date_0": "1", + "entry_into_force_date_1": "1", + "entry_into_force_date_2": "2024", + } + resp = valid_user_client.post(create_url, form_data) + assert resp.status_code == 403 + resp = valid_user_client.post(edit_url, form_data) + assert resp.status_code == 403 + resp = valid_user_client.post(delete_url) + assert resp.status_code == 403 + + +@pytest.mark.reference_documents +def test_ref_doc_version_detail_view(superuser_client): + """Test that the reference document version detail view shows preferential + rate and tariff quota data.""" + ref_doc = factories.ReferenceDocumentFactory.create( + area_id="XY", + title="Reference document for XY", + ) + ref_doc_version = factories.ReferenceDocumentVersionFactory( + reference_document=ref_doc, + version=1.0, + ) + preferential_rate_batch = factories.PreferentialRateFactory.create_batch( + 10, + reference_document_version=ref_doc_version, + ) + first_preferential_rate = preferential_rate_batch[0] + order_number_batch = factories.PreferentialQuotaOrderNumberFactory.create_batch( + 5, + reference_document_version=ref_doc_version, + ) + first_quota_order_number = order_number_batch[0].quota_order_number + # Recreate the first quota and first preferential rate's commodity code in TAP + tap_quota = QuotaOrderNumberFactory.create(order_number=first_quota_order_number) + tap_commodity_code = SimpleGoodsNomenclatureFactory.create( + item_id=first_preferential_rate.commodity_code, + valid_between=date_ranges("big"), + suffix=80, + ) + core_data_tab = ( + reverse( + "reference_documents:version-details", + kwargs={"pk": ref_doc_version.pk}, + ) + + "#core-data" + ) + response = superuser_client.get(core_data_tab) + page = BeautifulSoup(response.content, "html.parser") + assert response.status_code == 200 + # Assert the first rate's commodity code which exists in TAP appears as a link + assert page.find("a", href=True, text=f"{first_preferential_rate.commodity_code}") + table_rows = page.select("tr") + # Assert there is a row for each preferential rate + assert len(table_rows) == 11 + tariff_quotas_tab = ( + reverse( + "reference_documents:version-details", + kwargs={"pk": ref_doc_version.pk}, + ) + + "#tariff_quotas" + ) + response = superuser_client.get(tariff_quotas_tab) + page = BeautifulSoup(response.content, "html.parser") + quota_link_header = page.select("h2 a")[0].get_text() + # Assert the first quota which exists in TAP appears as a URL + assert first_quota_order_number in quota_link_header + assert response.status_code == 200 + # Assert the remaining four quotas appear too + for order_number in order_number_batch[1:]: + assert f"Order Number {order_number.quota_order_number}" in str( + response.content, + ) diff --git a/reference_documents/tests/test_reference_documents_forms.py b/reference_documents/tests/test_reference_documents_forms.py new file mode 100644 index 000000000..a7800bc04 --- /dev/null +++ b/reference_documents/tests/test_reference_documents_forms.py @@ -0,0 +1,60 @@ +import pytest + +from reference_documents.forms.reference_document_forms import ( + ReferenceDocumentCreateUpdateForm, +) +from reference_documents.forms.reference_document_forms import ( + ReferenceDocumentDeleteForm, +) +from reference_documents.tests import factories + +pytestmark = pytest.mark.django_db + + +@pytest.mark.reference_documents +def test_ref_doc_create_update_form_valid_data(): + """Test that ReferenceDocumentCreateUpdateForm is valid when completed + correctly.""" + data = {"title": "Reference document for XY", "area_id": "XY"} + form = ReferenceDocumentCreateUpdateForm(data=data) + + assert form.is_valid() + + +@pytest.mark.reference_documents +def test_ref_doc_create_update_form_invalid_data(): + """Test that ReferenceDocumentCreateUpdateForm is invalid when not completed + correctly.""" + form = ReferenceDocumentCreateUpdateForm(data={}) + assert not form.is_valid() + assert "A reference document title is required" in form.errors["title"] + assert "An area ID is required" in form.errors["area_id"] + + factories.ReferenceDocumentFactory.create( + title="Reference document for XY", + area_id="XY", + ) + data = {"title": "Reference document for XY", "area_id": "VWXYZ"} + form = ReferenceDocumentCreateUpdateForm(data=data) + assert not form.is_valid() + assert "Enter the area ID in the correct format" in form.errors["area_id"] + assert "A reference document with this title already exists" in form.errors["title"] + + +@pytest.mark.reference_documents +def test_ref_doc_delete_form_valid(): + """Test that ReferenceDocumentDeleteForm is valid for a reference document + with no versions.""" + ref_doc = factories.ReferenceDocumentFactory.create() + form = ReferenceDocumentDeleteForm(data={}, instance=ref_doc) + assert form.is_valid() + + +@pytest.mark.reference_documents +def test_ref_doc_delete_form_invalid(): + """Test that ReferenceDocumentDeleteForm is invalid for a reference document + with versions.""" + ref_doc = factories.ReferenceDocumentFactory.create() + factories.ReferenceDocumentVersionFactory(reference_document=ref_doc) + form = ReferenceDocumentDeleteForm(data={}, instance=ref_doc) + assert not form.is_valid() diff --git a/reference_documents/tests/test_reference_documents_models.py b/reference_documents/tests/test_reference_documents_models.py new file mode 100644 index 000000000..8faad368f --- /dev/null +++ b/reference_documents/tests/test_reference_documents_models.py @@ -0,0 +1,30 @@ +import pytest + +from common.tests.factories import GeographicalAreaFactory +from reference_documents.models import ReferenceDocument + +pytestmark = pytest.mark.django_db + + +@pytest.mark.reference_documents +class TestReferenceDocumentVersion: + def test_init(self): + target = ReferenceDocument() + + assert target.created_at is None + assert target.title == "" + assert target.area_id == "" + + def test_get_area_name_by_area_id_no_match_to_database(self): + target = ReferenceDocument() + + assert target.get_area_name_by_area_id() == " (unknown description)" + + def test_get_area_name_by_area_id_match_to_database(self): + GeographicalAreaFactory.create( + area_id="TEST", + description__description="test description", + ) + target = ReferenceDocument(area_id="TEST") + + assert target.get_area_name_by_area_id() == "test description" diff --git a/reference_documents/tests/test_reference_documents_views.py b/reference_documents/tests/test_reference_documents_views.py new file mode 100644 index 000000000..8bb50b131 --- /dev/null +++ b/reference_documents/tests/test_reference_documents_views.py @@ -0,0 +1,216 @@ +import pytest +from bs4 import BeautifulSoup +from django.contrib.auth.models import Permission +from django.urls import reverse + +from reference_documents.models import ReferenceDocument +from reference_documents.tests import factories + +pytestmark = pytest.mark.django_db + + +@pytest.mark.reference_documents +def test_ref_doc_create_creates_object_and_redirects(valid_user, client): + """Tests that posting the reference document create form adds the new + reference document to the database and redirects to the confirm-create + page.""" + valid_user.user_permissions.add( + Permission.objects.get(codename="add_referencedocument"), + ) + client.force_login(valid_user) + create_url = reverse("reference_documents:create") + form_data = { + "title": "Reference document for XY", + "area_id": "XY", + } + response = client.post(create_url, form_data) + assert response.status_code == 302 + + ref_doc = ReferenceDocument.objects.get(title=form_data["title"]) + assert ref_doc + assert response.url == reverse( + "reference_documents:confirm-create", + kwargs={"pk": ref_doc.pk}, + ) + + +@pytest.mark.reference_documents +def test_ref_doc_edit_updates_ref_doc_object(valid_user, client): + """Tests that posting the reference document edit form updates the reference + document and redirects to the confirm-update page.""" + valid_user.user_permissions.add( + Permission.objects.get(codename="change_referencedocument"), + ) + client.force_login(valid_user) + + ref_doc = factories.ReferenceDocumentFactory.create() + + edit_url = reverse( + "reference_documents:edit", + kwargs={"pk": ref_doc.pk}, + ) + + new_title = "Updated title for this reference document" + new_area_id = "XY" + form_data = { + "title": new_title, + "area_id": new_area_id, + } + + assert not ref_doc.title == new_title + assert not ref_doc.area_id == new_area_id + + response = client.get(edit_url) + assert response.status_code == 200 + + response = client.post(edit_url, form_data) + assert response.status_code == 302 + assert response.url == reverse( + "reference_documents:confirm-update", + kwargs={"pk": ref_doc.pk}, + ) + + ref_doc.refresh_from_db() + assert ref_doc.title == new_title + assert ref_doc.area_id == new_area_id + + +@pytest.mark.reference_documents +def test_successfully_delete_ref_doc(valid_user, client): + """Tests that posting the reference document delete form deletes the + reference document and redirects to the confirm-delete page.""" + valid_user.user_permissions.add( + Permission.objects.get(codename="delete_referencedocument"), + ) + client.force_login(valid_user) + + ref_doc = factories.ReferenceDocumentFactory.create(area_id="XY") + assert ReferenceDocument.objects.filter(pk=ref_doc.pk) + delete_url = reverse( + "reference_documents:delete", + kwargs={"pk": ref_doc.pk}, + ) + + response = client.get(delete_url) + page = BeautifulSoup(response.content, "html.parser") + assert response.status_code == 200 + assert ( + f"Delete reference document {ref_doc.area_id}" in page.select("main h1")[0].text + ) + + response = client.post(delete_url) + assert response.status_code == 302 + assert response.url == reverse( + "reference_documents:confirm-delete", + kwargs={"deleted_pk": ref_doc.pk}, + ) + assert not ReferenceDocument.objects.filter(pk=ref_doc.pk) + + response = client.get(response.url) + assert f"Reference document XY has been deleted" in str(response.content) + + +@pytest.mark.reference_documents +def test_delete_ref_doc_with_versions(valid_user, client): + """Test that deleting a reference document with versions does not work.""" + valid_user.user_permissions.add( + Permission.objects.get(codename="delete_referencedocument"), + ) + client.force_login(valid_user) + + ref_doc = factories.ReferenceDocumentFactory.create() + factories.ReferenceDocumentVersionFactory(reference_document=ref_doc) + + delete_url = reverse( + "reference_documents:delete", + kwargs={"pk": ref_doc.pk}, + ) + + response = client.get(delete_url) + assert response.status_code == 200 + + response = client.post(delete_url) + assert response.status_code == 200 + assert ( + f"Reference document {ref_doc.area_id} cannot be deleted as it has active versions." + in str(response.content) + ) + assert ReferenceDocument.objects.filter(pk=ref_doc.pk) + + +@pytest.mark.reference_documents +def test_ref_doc_crud_without_permission(valid_user_client): + # TODO: potentially update this if the permissions for reference doc behaviour changes + ref_doc = factories.ReferenceDocumentFactory.create() + create_url = reverse("reference_documents:create") + edit_url = reverse("reference_documents:edit", kwargs={"pk": ref_doc.pk}) + delete_url = reverse("reference_documents:delete", kwargs={"pk": ref_doc.pk}) + form_data = { + "title": "Reference document for XY", + "area_id": "XY", + } + response = valid_user_client.post(create_url, form_data) + assert response.status_code == 403 + response = valid_user_client.post(edit_url, form_data) + assert response.status_code == 403 + response = valid_user_client.post(delete_url) + assert response.status_code == 403 + + +@pytest.mark.reference_documents +def test_ref_doc_list_view(superuser_client): + """Test that the reference document list view renders correctly and shows + the correct version number.""" + area_ids_and_titles = { + "AD": "Andorra", + "AG": "Antigua and Barbuda", + "AL": "Albania", + "AT": "Austria", + "AU": "Australia", + } + for country in area_ids_and_titles.items(): + factories.ReferenceDocumentFactory.create(area_id=country[0], title=country[1]) + list_url = reverse("reference_documents:index") + response = superuser_client.get(list_url) + page = BeautifulSoup(response.content, "html.parser") + assert response.status_code == 200 + table_rows = page.select(".govuk-table tbody tr") + header = page.h1.get_text().strip() + assert "Reference documents" == header + assert len(table_rows) == 5 + # Test for ref docs with a version + ref_doc = ReferenceDocument.objects.first() + ref_doc_version = factories.ReferenceDocumentVersionFactory( + reference_document=ref_doc, + ) + response = superuser_client.get(list_url) + page = BeautifulSoup(response.content, "html.parser") + assert response.status_code == 200 + andorra_row = page.select("tr")[1] + latest_version_cell = andorra_row.select("td")[0] + assert str(ref_doc_version.version) in latest_version_cell + + +@pytest.mark.reference_documents +def test_ref_doc_detail_view(superuser_client): + """Test that the reference document detail view shows a row for each + reference document version.""" + ref_doc = factories.ReferenceDocumentFactory.create() + ref_doc_version_1 = factories.ReferenceDocumentVersionFactory( + reference_document=ref_doc, + version=1.0, + ) + ref_doc_version_2 = factories.ReferenceDocumentVersionFactory( + reference_document=ref_doc, + version=2.0, + ) + ref_doc_version_2 = factories.ReferenceDocumentVersionFactory( + reference_document=ref_doc, + version=3.0, + ) + details_url = reverse("reference_documents:details", kwargs={"pk": ref_doc.pk}) + response = superuser_client.get(details_url) + page = BeautifulSoup(response.content, "html.parser") + assert response.status_code == 200 + table_rows = page.select("tr") + assert len(table_rows) == 4 diff --git a/reference_documents/tests/test_views.py b/reference_documents/tests/test_views.py new file mode 100644 index 000000000..a7057c2cc --- /dev/null +++ b/reference_documents/tests/test_views.py @@ -0,0 +1,3 @@ +import pytest + +pytestmark = pytest.mark.django_db diff --git a/reference_documents/urls.py b/reference_documents/urls.py new file mode 100644 index 000000000..58fdeafac --- /dev/null +++ b/reference_documents/urls.py @@ -0,0 +1,232 @@ +from django.urls import path +from rest_framework import routers + +from reference_documents.views.alignment_report_views import AlignmentReportsDetailsView +from reference_documents.views.alignment_report_views import ( + ReferenceDocumentVersionAlignmentReportsDetailsView, +) +from reference_documents.views.example_views import ExampleReferenceDocumentsDetailView +from reference_documents.views.example_views import ExampleReferenceDocumentsListView +from reference_documents.views.preferential_quota_order_number_views import ( + PreferentialQuotaOrderNumberCreate, +) +from reference_documents.views.preferential_quota_order_number_views import ( + PreferentialQuotaOrderNumberDelete, +) +from reference_documents.views.preferential_quota_order_number_views import ( + PreferentialQuotaOrderNumberEdit, +) +from reference_documents.views.preferential_quota_views import ( + PreferentialQuotaBulkCreate, +) +from reference_documents.views.preferential_quota_views import PreferentialQuotaCreate +from reference_documents.views.preferential_quota_views import PreferentialQuotaDelete +from reference_documents.views.preferential_quota_views import PreferentialQuotaEdit +from reference_documents.views.preferential_rate_views import PreferentialRateCreate +from reference_documents.views.preferential_rate_views import PreferentialRateDelete +from reference_documents.views.preferential_rate_views import PreferentialRateEdit +from reference_documents.views.reference_document_version_views import ( + ReferenceDocumentVersionConfirmCreate, +) +from reference_documents.views.reference_document_version_views import ( + ReferenceDocumentVersionConfirmDelete, +) +from reference_documents.views.reference_document_version_views import ( + ReferenceDocumentVersionConfirmUpdate, +) +from reference_documents.views.reference_document_version_views import ( + ReferenceDocumentVersionCreate, +) +from reference_documents.views.reference_document_version_views import ( + ReferenceDocumentVersionDelete, +) +from reference_documents.views.reference_document_version_views import ( + ReferenceDocumentVersionDetails, +) +from reference_documents.views.reference_document_version_views import ( + ReferenceDocumentVersionEdit, +) +from reference_documents.views.reference_document_views import ( + ReferenceDocumentConfirmCreate, +) +from reference_documents.views.reference_document_views import ( + ReferenceDocumentConfirmDelete, +) +from reference_documents.views.reference_document_views import ( + ReferenceDocumentConfirmUpdate, +) +from reference_documents.views.reference_document_views import ReferenceDocumentCreate +from reference_documents.views.reference_document_views import ReferenceDocumentDelete +from reference_documents.views.reference_document_views import ReferenceDocumentDetails +from reference_documents.views.reference_document_views import ReferenceDocumentEdit +from reference_documents.views.reference_document_views import ReferenceDocumentList + +app_name = "reference_documents" + +api_router = routers.DefaultRouter() + +detail = "