diff --git a/api/applications/creators.py b/api/applications/creators.py index 89930f2519..346ab805e9 100644 --- a/api/applications/creators.py +++ b/api/applications/creators.py @@ -1,40 +1,6 @@ -from django.db.models import Q - -from api.applications.enums import ApplicationExportType, GoodsTypeCategory -from api.applications.models import ( - ApplicationDocument, - GoodOnApplication, - SiteOnApplication, - ExternalLocationOnApplication, - StandardApplication, -) -from api.cases.enums import CaseTypeSubTypeEnum +from api.applications.models import ApplicationDocument from api.core.helpers import str_to_bool -from api.goods.models import GoodDocument from lite_content.lite_api import strings -from api.parties.models import PartyDocument -from api.parties.enums import PartyType - - -def _validate_siel_locations(application, errors): - old_locations_invalid = ( - not SiteOnApplication.objects.filter(application=application).exists() - and not ExternalLocationOnApplication.objects.filter(application=application).exists() - and not getattr(application, "have_goods_departed", False) - and not getattr(application, "goodstype_category", None) == GoodsTypeCategory.CRYPTOGRAPHIC - ) - - new_locations_invalid = ( - not getattr(application, "export_type", False) - and not getattr(application, "goods_recipients", False) - and not getattr(application, "goods_starting_point", False) - and getattr(application, "is_shipped_waybill_or_lading") is None - ) - - if old_locations_invalid and new_locations_invalid: - errors["location"] = [strings.Applications.Generic.NO_LOCATION_SET] - - return errors def _get_document_errors(documents, processing_error, virus_error): @@ -49,154 +15,6 @@ def _get_document_errors(documents, processing_error, virus_error): return virus_error -def check_party_document(party, is_mandatory): - """ - Checks for existence of and status of document (if it is mandatory) and return any errors - """ - documents_qs = PartyDocument.objects.filter(party=party).values_list("safe", flat=True) - if not documents_qs.exists(): - # End-user document is mandatory but we are providing an option to not upload - # if there is a valid reason - if party.type == PartyType.END_USER and party.end_user_document_available is False: - return None - - if is_mandatory: - return getattr(strings.Applications.Standard, f"NO_{party.type.upper()}_DOCUMENT_SET") - else: - return None - - if None in documents_qs: - return build_document_processing_error_message( - get_document_type_description_from_party_type(party_type=party.type) - ) - elif False in documents_qs: - return getattr(strings.Applications.Standard, f"{party.type.upper()}_DOCUMENT_INFECTED") - - return None - - -def check_parties_documents(parties, is_mandatory=True): - """Check a given list of parties all have documents if is_mandatory. Also checks all documents are safe""" - - for poa in parties: - error = check_party_document(poa.party, is_mandatory) - if error: - return error - return None - - -def check_party_error(party, object_not_found_error, is_mandatory, is_document_mandatory=True): - """Check a given party exists and has a document if is_document_mandatory""" - - if is_mandatory and not party: - return object_not_found_error - elif party: - document_error = check_party_document(party, is_document_mandatory) - if document_error: - return document_error - - -def _validate_end_user(draft, errors, is_mandatory, open_application=False): - """Validates end user. If a document is mandatory, this is also validated.""" - - # Document is only mandatory if application is standard permanent or HMRC query - is_document_mandatory = ( - draft.case_type.sub_type == CaseTypeSubTypeEnum.STANDARD - and draft.export_type == ApplicationExportType.PERMANENT - ) or draft.case_type.sub_type == CaseTypeSubTypeEnum.HMRC - - end_user_errors = check_party_error( - draft.end_user.party if draft.end_user else None, - object_not_found_error=strings.Applications.Standard.NO_END_USER_SET, - is_mandatory=is_mandatory, - is_document_mandatory=is_document_mandatory, - ) - if end_user_errors: - errors["end_user"] = [end_user_errors] - - return errors - - -def _validate_consignee(draft, errors, is_mandatory): - """ - Checks there is an consignee if goods_recipients is set to VIA_CONSIGNEE or VIA_CONSIGNEE_AND_THIRD_PARTIES - (with a document if is_document_mandatory) - """ - # This logic includes old style applications where the goods_recipients field will be "" - if draft.goods_recipients != StandardApplication.DIRECT_TO_END_USER: - consignee_errors = check_party_error( - draft.consignee.party if draft.consignee else None, - object_not_found_error=strings.Applications.Standard.NO_CONSIGNEE_SET, - is_mandatory=is_mandatory, - is_document_mandatory=False, - ) - if consignee_errors: - errors["consignee"] = [consignee_errors] - return errors - - -def _validate_security_approvals(draft, errors, is_mandatory): - """Checks there are security approvals for the draft""" - if is_mandatory: - if draft.is_mod_security_approved is None: - errors["security_approvals"] = [ - "To submit the application, complete the 'Do you have a security approval?' section" - ] - return errors - - -def _validate_ultimate_end_users(draft, errors, is_mandatory): - """ - Checks all ultimate end users have documents if is_mandatory is True. - Also checks that at least one ultimate_end_user is present if there is an incorporated good - """ - # Document is always optional even if there are incorporated goods - ultimate_end_user_documents_error = check_parties_documents(draft.ultimate_end_users.all(), is_mandatory=False) - if ultimate_end_user_documents_error: - errors["ultimate_end_user_documents"] = [ultimate_end_user_documents_error] - - if is_mandatory: - ultimate_end_user_required = GoodOnApplication.objects.filter( - Q(application=draft), Q(is_good_incorporated=True) | Q(is_onward_incorporated=True) - ).exists() - - if ultimate_end_user_required: - if len(draft.ultimate_end_users.values_list()) == 0: - errors["ultimate_end_users"] = ["To submit the application, add an ultimate end-user"] - else: - # We make sure that an ultimate end user is not also the end user - for ultimate_end_user in draft.ultimate_end_users.values_list("id", flat=True): - if "end_user" not in errors and str(ultimate_end_user) == str(draft.end_user.party.id): - errors["ultimate_end_users"] = [ - "To submit the application, an ultimate end-user cannot be the same as the end user" - ] - - return errors - - -def _validate_end_use_details(draft, errors, application_type): - if application_type in [CaseTypeSubTypeEnum.STANDARD, CaseTypeSubTypeEnum.OPEN]: - if ( - draft.is_military_end_use_controls is None - or draft.is_informed_wmd is None - or draft.is_suspected_wmd is None - or not draft.intended_end_use - ) and not getattr(draft, "goodstype_category", None) == GoodsTypeCategory.CRYPTOGRAPHIC: - errors["end_use_details"] = [strings.Applications.Generic.NO_END_USE_DETAILS] - - if application_type == CaseTypeSubTypeEnum.STANDARD: - if draft.is_eu_military is None: - errors["end_use_details"] = [strings.Applications.Generic.NO_END_USE_DETAILS] - elif draft.is_eu_military and draft.is_compliant_limitations_eu is None: - errors["end_use_details"] = [strings.Applications.Generic.NO_END_USE_DETAILS] - - elif application_type == CaseTypeSubTypeEnum.F680: - if not draft.intended_end_use: - errors["end_use_details"] = [strings.Applications.Generic.NO_END_USE_DETAILS] - - return errors - - def _validate_agree_to_declaration(request, errors): """Checks the exporter has agreed to the T&Cs of the licence""" @@ -215,76 +33,6 @@ def _validate_agree_to_declaration(request, errors): return errors -def _validate_temporary_export_details(draft, errors): - if ( - draft.case_type.sub_type in [CaseTypeSubTypeEnum.STANDARD, CaseTypeSubTypeEnum.OPEN] - and draft.export_type == ApplicationExportType.TEMPORARY - ): - if not draft.temp_export_details or draft.is_temp_direct_control is None or draft.proposed_return_date is None: - errors["temporary_export_details"] = [strings.Applications.Generic.NO_TEMPORARY_EXPORT_DETAILS] - - return errors - - -def _validate_third_parties(draft, errors, is_mandatory): - """Checks all third parties have documents if is_mandatory is True""" - - third_parties_documents_error = check_parties_documents(draft.third_parties.all(), is_mandatory) - if third_parties_documents_error: - errors["third_parties_documents"] = [third_parties_documents_error] - - return errors - - -def _validate_goods(draft, errors, is_mandatory): - """Checks Goods""" - - goods_on_application = GoodOnApplication.objects.filter(application=draft) - - if is_mandatory: - if not goods_on_application: - errors["goods"] = [strings.Applications.Standard.NO_GOODS_SET] - - # Check goods documents - if goods_on_application.exists(): - goods = goods_on_application.values_list("good", flat=True) - document_errors = _get_document_errors( - GoodDocument.objects.filter(good__in=goods), - processing_error=build_document_processing_error_message("a good"), - virus_error=strings.Applications.Standard.GOODS_DOCUMENT_INFECTED, - ) - if document_errors: - errors["goods"] = [document_errors] - - return errors - - -def _validate_standard_licence(draft, errors): - """Checks that a standard licence has all party types & goods""" - - errors = _validate_siel_locations(draft, errors) - errors = _validate_end_user(draft, errors, is_mandatory=True) - errors = _validate_security_approvals(draft, errors, is_mandatory=True) - errors = _validate_consignee(draft, errors, is_mandatory=True) - errors = _validate_third_parties(draft, errors, is_mandatory=False) - errors = _validate_goods(draft, errors, is_mandatory=True) - errors = _validate_ultimate_end_users(draft, errors, is_mandatory=True) - errors = _validate_end_use_details(draft, errors, draft.case_type.sub_type) - errors = _validate_route_of_goods(draft, errors) - errors = _validate_temporary_export_details(draft, errors) - - return errors - - -def _validate_route_of_goods(draft, errors): - if ( - draft.is_shipped_waybill_or_lading is None - and not getattr(draft, "goodstype_category", None) == GoodsTypeCategory.CRYPTOGRAPHIC - ): - errors["route_of_goods"] = [strings.Applications.Generic.NO_ROUTE_OF_GOODS] - return errors - - def _validate_additional_documents(draft, errors): """Validate additional documents""" documents = ApplicationDocument.objects.filter(application=draft) @@ -305,12 +53,7 @@ def _validate_additional_documents(draft, errors): def validate_application_ready_for_submission(application): errors = {} - # Perform additional validation and append errors if found - if application.case_type.sub_type == CaseTypeSubTypeEnum.STANDARD: - _validate_standard_licence(application, errors) - else: - errors["unsupported_application"] = ["You can only validate a supported application type"] - + errors = application.validate() errors = _validate_additional_documents(application, errors) return errors @@ -318,13 +61,3 @@ def validate_application_ready_for_submission(application): def build_document_processing_error_message(document_type_description): return f"We are still processing {document_type_description} document. Try submitting again in a few minutes." - - -def get_document_type_description_from_party_type(party_type): - document_type_description = { - PartyType.CONSIGNEE: "a consignee", - PartyType.END_USER: "an end-user", - PartyType.THIRD_PARTY: "a third party", - PartyType.ULTIMATE_END_USER: "an ultimate end-user", - } - return document_type_description[party_type] diff --git a/api/applications/models.py b/api/applications/models.py index 4610d6f472..c78cb5a426 100644 --- a/api/applications/models.py +++ b/api/applications/models.py @@ -268,12 +268,17 @@ def set_appealed(self, appeal, exporter_user): self.set_sub_status(CaseSubStatusIdEnum.UNDER_APPEAL__APPEAL_RECEIVED) self.add_to_queue(Queue.objects.get(id=QueuesEnum.LU_APPEALS)) + def validate(self): + raise NotImplementedError("Validator to validate application attributes is not implemented") + def create_amendment(self, user): raise NotImplementedError() # Licence Applications class StandardApplication(BaseApplication, Clonable): + from api.applications.validators import StandardApplicationValidator + GB = "GB" NI = "NI" GOODS_STARTING_POINT_CHOICES = [ @@ -289,6 +294,7 @@ class StandardApplication(BaseApplication, Clonable): (VIA_CONSIGNEE, "To an end-user via a consignee"), (VIA_CONSIGNEE_AND_THIRD_PARTIES, "To an end-user via a consignee, with additional third parties"), ] + validator_class = StandardApplicationValidator export_type = models.TextField(choices=ApplicationExportType.choices, blank=True, default="") reference_number_on_information_form = models.CharField(blank=True, null=True, max_length=255) @@ -422,6 +428,10 @@ def create_amendment(self, user): self.case_ptr.change_status(system_user, get_case_status_by_status(CaseStatusEnum.SUPERSEDED_BY_EXPORTER_EDIT)) return amendment_application + def validate(self): + validator = self.validator_class(self) + return validator.validate() + class ApplicationDocument(Document, Clonable): application = models.ForeignKey(BaseApplication, on_delete=models.CASCADE) diff --git a/api/applications/tests/factories.py b/api/applications/tests/factories.py index 43423bafb3..cfbbbfc927 100644 --- a/api/applications/tests/factories.py +++ b/api/applications/tests/factories.py @@ -20,8 +20,8 @@ from api.documents.tests.factories import DocumentFactory from api.staticdata.statuses.models import CaseStatus from api.goods.tests.factories import GoodFactory -from api.organisations.tests.factories import OrganisationFactory, SiteFactory, ExternalLocationFactory from api.parties.tests.factories import ConsigneeFactory, EndUserFactory, PartyFactory, ThirdPartyFactory +from api.organisations.tests.factories import OrganisationFactory, SiteFactory, ExternalLocationFactory from api.users.tests.factories import ExporterUserFactory, GovUserFactory from api.staticdata.units.enums import Units from api.staticdata.control_list_entries.helpers import get_control_list_entry diff --git a/api/applications/tests/test_models.py b/api/applications/tests/test_models.py index ba797065cd..96d0f2f979 100644 --- a/api/applications/tests/test_models.py +++ b/api/applications/tests/test_models.py @@ -33,7 +33,7 @@ GoodOnApplicationFactory, PartyOnApplicationFactory, ) -from api.users.models import GovUser, ExporterUser +from api.users.models import BaseUser, GovUser, ExporterUser from api.goods.tests.factories import FirearmFactory from api.organisations.tests.factories import OrganisationFactory from api.staticdata.control_list_entries.models import ControlListEntry @@ -43,7 +43,6 @@ CaseStatusEnum, ) from api.users.enums import SystemUser -from api.users.models import ExporterUser from api.users.tests.factories import BaseUserFactory @@ -603,7 +602,8 @@ def test_clone_with_party_override(self): @pytest.mark.requires_transactions class TestStandardApplicationRaceConditions(TransactionTestCase): def test_create_amendment_race_condition_success(self): - BaseUserFactory(id=SystemUser.id) + if not BaseUser.objects.filter(id=SystemUser.id).exists(): + BaseUserFactory(id=SystemUser.id) original_application = StandardApplicationFactory() diff --git a/api/applications/tests/test_standard_application_submit.py b/api/applications/tests/test_standard_application_submit.py index 712d51e13a..249b04da69 100644 --- a/api/applications/tests/test_standard_application_submit.py +++ b/api/applications/tests/test_standard_application_submit.py @@ -7,6 +7,7 @@ from api.applications.enums import ApplicationExportType from api.applications.models import SiteOnApplication, GoodOnApplication, PartyOnApplication, StandardApplication +from api.applications.tests.factories import DraftStandardApplicationFactory from api.audit_trail.enums import AuditType from api.audit_trail.models import Audit from api.cases.enums import CaseTypeEnum, CaseDocumentState @@ -18,6 +19,7 @@ from lite_content.lite_api import strings from api.parties.enums import PartyType from api.parties.models import Party, PartyDocument +from api.parties.tests.factories import PartyDocumentFactory from api.staticdata.statuses.enums import CaseStatusEnum from api.staticdata.statuses.libraries.get_case_status import get_case_status_by_status from api.staticdata.trade_control.enums import TradeControlActivity, TradeControlProductCategory @@ -95,44 +97,45 @@ def test_submit_standard_application_with_no_new_or_old_location_info_failure(se status_code=status.HTTP_400_BAD_REQUEST, ) - def test_submit_standard_application_without_end_user_failure(self): - self.draft.delete_party(PartyOnApplication.objects.get(application=self.draft, party__type=PartyType.END_USER)) - - url = reverse("applications:application_submit", kwargs={"pk": self.draft.id}) - - response = self.client.put(url, **self.exporter_headers) - - self.assertContains( - response, - text=strings.Applications.Standard.NO_END_USER_SET, - status_code=status.HTTP_400_BAD_REQUEST, - ) - def test_submit_standard_application_without_end_user_document_success(self): - PartyDocument.objects.filter(party=self.draft.end_user.party).delete() - party = Party.objects.get(id=self.draft.end_user.party_id) + application = DraftStandardApplicationFactory(organisation=self.organisation) + party = Party.objects.get(id=application.end_user.party_id) party.end_user_document_available = False + party.end_user_document_missing_reason = "not applicable" party.save() - url = reverse("applications:application_submit", kwargs={"pk": self.draft.id}) + url = reverse("applications:application_submit", kwargs={"pk": application.id}) response = self.client.put(url, **self.exporter_headers) self.assertEqual(response.status_code, status.HTTP_200_OK) - def test_submit_standard_application_without_consignee_failure(self): - self.draft.delete_party(self.draft.consignee) - self.draft.goods_recipients = StandardApplication.VIA_CONSIGNEE - self.draft.save() + def test_submit_without_end_user_document_missing_reason_fail(self): + application = DraftStandardApplicationFactory(organisation=self.organisation) - url = reverse("applications:application_submit", kwargs={"pk": self.draft.id}) + url = reverse("applications:application_submit", kwargs={"pk": application.id}) response = self.client.put(url, **self.exporter_headers) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + errors = response.json()["errors"] + self.assertEqual(errors["end_user"][0], "To submit the application, attach a document to the end user") - self.assertContains( - response, - text=strings.Applications.Standard.NO_CONSIGNEE_SET, - status_code=status.HTTP_400_BAD_REQUEST, - ) + @parameterized.expand( + [ + (PartyType.END_USER, ["To submit the application, add an end user"]), + (PartyType.CONSIGNEE, ["To submit the application, add a consignee"]), + ] + ) + def test_submit_standard_application_without_party_failure(self, party_type, expected_errors): + application = DraftStandardApplicationFactory(organisation=self.organisation) + application.parties.filter(party__type=party_type).delete() + + url = reverse("applications:application_submit", kwargs={"pk": application.id}) + + response = self.client.put(url, **self.exporter_headers) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + errors = response.json()["errors"] + self.assertEqual(errors[party_type], expected_errors) def test_submit_standard_application_direct_end_user_without_consignee_success(self): self.draft.delete_party(self.draft.consignee) @@ -146,16 +149,16 @@ def test_submit_standard_application_direct_end_user_without_consignee_success(s self.assertEqual(response.status_code, status.HTTP_200_OK) def test_submit_standard_application_without_consignee_document_success(self): - # Consignee document is optional - PartyDocument.objects.filter(party=self.draft.consignee.party).delete() - url = reverse("applications:application_submit", kwargs={"pk": self.draft.id}) + application = DraftStandardApplicationFactory(organisation=self.organisation) + party_on_application = application.parties.filter(party__type=PartyType.END_USER).first() - response = self.client.put(url, **self.exporter_headers) + # only Consignee document is optional + PartyDocumentFactory(party=party_on_application.party, safe=True) - self.assertNotContains( - response, - text=strings.Applications.Standard.NO_CONSIGNEE_DOCUMENT_SET, - ) + url = reverse("applications:application_submit", kwargs={"pk": application.id}) + + response = self.client.put(url, **self.exporter_headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) def test_submit_standard_application_without_good_failure(self): GoodOnApplication.objects.get(application=self.draft).delete() @@ -227,6 +230,16 @@ def test_submit_draft_with_incorporated_good_and_without_ultimate_end_user_docum text="To submit the application, attach a document to the ultimate end-users", ) + def test_submit_draft_without_third_parties_success(self): + application = DraftStandardApplicationFactory(organisation=self.organisation) + party_on_application = application.parties.filter(party__type=PartyType.END_USER).first() + PartyDocumentFactory(party=party_on_application.party, safe=True) + + url = reverse("applications:application_submit", kwargs={"pk": application.id}) + + response = self.client.put(url, **self.exporter_headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) + def test_submit_draft_without_third_party_documents_success(self): third_party = PartyOnApplication.objects.get(application=self.draft, party__type=PartyType.THIRD_PARTY).party PartyDocument.objects.filter(party=third_party).delete() @@ -236,29 +249,65 @@ def test_submit_draft_without_third_party_documents_success(self): self.assertEqual(response.status_code, status.HTTP_200_OK) - def test_status_code_post_with_untested_document_failure(self): - draft = self.create_draft_standard_application(self.organisation, safe_document=None) - url = reverse("applications:application_submit", kwargs={"pk": draft.id}) - - response = self.client.put(url, **self.exporter_headers) - - self.assertContains( - response, - text="We are still processing an end-user document. Try submitting again in a few minutes.", - status_code=status.HTTP_400_BAD_REQUEST, - ) - - def test_status_code_post_with_infected_document_failure(self): - draft = self.create_draft_standard_application(self.organisation, safe_document=False) - url = reverse("applications:application_submit", kwargs={"pk": draft.id}) + @parameterized.expand( + [ + ( + StandardApplication.DIRECT_TO_END_USER, + PartyType.END_USER, + PartyType.END_USER, + None, + ["We're still processing the end user document. Please submit again"], + ), + ( + StandardApplication.DIRECT_TO_END_USER, + PartyType.END_USER, + PartyType.END_USER, + False, + ["To submit the application, attach a document that does not contain a virus to the end user"], + ), + ( + StandardApplication.VIA_CONSIGNEE, + PartyType.CONSIGNEE, + PartyType.CONSIGNEE, + None, + ["We're still processing the consignee document. Please submit again"], + ), + ( + StandardApplication.VIA_CONSIGNEE, + PartyType.CONSIGNEE, + PartyType.CONSIGNEE, + False, + ["To submit the application, attach a document that does not contain a virus to the consignee"], + ), + ( + StandardApplication.VIA_CONSIGNEE_AND_THIRD_PARTIES, + PartyType.THIRD_PARTY, + "third_parties_documents", + None, + ["We're still processing the third party document. Please submit again"], + ), + ( + StandardApplication.VIA_CONSIGNEE_AND_THIRD_PARTIES, + PartyType.THIRD_PARTY, + "third_parties_documents", + False, + ["To submit the application, attach a document that does not contain a virus to the third party"], + ), + ] + ) + def test_application_with_infected_party_document_failure( + self, recipients, party_type, field_name, safe_document, expected_errors + ): + application = DraftStandardApplicationFactory(organisation=self.organisation, goods_recipients=recipients) + party_on_application = application.parties.filter(party__type=party_type).first() + PartyDocumentFactory(party=party_on_application.party, safe=safe_document) + url = reverse("applications:application_submit", kwargs={"pk": application.id}) response = self.client.put(url, **self.exporter_headers) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + errors = response.json()["errors"] - self.assertContains( - response, - text=strings.Applications.Standard.END_USER_DOCUMENT_INFECTED, - status_code=status.HTTP_400_BAD_REQUEST, - ) + self.assertEqual(errors[field_name], expected_errors) def test_submit_standard_application_with_unprocessed_additional_documents_failure(self): self.create_application_document(self.draft, safe=None) diff --git a/api/applications/validators.py b/api/applications/validators.py new file mode 100644 index 0000000000..4760cfd3f4 --- /dev/null +++ b/api/applications/validators.py @@ -0,0 +1,177 @@ +from django.db.models import Q + +from api.applications.enums import ApplicationExportType +from api.core.model_mixins import BaseApplicationValidator +from api.goods.models import GoodDocument +from api.parties.models import PartyDocument + + +def siel_locations_validator(application): + from api.applications.models import SiteOnApplication + + export_type_choices = [item[0] for item in ApplicationExportType.choices] + starting_point_choices = [item[0] for item in application.GOODS_STARTING_POINT_CHOICES] + recipient_choices = [item[0] for item in application.GOODS_RECIPIENTS_CHOICES] + + if ( + not SiteOnApplication.objects.filter(application=application).exists() + and application.export_type not in export_type_choices + and application.goods_starting_point not in starting_point_choices + and application.goods_recipients not in recipient_choices + and application.is_shipped_waybill_or_lading is None + ): + return "To submit the application, add a product location" + + return None + + +def siel_end_user_validator(application): + error = None + if application.export_type == ApplicationExportType.TEMPORARY: + return None + + if not application.end_user: + return "To submit the application, add an end user" + + party = application.end_user.party + documents_qs = PartyDocument.objects.filter(party=party).values_list("safe", flat=True) + if documents_qs.exists(): + if None in documents_qs: + error = "We're still processing the end user document. Please submit again" + elif False in documents_qs: + error = "To submit the application, attach a document that does not contain a virus to the end user" + else: + if not party.end_user_document_available and not party.end_user_document_missing_reason: + error = "To submit the application, attach a document to the end user" + + return error + + +def siel_consignee_validator(application): + from api.applications.models import StandardApplication + + error = None + if application.goods_recipients not in [ + StandardApplication.VIA_CONSIGNEE, + StandardApplication.VIA_CONSIGNEE_AND_THIRD_PARTIES, + ]: + return error + + if not application.consignee: + return "To submit the application, add a consignee" + + party = application.consignee.party + documents_qs = PartyDocument.objects.filter(party=party).values_list("safe", flat=True) + if documents_qs.exists(): + if None in documents_qs: + error = "We're still processing the consignee document. Please submit again" + elif False in documents_qs: + error = "To submit the application, attach a document that does not contain a virus to the consignee" + + return error + + +def siel_third_parties_validator(application): + """If there are third parties and they added any documents check if they are all valid""" + error = None + + if application.third_parties.count() == 0: + return error + + for third_party_on_application in application.third_parties.all(): + party = third_party_on_application.party + documents_qs = PartyDocument.objects.filter(party=party).values_list("safe", flat=True) + if documents_qs.exists(): + if None in documents_qs: + return "We're still processing the third party document. Please submit again" + elif False in documents_qs: + return "To submit the application, attach a document that does not contain a virus to the third party" + + +def siel_ultimate_end_users_validator(application): + """If ultimate end users are required and they added any documents check if they are all valid""" + error = None + if not application.end_user: + return "To submit the application, add an end user" + + ultimate_end_user_required = application.goods.filter( + Q(is_good_incorporated=True) | Q(is_onward_incorporated=True) + ).exists() + + if ultimate_end_user_required and application.ultimate_end_users.count() == 0: + error = "To submit the application, add an ultimate end-user" + + return error + + +def siel_security_approvals_validator(application): + error = "To submit the application, complete the 'Do you have a security approval?' section" + + return error if application.is_mod_security_approved is None else None + + +def siel_goods_validator(application): + + if application.goods.count() == 0: + return "To submit the application, add a product" + + goods = application.goods.values_list("good", flat=True) + document_statuses = GoodDocument.objects.filter(good__in=goods).values_list("safe", flat=True) + + # If safe field value is None, then the document hasn't been virus scanned yet + if not all(item is not None for item in document_statuses): + return "We are still processing a good document. Try submitting again in a few minutes." + + # If safe is False, the file contains a virus + if not all(document_statuses): + return "To submit the application, attach a document that does not contain a virus to goods" + + +def siel_end_use_details_validator(application): + if ( + application.is_military_end_use_controls is None + or application.is_informed_wmd is None + or application.is_suspected_wmd is None + or application.is_eu_military is None + or not application.intended_end_use + or (application.is_eu_military and application.is_compliant_limitations_eu is None) + ): + return "To submit the application, complete the 'End use details' section" + + return None + + +def siel_route_of_goods_validator(application): + if application.is_shipped_waybill_or_lading is None: + return "To submit the application, complete the 'Route of products' section" + + return None + + +def siel_temporary_export_details_validator(application): + if application.export_type == ApplicationExportType.PERMANENT: + return None + + if ( + not application.temp_export_details + or application.is_temp_direct_control is None + or application.proposed_return_date is None + ): + return "To submit the application, add temporary export details" + + return None + + +class StandardApplicationValidator(BaseApplicationValidator): + config = { + "location": siel_locations_validator, + "end_user": siel_end_user_validator, + "consignee": siel_consignee_validator, + "third_parties_documents": siel_third_parties_validator, + "ultimate_end_user_documents": siel_ultimate_end_users_validator, + "security_approvals": siel_security_approvals_validator, + "goods": siel_goods_validator, + "end_use_details": siel_end_use_details_validator, + "route_of_goods": siel_route_of_goods_validator, + "temporary_export_details": siel_temporary_export_details_validator, + } diff --git a/api/core/model_mixins.py b/api/core/model_mixins.py index daa0af6fbf..e237a7bbe9 100644 --- a/api/core/model_mixins.py +++ b/api/core/model_mixins.py @@ -42,3 +42,20 @@ class Trackable: def get_history(self, field): raise NotImplementedError() + + +class BaseApplicationValidator: + config = {} + + def __init__(self, application): + self.application = application + + def validate(self): + all_errors = {} + for entity, func in self.config.items(): + error = func(self.application) + if error: + entity_errors = {entity: [error]} + all_errors = {**entity_errors, **all_errors} + + return all_errors diff --git a/api/data_workspace/v2/tests/bdd/conftest.py b/api/data_workspace/v2/tests/bdd/conftest.py index e052c8471c..4bf5c3e915 100644 --- a/api/data_workspace/v2/tests/bdd/conftest.py +++ b/api/data_workspace/v2/tests/bdd/conftest.py @@ -9,6 +9,7 @@ from django.urls import reverse from freezegun import freeze_time from moto import mock_aws +from operator import itemgetter from pytest_bdd import ( given, parsers, @@ -522,8 +523,8 @@ def check_rows(client, parse_table, unpage_data, table_name, rows): for row in parsed_rows[1:]: expected_data.append({key: value for key, value in zip(keys, row)}) expected_data = cast_to_types(expected_data, table_metadata["fields"]) - actual_data = sorted(actual_data, key=lambda item, keys=keys: item[keys[0]]) - expected_data = sorted(expected_data, key=lambda item, keys=keys: item[keys[0]]) + actual_data = sorted(actual_data, key=itemgetter(*keys)) + expected_data = sorted(expected_data, key=itemgetter(*keys)) assert actual_data == expected_data diff --git a/api/parties/tests/factories.py b/api/parties/tests/factories.py index 5b9683bc9b..005029cb0d 100644 --- a/api/parties/tests/factories.py +++ b/api/parties/tests/factories.py @@ -18,6 +18,8 @@ class Meta: class PartyDocumentFactory(factory.django.DjangoModelFactory): + s3_key = factory.Faker("name") + class Meta: model = PartyDocument