diff --git a/Pipfile b/Pipfile index 643386f919..5a5e27ee41 100644 --- a/Pipfile +++ b/Pipfile @@ -36,7 +36,7 @@ django-health-check = "~=3.18.1" django-model-utils = "~=4.3.1" django-sortedm2m = "~=3.1.1" django-staff-sso-client = "~=4.2.1" -djangorestframework = "~=3.14.0" +djangorestframework = "~=3.15.2" elasticsearch = "<7.14.0" markdown = "~=3.4.1" mohawk = "~=1.1.0" @@ -73,7 +73,9 @@ django-queryable-properties = "~=1.9.1" database-sanitizer = ">=1.1.0" django-reversion = ">=5.0.12" psycopg = "~=3.1.18" +django-log-formatter-asim = "~=0.0.5" dbt-copilot-python = "~=0.2.1" +dj-database-url = "~=2.2.0" [requires] python_version = "3.9" diff --git a/Pipfile.lock b/Pipfile.lock index 1073dee466..27cf2db737 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "a344bd39e24e797cace7e44c3c3104c72353921e5aa0f3675fcd7c2eedd0f7b9" + "sha256": "4f98561bdf3763491c38fdf9b46517823a54548fd1eb84ef79131de86c27b9b2" }, "pipfile-spec": 6, "requires": { @@ -43,11 +43,11 @@ }, "autopep8": { "hashes": [ - "sha256:5cfe45eb3bef8662f6a3c7e28b7c0310c7310d340074b7f0f28f9810b44b7ef4", - "sha256:b716efa70cbafbf4a2c9c5ec1cabfa037a68f9e30b04c74ffa5864dd49b8f7d2" + "sha256:8d6c87eba648fdcfc83e29b788910b8643171c395d9c4bcf115ece035b9c9dda", + "sha256:a203fe0fcad7939987422140ab17a930f684763bf7335bdb6709991dd7ef6c2d" ], "markers": "python_version >= '3.8'", - "version": "==2.3.0" + "version": "==2.3.1" }, "backcall": { "hashes": [ @@ -116,11 +116,12 @@ }, "certifi": { "hashes": [ - "sha256:3cd43f1c6fa7dedc5899d69d3ad0398fd018ad1a17fba83ddaf78aa46c747516", - "sha256:ddc6c8ce995e6987e7faf5e3f1b02b302836a0e5d98ece18392cb1a36c72ad56" + "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b", + "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90" ], + "index": "pypi", "markers": "python_version >= '3.6'", - "version": "==2024.6.2" + "version": "==2024.7.4" }, "cffi": { "hashes": [ @@ -180,6 +181,14 @@ "markers": "python_version >= '3.8'", "version": "==1.16.0" }, + "cfgv": { + "hashes": [ + "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", + "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560" + ], + "markers": "python_version >= '3.8'", + "version": "==3.4.0" + }, "charset-normalizer": { "hashes": [ "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", @@ -404,6 +413,21 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.2.14" }, + "distlib": { + "hashes": [ + "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784", + "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64" + ], + "version": "==0.3.8" + }, + "dj-database-url": { + "hashes": [ + "sha256:3e792567b0aa9a4884860af05fe2aa4968071ad351e033b6db632f97ac6db9de", + "sha256:9f9b05058ddf888f1e6f840048b8d705ff9395e3b52a07165daa3d8b9360551b" + ], + "index": "pypi", + "version": "==2.2.0" + }, "django": { "hashes": [ "sha256:837e3cf1f6c31347a1396a3f6b65688f2b4bb4a11c580dcb628b5afe527b68a5", @@ -475,12 +499,12 @@ }, "django-health-check": { "hashes": [ - "sha256:16f9c9186236cbc2858fa0d0ecc3566ba2ad2b72683e5678d0d58eb9e8bbba1a", - "sha256:21235120f8d756fa75ba430d0b0dbb04620fbd7bfac92ed6a0b911915ba38918" + "sha256:18b75daca4551c69a43f804f9e41e23f5f5fb9efd06cf6a313b3d5031bb87bd0", + "sha256:f5f58762b80bdf7b12fad724761993d6e83540f97e2c95c42978f187e452fa07" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==3.18.2" + "version": "==3.18.3" }, "django-ipware": { "hashes": [ @@ -490,6 +514,15 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "version": "==3.0.7" }, + "django-log-formatter-asim": { + "hashes": [ + "sha256:77501044786d8f5f249ed90f8eca0b7c79e57003519373dc2c69006e546daf3b", + "sha256:cfa38573db9c973d672b3d517a27994e8b1d794bbdebb8b5ddabe63be02c8af5" + ], + "index": "pypi", + "markers": "python_version >= '3.9' and python_version < '4'", + "version": "==0.0.5" + }, "django-log-formatter-ecs": { "hashes": [ "sha256:1e8731dd25a11ac64e789f19931e12fe7ef8ad1a172b7bceb2ea5cab185a583e", @@ -577,12 +610,12 @@ }, "djangorestframework": { "hashes": [ - "sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8", - "sha256:eb63f58c9f218e1a7d064d17a70751f528ed4e1d35547fdade9aaf4cd103fd08" + "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20", + "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad" ], "index": "pypi", - "markers": "python_version >= '3.6'", - "version": "==3.14.0" + "markers": "python_version >= '3.8'", + "version": "==3.15.2" }, "docopt": { "hashes": [ @@ -669,6 +702,14 @@ "markers": "python_version >= '3.8'", "version": "==23.2.1" }, + "filelock": { + "hashes": [ + "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb", + "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7" + ], + "markers": "python_version >= '3.8'", + "version": "==3.15.4" + }, "future": { "hashes": [ "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", @@ -873,6 +914,14 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==1.1" }, + "identify": { + "hashes": [ + "sha256:37d93f380f4de590500d9dba7db359d0d3da95ffe7f9de1753faa159e71e7dfa", + "sha256:e5e00f54165f9047fbebeb4a560f9acfb8af4c88232be60a488e9b68d122745d" + ], + "markers": "python_version >= '3.8'", + "version": "==2.5.36" + }, "idna": { "hashes": [ "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc", @@ -977,6 +1026,14 @@ ], "version": "==1.6" }, + "nodeenv": { + "hashes": [ + "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", + "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", + "version": "==1.9.1" + }, "notifications-python-client": { "hashes": [ "sha256:3193a74eba1a5d33aac1662e57068de9705eb63396eed4708d81a6bdbb07b5ee" @@ -1227,6 +1284,22 @@ "markers": "python_version >= '3.8'", "version": "==10.2.0" }, + "platformdirs": { + "hashes": [ + "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee", + "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3" + ], + "markers": "python_version >= '3.8'", + "version": "==4.2.2" + }, + "pre-commit": { + "hashes": [ + "sha256:8ca3ad567bc78a4972a3f1a477e94a79d4597e8140a6e0b651c5e33899c3654a", + "sha256:fae36fd1d7ad7d6a5a1c0b0d5adb2ed1a3bda5a21bf6c3e5372073d7a11cd4c5" + ], + "markers": "python_version >= '3.9'", + "version": "==3.7.1" + }, "prompt-toolkit": { "hashes": [ "sha256:0d7bfa67001d5e39d02c224b663abc33687405033a8c422d0d675a5a13361d10", @@ -1536,6 +1609,14 @@ "markers": "python_version >= '3.6'", "version": "==5.1.0" }, + "virtualenv": { + "hashes": [ + "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a", + "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589" + ], + "markers": "python_version >= '3.7'", + "version": "==20.26.3" + }, "wcwidth": { "hashes": [ "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", diff --git a/README.md b/README.md index 32849e102e..72a4209f7d 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ Service for handling backend calls in LITE. - Starting the service - In general you can use `docker-compose up --build` if you want to make sure new changes are included in the build +- Indexing search + - If this is something you require, you can run `make rebuild-search` to rebuild the search indexes using your local db. ### Known issues when running with Docker diff --git a/api/applications/libraries/application_helpers.py b/api/applications/libraries/application_helpers.py index 79251f2c1b..501d03e59e 100644 --- a/api/applications/libraries/application_helpers.py +++ b/api/applications/libraries/application_helpers.py @@ -1,7 +1,6 @@ from django.http import JsonResponse from rest_framework import status -from rest_framework.request import Request from rest_framework.exceptions import PermissionDenied from api.audit_trail import service as audit_trail_service @@ -38,6 +37,8 @@ def can_status_be_set_by_exporter_user(original_status: str, new_status: str) -> elif new_status == CaseStatusEnum.SURRENDERED: if original_status != CaseStatusEnum.FINALISED: return False + elif CaseStatusEnum.can_invoke_major_edit(original_status) and new_status == CaseStatusEnum.APPLICANT_EDITING: + return True elif CaseStatusEnum.is_read_only(original_status) or new_status != CaseStatusEnum.APPLICANT_EDITING: return False @@ -68,17 +69,23 @@ def can_status_be_set_by_gov_user(user: GovUser, original_status: str, new_statu return True -def create_submitted_audit(request: Request, application, old_status: str) -> None: +def create_submitted_audit(user, application, old_status: str, additional_payload=None) -> None: + if not additional_payload: + additional_payload = {} + + payload = { + "status": { + "new": CaseStatusEnum.RESUBMITTED if old_status != CaseStatusEnum.DRAFT else CaseStatusEnum.SUBMITTED, + "old": old_status, + }, + **additional_payload, + } + audit_trail_service.create( - actor=request.user, + actor=user, verb=AuditType.UPDATED_STATUS, target=application.get_case(), - payload={ - "status": { - "new": CaseStatusEnum.RESUBMITTED if old_status != CaseStatusEnum.DRAFT else CaseStatusEnum.SUBMITTED, - "old": old_status, - } - }, + payload=payload, ignore_case_status=True, send_notification=False, ) @@ -89,7 +96,6 @@ def check_user_can_set_status(request, application, data): Checks whether an user (internal/exporter) can set the requested status Returns error response if user cannot set the status, None otherwise """ - if hasattr(request.user, "exporteruser"): if get_request_user_organisation_id(request) != application.organisation.id: raise PermissionDenied() diff --git a/api/applications/libraries/get_applications.py b/api/applications/libraries/get_applications.py index bb6948c87a..2e7ea726af 100644 --- a/api/applications/libraries/get_applications.py +++ b/api/applications/libraries/get_applications.py @@ -47,7 +47,6 @@ def get_application(pk, organisation_id=None): "goods__regime_entries__subsection__regime", "goods__good__report_summary_prefix", "goods__good__report_summary_subject", - "goods__audit_trail", "goods__goodonapplicationdocument_set", "goods__goodonapplicationdocument_set__user", "goods__good_on_application_internal_documents", diff --git a/api/applications/managers.py b/api/applications/managers.py index 0367c87bb3..fdb2059dde 100644 --- a/api/applications/managers.py +++ b/api/applications/managers.py @@ -5,10 +5,14 @@ class BaseApplicationManager(InheritanceManager): - def drafts(self, organisation): + def drafts(self, organisation, sort_by): draft = get_case_status_by_status(CaseStatusEnum.DRAFT) - return self.get_queryset().filter(status=draft, organisation=organisation).order_by("-created_at") + return self.get_queryset().filter(status=draft, organisation=organisation).order_by(sort_by) - def submitted(self, organisation): + def submitted(self, organisation, sort_by): draft = get_case_status_by_status(CaseStatusEnum.DRAFT) - return self.get_queryset().filter(organisation=organisation).exclude(status=draft).order_by("-submitted_at") + return self.get_queryset().filter(organisation=organisation).exclude(status=draft).order_by(sort_by) + + def finalised(self, organisation, sort_by): + finalised = get_case_status_by_status(CaseStatusEnum.FINALISED) + return self.get_queryset().filter(status=finalised, organisation=organisation).order_by(sort_by) diff --git a/api/applications/models.py b/api/applications/models.py index a67b62886e..0af159f2dd 100644 --- a/api/applications/models.py +++ b/api/applications/models.py @@ -1,6 +1,5 @@ import uuid -from django.contrib.contenttypes.fields import GenericRelation from django.contrib.postgres.fields import ArrayField from django.db import models, transaction from django.utils import timezone @@ -17,7 +16,8 @@ from api.appeals.models import Appeal from api.applications.managers import BaseApplicationManager from api.applications.validators import StandardApplicationValidator -from api.audit_trail.models import Audit, AuditType +from api.applications.libraries.application_helpers import create_submitted_audit +from api.audit_trail.models import AuditType from api.audit_trail import service as audit_trail_service from api.cases.enums import CaseTypeEnum from api.cases.models import Case, CaseQueue @@ -43,7 +43,8 @@ from api.staticdata.statuses.libraries.get_case_status import get_case_status_by_status from api.staticdata.trade_control.enums import TradeControlProductCategory, TradeControlActivity from api.staticdata.units.enums import Units -from api.users.models import ExporterUser, GovUser +from api.users.enums import SystemUser +from api.users.models import ExporterUser, GovUser, BaseUser from lite_content.lite_api.strings import PartyErrors from lite_routing.routing_rules_internal.enums import QueuesEnum @@ -200,6 +201,20 @@ class BaseApplication(ApplicationPartyMixin, Case): class Meta: ordering = ["created_at"] + def on_submit(self, old_status): + additional_payload = {} + if self.amendment_of: + # Add an audit entry to the case that was superseded by this amendment + audit_trail_service.create_system_user_audit( + verb=AuditType.EXPORTER_SUBMITTED_AMENDMENT, + target=self.amendment_of, + payload={ + "amendment": {"reference_code": self.reference_code}, + }, + ) + additional_payload["amendment_of"] = {"reference_code": self.amendment_of.reference_code} + create_submitted_audit(self.submitted_by, self, old_status, additional_payload) + def add_to_queue(self, queue): case = self.get_case() @@ -252,7 +267,7 @@ def set_appealed(self, appeal, exporter_user): def validate(self): raise NotImplementedError("Validator to validate application attributes is not implemented") - def create_amendment(self): + def create_amendment(self, user): raise NotImplementedError() @@ -364,13 +379,23 @@ def clone(self, exclusions=None, **overrides): return cloned_application @transaction.atomic - def create_amendment(self): + def create_amendment(self, user): amendment_application = self.clone(amendment_of=self) - # TODO: Do we need a log on the audit trail? - # Remove case from all queues and set status to superseded CaseQueue.objects.filter(case=self.case_ptr).delete() - self.status = get_case_status_by_status(CaseStatusEnum.SUPERSEDED_BY_AMENDMENT) - self.save() + audit_trail_service.create( + actor=user, + verb=AuditType.EXPORTER_CREATED_AMENDMENT, + target=self.get_case(), + payload={}, + ) + audit_trail_service.create_system_user_audit( + verb=AuditType.AMENDMENT_CREATED, + target=amendment_application.case_ptr, + payload={"superseded_case": {"reference_code": self.reference_code}}, + ignore_case_status=True, + ) + system_user = BaseUser.objects.get(id=SystemUser.id) + self.case_ptr.change_status(system_user, get_case_status_by_status(CaseStatusEnum.SUPERSEDED_BY_AMENDMENT)) return amendment_application def validate(self): @@ -521,12 +546,6 @@ class GoodOnApplication(AbstractGoodOnApplication, Clonable): # Exhibition applications are the only applications that contain the following as such may be null item_type = models.CharField(choices=ItemType.choices, max_length=10, null=True, blank=True, default=None) other_item_type = models.CharField(max_length=100, null=True, blank=True, default=None) - audit_trail = GenericRelation( - Audit, - related_query_name="good_on_application", - content_type_field="action_object_content_type", - object_id_field="action_object_object_id", - ) control_list_entries = models.ManyToManyField(ControlListEntry, through=GoodOnApplicationControlListEntry) regime_entries = models.ManyToManyField(RegimeEntry, through=GoodOnApplicationRegimeEntry) diff --git a/api/applications/serializers/generic_application.py b/api/applications/serializers/generic_application.py index 486383206d..e55fe8a85c 100644 --- a/api/applications/serializers/generic_application.py +++ b/api/applications/serializers/generic_application.py @@ -44,6 +44,7 @@ class GenericApplicationListSerializer(serializers.Serializer): name = serializers.CharField() case_type = TinyCaseTypeSerializer() status = CaseStatusField() + submitted_at = serializers.DateTimeField() updated_at = serializers.DateTimeField() reference_code = serializers.CharField() export_type = serializers.SerializerMethodField() diff --git a/api/applications/serializers/good.py b/api/applications/serializers/good.py index dfe8f88a0b..8f673b46e9 100644 --- a/api/applications/serializers/good.py +++ b/api/applications/serializers/good.py @@ -2,6 +2,7 @@ from rest_framework.fields import DecimalField, ChoiceField, BooleanField from rest_framework.relations import PrimaryKeyRelatedField +from django.contrib.contenttypes.models import ContentType from django.forms.models import model_to_dict from api.applications.models import ( @@ -15,6 +16,7 @@ ) from api.audit_trail import service as audit_trail_service from api.audit_trail.enums import AuditType +from api.audit_trail.models import Audit from api.audit_trail.serializers import AuditSerializer from api.applications.enums import NSGListType from api.cases.enums import CaseTypeEnum @@ -153,7 +155,13 @@ def get_audit_trail(self, instance): # this serializer is used by a few views. Most views do not need to know audit trail if not self.context.get("include_audit_trail"): return [] - return AuditSerializer(instance.audit_trail.all(), many=True).data + action_object_content_type = ContentType.objects.get_for_model(GoodOnApplication) + action_object_object_id = instance.pk + audits = Audit.objects.filter( + action_object_content_type=action_object_content_type, + action_object_object_id=action_object_object_id, + ) + return AuditSerializer(audits, many=True).data def update(self, instance, validated_data): if "firearm_details" in validated_data: diff --git a/api/applications/tests/factories.py b/api/applications/tests/factories.py index 7ee4b35911..0226876074 100644 --- a/api/applications/tests/factories.py +++ b/api/applications/tests/factories.py @@ -21,6 +21,7 @@ from api.goods.tests.factories import GoodFactory from api.parties.tests.factories import ConsigneeFactory, EndUserFactory, PartyFactory, ThirdPartyFactory from api.organisations.tests.factories import OrganisationFactory, SiteFactory, ExternalLocationFactory +from api.parties.tests.factories import ConsigneeFactory, EndUserFactory, PartyFactory, ThirdPartyFactory from api.users.tests.factories import ExporterUserFactory, GovUserFactory from api.staticdata.control_list_entries.helpers import get_control_list_entry from api.staticdata.regimes.helpers import get_regime_entry diff --git a/api/applications/tests/test_adding_sites.py b/api/applications/tests/test_adding_sites.py index 73a018bf91..42e0879990 100644 --- a/api/applications/tests/test_adding_sites.py +++ b/api/applications/tests/test_adding_sites.py @@ -97,8 +97,7 @@ def test_adding_site_to_draft_deletes_external_locations(self): self.assertEqual(SiteOnApplication.objects.filter(application=draft).count(), 1) self.assertEqual(ExternalLocationOnApplication.objects.filter(application=draft).count(), 0) - def test_add_site_to_a_submitted_application_success(self): - + def test_add_site_to_a_submitted_application_failure(self): site_to_add = SiteFactory(organisation=self.organisation, address=AddressFactoryGB()) data = {"sites": [self.primary_site.id, site_to_add.id]} @@ -107,10 +106,10 @@ def test_add_site_to_a_submitted_application_success(self): response = self.client.post(self.url, data, **self.exporter_headers) self.application.refresh_from_db() - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(SiteOnApplication.objects.filter(application=self.application).count(), 2) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(SiteOnApplication.objects.filter(application=self.application).count(), 1) - def test_add_site_to_a_submitted_application_failure(self): + def test_add_site_to_a_submitted_application_failure_different_country(self): """ Cannot add additional site to a submitted application unless the additional site is located in a country that is already on the application diff --git a/api/applications/tests/test_application_status.py b/api/applications/tests/test_application_status.py index 22338df129..bbbb35a235 100644 --- a/api/applications/tests/test_application_status.py +++ b/api/applications/tests/test_application_status.py @@ -59,6 +59,8 @@ def test_set_application_status_on_application_not_in_users_organisation_failure @mock.patch("api.applications.views.applications.notify_exporter_case_opened_for_editing") def test_exporter_set_application_status_applicant_editing_when_in_editable_status_success(self, mock_notify): self.submit_application(self.standard_application) + self.standard_application.status = get_case_status_by_status(CaseStatusEnum.REOPENED_FOR_CHANGES) + self.standard_application.save() data = {"status": CaseStatusEnum.APPLICANT_EDITING} response = self.client.put(self.url, data=data, **self.exporter_headers) @@ -70,7 +72,8 @@ def test_exporter_set_application_status_applicant_editing_when_in_editable_stat audit_event = Audit.objects.first() self.assertEqual(audit_event.verb, AuditType.UPDATED_STATUS) self.assertEqual( - audit_event.payload, {"status": {"new": CaseStatusEnum.APPLICANT_EDITING, "old": CaseStatusEnum.SUBMITTED}} + audit_event.payload, + {"status": {"new": CaseStatusEnum.APPLICANT_EDITING, "old": CaseStatusEnum.REOPENED_FOR_CHANGES}}, ) mock_notify.assert_called_with(self.standard_application) @@ -420,7 +423,9 @@ def test_hmrc_user_set_status_to_finalised_fail(self, mock_authenticate): self.standard_application.save() self.assertEqual(self.standard_application.status, get_case_status_by_status(CaseStatusEnum.UNDER_FINAL_REVIEW)) - base_user = BaseUser(email="test@mail.com", first_name="John", last_name="Smith", type=UserType.SYSTEM) + base_user = BaseUser( + email="test@mail.com", first_name="John", last_name="Smith", type=UserType.SYSTEM # /PS-IGNORE + ) base_user.team = Team.objects.get(id=TeamIdEnum.LICENSING_UNIT) base_user.save() diff --git a/api/applications/tests/test_audit_trail.py b/api/applications/tests/test_audit_trail.py new file mode 100644 index 0000000000..0565fb44a0 --- /dev/null +++ b/api/applications/tests/test_audit_trail.py @@ -0,0 +1,22 @@ +from api.audit_trail.models import Audit +from api.audit_trail.tests.factories import AuditFactory + +from test_helpers.clients import DataTestClient + + +class GoodOnApplicationAuditTrailTests(DataTestClient): + def test_removing_object_keeps_audit_trail(self): + application = self.create_draft_standard_application(self.organisation) + Audit.objects.all().delete() + + good_on_application = application.goods.first() + audit = AuditFactory( + action_object=good_on_application, + ) + self.assertEqual(Audit.objects.count(), 1) + + good_on_application.delete() + + self.assertEqual(Audit.objects.count(), 1) + audit = Audit.objects.get(pk=audit.pk) + self.assertIsNone(audit.action_object) diff --git a/api/applications/tests/test_external_locations.py b/api/applications/tests/test_external_locations.py index a661bfe7a0..f9eb4fd380 100644 --- a/api/applications/tests/test_external_locations.py +++ b/api/applications/tests/test_external_locations.py @@ -43,7 +43,7 @@ def test_adding_external_location_to_unsubmitted_application_removes_sites(self) 1, ) - def test_add_external_location_to_a_submitted_application_success(self): + def test_add_external_location_to_a_submitted_application_failure(self): SiteOnApplication.objects.filter(application=self.application).delete() ExternalLocationOnApplication(application=self.application, external_location=self.external_location).save() external_location_to_add = self.create_external_location("storage facility 2", self.organisation) @@ -58,13 +58,13 @@ def test_add_external_location_to_a_submitted_application_success(self): response = self.client.post(self.url, data, **self.exporter_headers) self.application.refresh_from_db() - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( ExternalLocationOnApplication.objects.filter(application=self.application).count(), - 2, + 1, ) - def test_add_external_location_to_a_submitted_application_failure(self): + def test_add_external_location_already_on_application_to_a_submitted_application_failure(self): """ Cannot add additional external locations to a submitted application unless the additional external location is located in a country that is already on the application diff --git a/api/applications/tests/test_matching_denials.py b/api/applications/tests/test_matching_denials.py index 0a59a460ca..7d1c6dba62 100644 --- a/api/applications/tests/test_matching_denials.py +++ b/api/applications/tests/test_matching_denials.py @@ -7,6 +7,8 @@ from api.applications.tests.factories import DenialEntityFactory from api.external_data import models +from api.staticdata.statuses.enums import CaseStatusEnum +from api.staticdata.statuses.libraries.get_case_status import get_case_status_by_status from test_helpers.clients import DataTestClient @@ -14,12 +16,17 @@ class ApplicationDenialMatchesOnApplicationTests(DataTestClient): def setUp(self): super().setUp() self.application = self.create_standard_application_case(self.organisation) + self.application.status = get_case_status_by_status(CaseStatusEnum.INITIAL_CHECKS) + self.application.save() file_path = os.path.join(settings.BASE_DIR, "external_data/tests/denial_valid.csv") with open(file_path, "rb") as f: content = f.read() + f.seek(0) + self.total_denials = len(f.readlines()) - 1 + response = self.client.post(reverse("external_data:denial-list"), {"csv_file": content}, **self.gov_headers) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(models.DenialEntity.objects.count(), 5) + self.assertEqual(models.DenialEntity.objects.count(), self.total_denials) @pytest.mark.xfail(reason="This test is flaky and should be rewritten") # Occasionally causes this error: @@ -45,7 +52,7 @@ def test_adding_denials_to_application(self): def test_revoke_denial_without_comment_failure(self): response = self.client.get(reverse("external_data:denial-list"), **self.gov_headers) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.json()["count"], 5) + self.assertEqual(response.json()["count"], self.total_denials) denials = response.json()["results"] @@ -62,7 +69,7 @@ def test_revoke_denial_without_comment_failure(self): def test_revoke_denial_success(self): response = self.client.get(reverse("external_data:denial-list"), **self.gov_headers) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.json()["count"], 5) + self.assertEqual(response.json()["count"], self.total_denials) denials = response.json()["results"] @@ -82,7 +89,7 @@ def test_revoke_denial_success(self): def test_revoke_denial_active_success(self): response = self.client.get(reverse("external_data:denial-list"), **self.gov_headers) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.json()["count"], 5) + self.assertEqual(response.json()["count"], self.total_denials) denials = response.json()["results"] diff --git a/api/applications/tests/test_models.py b/api/applications/tests/test_models.py index 75d5fd5ea4..5d41fa7974 100644 --- a/api/applications/tests/test_models.py +++ b/api/applications/tests/test_models.py @@ -1,14 +1,13 @@ from django.forms import model_to_dict from django.utils import timezone -from freezegun import freeze_time from test_helpers.clients import DataTestClient from api.appeals.tests.factories import AppealFactory +from api.audit_trail.models import Audit from api.cases.models import CaseType, Queue from api.flags.models import Flag from api.applications.models import ( - ExternalLocationOnApplication, GoodOnApplication, GoodOnApplicationDocument, GoodOnApplicationInternalDocument, @@ -30,9 +29,50 @@ from api.goods.tests.factories import FirearmFactory from api.organisations.tests.factories import OrganisationFactory from api.staticdata.control_list_entries.models import ControlListEntry -from api.staticdata.regimes.models import RegimeEntry from api.staticdata.report_summaries.models import ReportSummary, ReportSummaryPrefix, ReportSummarySubject from api.staticdata.statuses.models import CaseStatus, CaseSubStatus +from api.users.models import ExporterUser + + +class TestBaseApplication(DataTestClient): + + def test_on_submit_new_application(self): + draft_status = CaseStatus.objects.get(status="draft") + submitted_by = ExporterUser.objects.first() + # Use StandardApplication as BaseApplication is an abstract model + application = StandardApplicationFactory( + status=draft_status, + submitted_by=submitted_by, + ) + application.on_submit(draft_status.status) + submitted_audit_entry = Audit.objects.first() + assert submitted_audit_entry.verb == "updated_status" + assert submitted_audit_entry.payload == {"status": {"new": "submitted", "old": "draft"}} + assert submitted_audit_entry.actor == submitted_by + + def test_on_submit_amendment_application(self): + draft_status = CaseStatus.objects.get(status="draft") + submitted_by = ExporterUser.objects.first() + # Use StandardApplication as BaseApplication is an abstract model + original_application = StandardApplicationFactory() + amendment_application = StandardApplicationFactory( + status=draft_status, + submitted_by=submitted_by, + amendment_of=original_application.case_ptr, + ) + amendment_application.on_submit(draft_status.status) + audit_entries = Audit.objects.all() + submitted_audit_entry = audit_entries[0] + assert submitted_audit_entry.verb == "updated_status" + assert submitted_audit_entry.payload == { + "status": {"new": "submitted", "old": "draft"}, + "amendment_of": {"reference_code": original_application.reference_code}, + } + assert submitted_audit_entry.actor == submitted_by + amendment_audit_entry = audit_entries[1] + assert amendment_audit_entry.verb == "exporter_submitted_amendment" + assert amendment_audit_entry.target == original_application.case_ptr + assert amendment_audit_entry.payload == {"amendment": {"reference_code": amendment_application.reference_code}} class TestStandardApplication(DataTestClient): @@ -44,7 +84,8 @@ def test_create_amendment(self): original_application.queues.add(Queue.objects.first()) original_application.save() - amendment_application = original_application.create_amendment() + exporter_user = ExporterUser.objects.first() + amendment_application = original_application.create_amendment(exporter_user) # Ensure the amendment application has been saved to the DB - by retrieving it directly amendment_application = StandardApplication.objects.get(id=amendment_application.id) # It's unnecessary to be exhaustive in testing clone functionality as that is done below @@ -53,6 +94,20 @@ def test_create_amendment(self): original_application.refresh_from_db() assert original_application.status.status == "superseded_by_amendment" assert original_application.queues.all().count() == 0 + audit_entries = Audit.objects.all() + supersede_audit_entry = audit_entries[1] + assert supersede_audit_entry.payload == { + "superseded_case": {"reference_code": original_application.reference_code} + } + assert supersede_audit_entry.verb == "amendment_created" + assert supersede_audit_entry.target == amendment_application.case_ptr + amendment_audit_entry = audit_entries[2] + assert amendment_audit_entry.payload == {} + assert amendment_audit_entry.verb == "exporter_created_amendment" + assert amendment_audit_entry.actor == exporter_user + status_change_audit_entry = audit_entries[0] + assert status_change_audit_entry.payload == {"status": {"new": "Superseded by amendment", "old": "ogd_advice"}} + assert status_change_audit_entry.verb == "updated_status" def test_clone(self): original_application = StandardApplicationFactory( diff --git a/api/applications/tests/test_removing_goods.py b/api/applications/tests/test_removing_goods.py index e56d2e5dd9..912c5845c1 100644 --- a/api/applications/tests/test_removing_goods.py +++ b/api/applications/tests/test_removing_goods.py @@ -10,7 +10,7 @@ from api.goods.enums import GoodStatus from api.goods.models import Good, FirearmGoodDetails from api.users.models import UserOrganisationRelationship -from api.staticdata.statuses.libraries.get_case_status import get_case_status_by_status +from api.staticdata.statuses.enums import CaseStatusEnum from api.staticdata.units.enums import Units from test_helpers.clients import DataTestClient from api.users.libraries.user_to_token import user_to_token @@ -26,10 +26,7 @@ def test_remove_a_good_from_draft_success(self): Then the good_on_application is deleted And the good status is changed to DRAFT """ - draft = self.create_draft_standard_application(self.organisation) - application = self.submit_application( - draft - ) # This will submit the application and set the good status to SUBMITTED + application = self.create_draft_standard_application(self.organisation) good_on_application = application.goods.first() url = reverse( @@ -40,7 +37,7 @@ def test_remove_a_good_from_draft_success(self): response = self.client.delete(url, **self.exporter_headers) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(GoodOnApplication.objects.filter(application=draft).count(), 0) + self.assertEqual(GoodOnApplication.objects.filter(application=application).count(), 0) self.assertEqual(good_on_application.good.status, GoodStatus.DRAFT) def test_remove_a_good_from_draft_success_when_good_is_verified(self): @@ -52,8 +49,7 @@ def test_remove_a_good_from_draft_success_when_good_is_verified(self): Then the good_on_application is deleted And the good status is not changed """ - draft = self.create_draft_standard_application(self.organisation) - application = self.submit_application(draft) + application = self.create_draft_standard_application(self.organisation) good_on_application = application.goods.first() good_on_application.good.status = GoodStatus.VERIFIED good_on_application.good.save() @@ -66,7 +62,7 @@ def test_remove_a_good_from_draft_success_when_good_is_verified(self): response = self.client.delete(url, **self.exporter_headers) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(GoodOnApplication.objects.filter(application=draft).count(), 0) + self.assertEqual(GoodOnApplication.objects.filter(application=application).count(), 0) self.assertEqual(good_on_application.good.status, GoodStatus.VERIFIED) def test_remove_a_good_from_application_success_when_good_is_on_multiple_applications( @@ -82,6 +78,7 @@ def test_remove_a_good_from_application_success_when_good_is_on_multiple_applica """ application1 = self.create_draft_standard_application(self.organisation) self.submit_application(application1) + self.set_application_status(application1, CaseStatusEnum.APPLICANT_EDITING) good_on_application1 = GoodOnApplication.objects.get(application=application1) application2 = self.create_draft_standard_application(self.organisation) @@ -130,8 +127,7 @@ def test_remove_a_good_that_does_not_exist_from_draft(self): self.assertEqual(GoodOnApplication.objects.filter(application=draft).count(), 1) def test_remove_a_good_from_draft_as_gov_user_failure(self): - draft = self.create_draft_standard_application(self.organisation) - application = self.submit_application(draft, self.exporter_user) + application = self.create_draft_standard_application(self.organisation) good_on_application = application.goods.first() url = reverse( @@ -145,8 +141,7 @@ def test_remove_a_good_from_draft_as_gov_user_failure(self): self.assertEqual(GoodOnApplication.objects.filter(application=application).count(), 1) def test_remove_goods_from_application_not_in_users_organisation_failure(self): - draft = self.create_draft_standard_application(self.organisation) - application = self.submit_application(draft) + application = self.create_draft_standard_application(self.organisation) good_on_application = application.goods.first() url = reverse( "applications:good_on_application", @@ -167,8 +162,7 @@ def test_remove_goods_from_application_not_in_users_organisation_failure(self): @parameterized.expand(get_case_statuses(read_only=False)) def test_delete_good_from_application_in_an_editable_status_success(self, editable_status): application = self.create_draft_standard_application(self.organisation) - application.status = get_case_status_by_status(editable_status) - application.save() + self.set_application_status(application, editable_status) good_on_application = application.goods.first() url = reverse( "applications:good_on_application", @@ -183,8 +177,7 @@ def test_delete_good_from_application_in_an_editable_status_success(self, editab @parameterized.expand(get_case_statuses(read_only=True)) def test_delete_good_from_application_in_read_only_status_failure(self, read_only_status): application = self.create_draft_standard_application(self.organisation) - application.status = get_case_status_by_status(read_only_status) - application.save() + self.set_application_status(application, read_only_status) good_on_application = application.goods.first() url = reverse( "applications:good_on_application", diff --git a/api/applications/tests/test_view_application.py b/api/applications/tests/test_view_application.py index e1e17d962f..2dc44f0dc3 100644 --- a/api/applications/tests/test_view_application.py +++ b/api/applications/tests/test_view_application.py @@ -1,29 +1,38 @@ from uuid import UUID +import datetime +from django.utils import timezone from django.urls import reverse +from api.cases.tests.factories import FinalAdviceFactory +from api.licences.enums import LicenceStatus +from api.licences.tests.factories import StandardLicenceFactory +from api.staticdata.decisions.models import Decision +from api.staticdata.statuses.models import CaseStatus from parameterized import parameterized from rest_framework import status from api.applications.models import GoodOnApplication, SiteOnApplication -from api.cases.enums import CaseTypeEnum +from api.applications.tests.factories import DraftStandardApplicationFactory +from api.cases.enums import AdviceType, CaseTypeEnum from api.organisations.tests.factories import SiteFactory from api.staticdata.statuses.enums import CaseStatusEnum from api.staticdata.trade_control.enums import TradeControlActivity, TradeControlProductCategory from test_helpers.clients import DataTestClient from api.users.libraries.get_user import get_user_organisation_relationship +from api.core.constants import GovPermissions class DraftTests(DataTestClient): def setUp(self): super().setUp() - self.url = reverse("applications:applications") + "?submitted=false" + self.url = reverse("applications:applications") + "?sort_by=-created_at&selected_filter=draft_applications" def test_view_draft_standard_application_list_as_exporter_success(self): """ Ensure we can get a list of drafts. """ self.exporter_user.set_role(self.organisation, self.exporter_super_user_role) - standard_application = self.create_draft_standard_application(self.organisation) + standard_application = DraftStandardApplicationFactory(organisation=self.organisation) response = self.client.get(self.url, **self.exporter_headers) response_data = response.json()["results"] @@ -203,3 +212,77 @@ def test_organisation_has_existing_applications(self): response = self.client.get(url, **self.exporter_headers) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json()["applications"], True) + + def test_view_finalised_applications(self): + url = reverse("applications:applications") + response = self.client.get(url, **self.exporter_headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()["count"], 0) + + self.exporter_user.set_role(self.organisation, self.exporter_super_user_role) + application = self.create_draft_standard_application(self.organisation) + + self.submit_application(application) + url = reverse("applications:applications") + "?sort_by=submitted_at" + response = self.client.get(url, **self.exporter_headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()["count"], 1) + + application_finalised = self.create_standard_application_case(self.organisation) + FinalAdviceFactory(user=self.gov_user, case=application_finalised, type=AdviceType.APPROVE) + template = self.create_letter_template( + name="Template", + case_types=[CaseTypeEnum.SIEL.id], + decisions=[Decision.objects.get(name=AdviceType.APPROVE)], + ) + + self.gov_user.role.permissions.set([GovPermissions.MANAGE_LICENCE_FINAL_ADVICE.name]) + licence = StandardLicenceFactory(case=application_finalised, status=LicenceStatus.DRAFT) + self.create_generated_case_document( + application_finalised, template, advice_type=AdviceType.APPROVE, licence=licence + ) + + finalised_url = reverse("cases:finalise", kwargs={"pk": application_finalised.id}) + response = self.client.put(finalised_url, data={}, **self.gov_headers) + + application.submitted_at = timezone.make_aware(datetime.datetime(2020, 6, 20, 12, 0)) + application.save() + application_finalised.refresh_from_db() + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(application_finalised.status, CaseStatus.objects.get(status=CaseStatusEnum.FINALISED)) + + response = self.client.get(url, **self.exporter_headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()["count"], 2) + + data = response.json() + submitted_dates = [ + datetime.datetime.fromisoformat(item["submitted_at"].rstrip("Z").replace("Z", "+00:00")) + for item in data["results"] + ] + assert all( + submitted_dates[i] <= submitted_dates[i + 1] for i in range(len(submitted_dates) - 1) + ), "Dates are not in ascending order." + + url = reverse("applications:applications") + "?sort_by=-updated_at" + response = self.client.get(url, **self.exporter_headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json() + updated_dates = [ + datetime.datetime.fromisoformat(item["updated_at"].rstrip("Z").replace("Z", "+00:00")) + for item in data["results"] + ] + assert all( + updated_dates[i] >= updated_dates[i + 1] for i in range(len(updated_dates) - 1) + ), "Dates are not in descending order." + + url = reverse("applications:applications") + "?selected_filter=finalised_applications" + response = self.client.get(url, **self.exporter_headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()["count"], 1) + self.assertEqual( + response.json()["results"][0]["status"]["value"], + "Finalised", + ) diff --git a/api/applications/views/amendments.py b/api/applications/views/amendments.py index fc83b44d78..ca0b3f14d1 100644 --- a/api/applications/views/amendments.py +++ b/api/applications/views/amendments.py @@ -35,7 +35,7 @@ def get_organisation(self): return self.application.organisation def perform_create(self, serializer): - self.amendment_application = self.application.create_amendment() + self.amendment_application = self.application.create_amendment(self.request.user) def create(self, request, *args, **kwargs): super().create(request, *args, **kwargs) diff --git a/api/applications/views/applications.py b/api/applications/views/applications.py index 369454b1b3..965e34c3fa 100644 --- a/api/applications/views/applications.py +++ b/api/applications/views/applications.py @@ -31,7 +31,6 @@ auto_match_sanctions, ) from api.applications.libraries.application_helpers import ( - optional_str_to_bool, can_status_be_set_by_gov_user, create_submitted_audit, check_user_can_set_status, @@ -70,7 +69,8 @@ from api.core.authentication import ExporterAuthentication, SharedAuthentication, GovAuthentication from api.core.constants import ExporterPermissions, GovPermissions, AutoGeneratedDocuments from api.core.decorators import ( - application_in_state, + application_is_editable, + application_is_major_editable, authorised_to_view_application, allowed_application_types, ) @@ -112,19 +112,17 @@ def get_queryset(self): """ Filter applications on submitted """ - try: - submitted = optional_str_to_bool(self.request.GET.get("submitted")) - except ValueError: - return BaseApplication.objects.none() - organisation = get_request_user_organisation(self.request) - if submitted is None: - applications = BaseApplication.objects.filter(organisation=organisation) - elif submitted: - applications = BaseApplication.objects.submitted(organisation) + selected_filter = self.request.GET.get("selected_filter", "submitted_applications") + sort_by = self.request.GET.get("sort_by", "-submitted_at") + + if selected_filter == "submitted_applications": + applications = BaseApplication.objects.submitted(organisation, sort_by) + elif selected_filter == "finalised_applications": + applications = BaseApplication.objects.finalised(organisation, sort_by) else: - applications = BaseApplication.objects.drafts(organisation) + applications = BaseApplication.objects.drafts(organisation, sort_by) users_sites = Site.objects.get_by_user_and_organisation(self.request.user.exporteruser, organisation) disallowed_applications = SiteOnApplication.objects.exclude(site__id__in=users_sites).values_list( @@ -226,7 +224,7 @@ def get(self, request, pk): return JsonResponse(data=data, status=status.HTTP_200_OK) @authorised_to_view_application(ExporterUser) - @application_in_state(is_editable=True) + @application_is_editable def put(self, request, pk): """ Update an application instance @@ -299,7 +297,7 @@ class ApplicationSubmission(APIView): authentication_classes = (ExporterAuthentication,) @transaction.atomic - @application_in_state(is_major_editable=True) + @application_is_major_editable @authorised_to_view_application(ExporterUser) def put(self, request, pk): """ @@ -357,10 +355,10 @@ def put(self, request, pk): if application.case_type.sub_type == CaseTypeSubTypeEnum.STANDARD: set_case_flags_on_submitted_standard_application(application) + application.on_submit(old_status) add_goods_flags_to_submitted_application(application) apply_flagging_rules_to_case(application) - create_submitted_audit(request, application, old_status) auto_generate_case_document( "application_form", application, @@ -849,7 +847,7 @@ class ApplicationRouteOfGoods(UpdateAPIView): authentication_classes = (ExporterAuthentication,) @authorised_to_view_application(ExporterUser) - @application_in_state(is_major_editable=True) + @application_is_major_editable @allowed_application_types([CaseTypeSubTypeEnum.OPEN, CaseTypeSubTypeEnum.STANDARD]) def put(self, request, pk): """Update an application instance with route of goods data.""" diff --git a/api/applications/views/denials.py b/api/applications/views/denials.py index 1d50e6634c..181787ec35 100644 --- a/api/applications/views/denials.py +++ b/api/applications/views/denials.py @@ -3,10 +3,10 @@ from rest_framework import status from rest_framework.views import APIView -from api.applications.libraries.case_status_helpers import get_case_statuses from api.applications.models import BaseApplication, DenialMatchOnApplication from api.applications.serializers import denial from api.core.authentication import GovAuthentication +from api.staticdata.statuses.enums import CaseStatusEnum from lite_content.lite_api import strings @@ -25,7 +25,6 @@ def get(self, request, pk): return JsonResponse(data={"denial_matches": denial_matches_data}) def post(self, request, pk): - serializer = denial.DenialMatchOnApplicationCreateSerializer(data=request.data, many=True) if serializer.is_valid(): serializer.save() @@ -36,7 +35,7 @@ def post(self, request, pk): def delete(self, request, pk): application = get_object_or_404(BaseApplication.objects.all(), pk=pk) - if application.status.status in get_case_statuses(read_only=True): + if application.status.status != CaseStatusEnum.INITIAL_CHECKS: return JsonResponse( data={"errors": [strings.Applications.Generic.READ_ONLY]}, status=status.HTTP_400_BAD_REQUEST, diff --git a/api/applications/views/documents.py b/api/applications/views/documents.py index e392473c22..ba74ba9f4d 100644 --- a/api/applications/views/documents.py +++ b/api/applications/views/documents.py @@ -12,7 +12,8 @@ from api.core.decorators import ( authorised_to_view_application, allowed_application_types, - application_in_state, + application_is_editable, + application_is_major_editable, ) from api.goodstype.helpers import get_goods_type from api.users.models import ExporterUser @@ -37,7 +38,7 @@ def get(self, request, pk): @transaction.atomic @authorised_to_view_application(ExporterUser) - @application_in_state(is_editable=True) + @application_is_editable def post(self, request, pk): """ Upload additional document onto an application @@ -62,7 +63,7 @@ def get(self, request, pk, doc_pk): @transaction.atomic @authorised_to_view_application(ExporterUser) - @application_in_state(is_editable=True) + @application_is_editable def delete(self, request, pk, doc_pk): """ Delete an additional document on an application @@ -86,7 +87,7 @@ def get(self, request, pk, goods_type_pk): @transaction.atomic @allowed_application_types([CaseTypeSubTypeEnum.HMRC]) - @application_in_state(is_major_editable=True) + @application_is_major_editable @authorised_to_view_application(ExporterUser) def post(self, request, pk, goods_type_pk): goods_type = get_goods_type(goods_type_pk) diff --git a/api/applications/views/end_use_details.py b/api/applications/views/end_use_details.py index c9a40ad79f..3eea09a8bb 100644 --- a/api/applications/views/end_use_details.py +++ b/api/applications/views/end_use_details.py @@ -6,7 +6,10 @@ from api.applications.libraries.edit_applications import save_and_audit_end_use_details from api.applications.libraries.get_applications import get_application from api.core.authentication import ExporterAuthentication -from api.core.decorators import authorised_to_view_application, application_in_state +from api.core.decorators import ( + authorised_to_view_application, + application_is_major_editable, +) from api.users.models import ExporterUser @@ -14,7 +17,7 @@ class EndUseDetails(UpdateAPIView): authentication_classes = (ExporterAuthentication,) @authorised_to_view_application(ExporterUser) - @application_in_state(is_major_editable=True) + @application_is_major_editable def put(self, request, pk): application = get_application(pk) serializer = get_application_end_use_details_update_serializer(application) diff --git a/api/applications/views/external_locations.py b/api/applications/views/external_locations.py index 7a80b490eb..2ffd72cae3 100644 --- a/api/applications/views/external_locations.py +++ b/api/applications/views/external_locations.py @@ -12,7 +12,10 @@ from api.audit_trail.enums import AuditType from api.cases.enums import CaseTypeEnum from api.core.authentication import ExporterAuthentication -from api.core.decorators import authorised_to_view_application, application_in_state +from api.core.decorators import ( + authorised_to_view_application, + application_is_editable, +) from lite_content.lite_api.strings import ExternalLocations from api.organisations.enums import LocationType from api.organisations.libraries.get_external_location import get_location @@ -44,7 +47,7 @@ def get(self, request, pk): @transaction.atomic @authorised_to_view_application(ExporterUser) - @application_in_state(is_editable=True) + @application_is_editable def post(self, request, pk): application = get_application(pk) data = request.data diff --git a/api/applications/views/goods.py b/api/applications/views/goods.py index 806a999f3c..936cc519da 100644 --- a/api/applications/views/goods.py +++ b/api/applications/views/goods.py @@ -23,7 +23,7 @@ from api.core.decorators import ( authorised_to_view_application, allowed_application_types, - application_in_state, + application_is_major_editable, ) from api.core.exceptions import BadRequestError from api.flags.enums import SystemFlags @@ -67,7 +67,7 @@ def get(self, request, pk): CaseTypeSubTypeEnum.F680, ] ) - @application_in_state(is_major_editable=True) + @application_is_major_editable @authorised_to_view_application(ExporterUser) def post(self, request, pk): data = request.data @@ -271,7 +271,7 @@ def get(self, request, pk, good_pk): return JsonResponse({"documents": serializer.data}, status=status.HTTP_200_OK) - @application_in_state(is_major_editable=True) + @application_is_major_editable @authorised_to_view_application(ExporterUser) def post(self, request, pk, good_pk): data = request.data @@ -300,7 +300,7 @@ def post(self, request, pk, good_pk): delete_uploaded_document(data) return JsonResponse({"errors": serializer.errors}, status=status.HTTP_400_BAD_REQUEST) - @application_in_state(is_major_editable=True) + @application_is_major_editable @authorised_to_view_application(ExporterUser) def delete(self, request, pk, good_pk): delete_uploaded_document(request.data) diff --git a/api/applications/views/parties.py b/api/applications/views/parties.py index f16d8b400b..86d9c8b9fc 100644 --- a/api/applications/views/parties.py +++ b/api/applications/views/parties.py @@ -10,7 +10,7 @@ from api.core.decorators import ( authorised_to_view_application, allowed_party_type_for_open_application_goodstype_category, - application_in_state, + application_is_major_editable, ) from api.core.helpers import str_to_bool from lite_content.lite_api import strings @@ -19,6 +19,7 @@ from api.parties.models import Party from api.parties.serializers import PartySerializer from api.users.models import ExporterUser +from api.staticdata.statuses.enums import CaseStatusEnum class ApplicationPartyView(APIView): @@ -36,7 +37,7 @@ def party(self): @allowed_party_type_for_open_application_goodstype_category() @authorised_to_view_application(ExporterUser) - @application_in_state(is_major_editable=True) + @application_is_major_editable def post(self, request, pk): """ Add a party to an application @@ -136,6 +137,14 @@ def get(self, request, **kwargs): @authorised_to_view_application(ExporterUser) def put(self, request, **kwargs): + # Check if the case is in draft or applicant_editing status + case_status = self.application.get_case().status.status + if case_status not in [CaseStatusEnum.APPLICANT_EDITING, CaseStatusEnum.DRAFT]: + return JsonResponse( + data={"errors": [f"The {self.party.type} party cannot be edited in {case_status} status"]}, + status=status.HTTP_403_FORBIDDEN, + ) + serializer = PartySerializer(instance=self.party, data=request.data, partial=True) if not serializer.is_valid(): return JsonResponse(data={"errors": serializer.errors}, status=status.HTTP_400_BAD_REQUEST) diff --git a/api/applications/views/temporary_export_details.py b/api/applications/views/temporary_export_details.py index 84c9da4186..2d2172085d 100644 --- a/api/applications/views/temporary_export_details.py +++ b/api/applications/views/temporary_export_details.py @@ -12,7 +12,7 @@ from api.core.decorators import ( authorised_to_view_application, allowed_application_types, - application_in_state, + application_is_major_editable, ) from api.users.models import ExporterUser @@ -22,7 +22,7 @@ class TemporaryExportDetails(UpdateAPIView): @authorised_to_view_application(ExporterUser) @allowed_application_types([CaseTypeSubTypeEnum.OPEN, CaseTypeSubTypeEnum.STANDARD]) - @application_in_state(is_major_editable=True) + @application_is_major_editable def put(self, request, pk): application = get_application(pk) if application.export_type == ApplicationExportType.PERMANENT: diff --git a/api/applications/views/tests/test_parties.py b/api/applications/views/tests/test_parties.py new file mode 100644 index 0000000000..d1b20f3e8f --- /dev/null +++ b/api/applications/views/tests/test_parties.py @@ -0,0 +1,54 @@ +from django.urls import reverse +from rest_framework import status + +from api.applications.tests.factories import ( + StandardApplicationFactory, + PartyOnApplicationFactory, +) +from api.staticdata.statuses.models import CaseStatus +from test_helpers.clients import DataTestClient + + +class TestApplicationPartyView(DataTestClient): + + def setUp(self): + super().setUp() + self.application = StandardApplicationFactory(organisation=self.organisation) + self.case_statuses = [ + case_status.status + for case_status in CaseStatus.objects.all() + if case_status.status not in ["draft", "applicant_editing"] + ] + self.data = {"name": "End user", "address": "1 Example Street", "country": {"id": "FR", "name": "France"}} + self.party_on_application = PartyOnApplicationFactory(application=self.application) + self.url = reverse( + "applications:party", + kwargs={"pk": str(self.application.pk), "party_pk": str(self.party_on_application.party.pk)}, + ) + + def test_draft_application_can_update_party_detail(self): + self.application.status = CaseStatus.objects.get(status="draft") + self.application.save() + response = self.client.put(self.url, **self.exporter_headers, data=self.data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_editing_application_can_update_party_detail(self): + self.application.status = CaseStatus.objects.get(status="applicant_editing") + self.application.save() + response = self.client.put(self.url, **self.exporter_headers, data=self.data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_other_status_cannot_update_party_detail(self): + for case_status in self.case_statuses: + self.application.status = CaseStatus.objects.get(status=case_status) + self.application.save() + response = self.client.put(self.url, **self.exporter_headers, data=self.data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual( + response.json(), + { + "errors": [ + f"The {self.party_on_application.party.type} party cannot be edited in {case_status} status" + ] + }, + ) diff --git a/api/assessments/tests/test_views.py b/api/assessments/tests/test_views.py index 48f4eaab14..913cf5bb17 100644 --- a/api/assessments/tests/test_views.py +++ b/api/assessments/tests/test_views.py @@ -216,6 +216,11 @@ def test_valid_data_updates_single_record(self): "report_summary": good_on_application.report_summary, } + # ensure verified good cannot be edited + url = reverse("goods:good", kwargs={"pk": good.id}) + response = self.client.put(url, {"is_archived": True}, **self.exporter_headers) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + def test_making_a_good_uncontrolled_clears_report_fields(self): # Setting is_good_controlled to False should set report_summary_prefix and report_summary_subject to None good_on_application = self.good_on_application diff --git a/api/audit_trail/enums.py b/api/audit_trail/enums.py index be5c78fc39..e77d2af671 100644 --- a/api/audit_trail/enums.py +++ b/api/audit_trail/enums.py @@ -141,6 +141,9 @@ class AuditType(LiteEnum): LU_CREATE_MEETING_NOTE = autostr() CREATE_REFUSAL_CRITERIA = autostr() EXPORTER_APPEALED_REFUSAL = autostr() + EXPORTER_CREATED_AMENDMENT = autostr() + EXPORTER_SUBMITTED_AMENDMENT = autostr() + AMENDMENT_CREATED = autostr() def human_readable(self): """ diff --git a/api/audit_trail/formatters.py b/api/audit_trail/formatters.py index f705bab928..1eebbc9c4a 100644 --- a/api/audit_trail/formatters.py +++ b/api/audit_trail/formatters.py @@ -176,6 +176,8 @@ def upload_party_document(**payload): def get_updated_status(**payload): status = payload.get("status", "").lower() if status == CaseStatusEnum.SUBMITTED: + if payload.get("amendment_of"): + return f"submitted a 'major edit' to case {payload['amendment_of']['reference_code']}." return "applied for a licence." if status == CaseStatusEnum.RESUBMITTED: return "reapplied for a licence." @@ -339,3 +341,11 @@ def create_lu_meeting_note(advice_type, **payload): def create_refusal_criteria(**payload): return " added refusal criteria." + + +def exporter_submitted_amendment(**payload): + return f"created a new case for the edited application at {payload['amendment']['reference_code']}." + + +def amendment_created(**payload): + return f"created the case to supersede {payload['superseded_case']['reference_code']}." diff --git a/api/audit_trail/migrations/0023_alter_audit_verb.py b/api/audit_trail/migrations/0023_alter_audit_verb.py new file mode 100644 index 0000000000..5a57ac9241 --- /dev/null +++ b/api/audit_trail/migrations/0023_alter_audit_verb.py @@ -0,0 +1,277 @@ +# Generated by Django 4.2.13 on 2024-06-18 13:55 + +import api.audit_trail.enums +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("audit_trail", "0022_alter_audit_verb"), + ] + + operations = [ + migrations.AlterField( + model_name="audit", + name="verb", + field=models.CharField( + choices=[ + (api.audit_trail.enums.AuditType["CREATED"], "created"), + (api.audit_trail.enums.AuditType["OGL_CREATED"], "ogl_created"), + (api.audit_trail.enums.AuditType["OGL_FIELD_EDITED"], "ogl_field_edited"), + (api.audit_trail.enums.AuditType["OGL_MULTI_FIELD_EDITED"], "ogl_multi_field_edited"), + (api.audit_trail.enums.AuditType["ADD_FLAGS"], "add_flags"), + (api.audit_trail.enums.AuditType["REMOVE_FLAGS"], "remove_flags"), + (api.audit_trail.enums.AuditType["GOOD_REVIEWED"], "good_reviewed"), + (api.audit_trail.enums.AuditType["GOOD_ADD_FLAGS"], "good_add_flags"), + (api.audit_trail.enums.AuditType["GOOD_REMOVE_FLAGS"], "good_remove_flags"), + (api.audit_trail.enums.AuditType["GOOD_ADD_REMOVE_FLAGS"], "good_add_remove_flags"), + (api.audit_trail.enums.AuditType["DESTINATION_ADD_FLAGS"], "destination_add_flags"), + (api.audit_trail.enums.AuditType["DESTINATION_REMOVE_FLAGS"], "destination_remove_flags"), + (api.audit_trail.enums.AuditType["ADD_GOOD_TO_APPLICATION"], "add_good_to_application"), + (api.audit_trail.enums.AuditType["REMOVE_GOOD_FROM_APPLICATION"], "remove_good_from_application"), + (api.audit_trail.enums.AuditType["ADD_GOOD_TYPE_TO_APPLICATION"], "add_good_type_to_application"), + ( + api.audit_trail.enums.AuditType["REMOVE_GOOD_TYPE_FROM_APPLICATION"], + "remove_good_type_from_application", + ), + ( + api.audit_trail.enums.AuditType["UPDATE_APPLICATION_END_USE_DETAIL"], + "update_application_end_use_detail", + ), + ( + api.audit_trail.enums.AuditType["UPDATE_APPLICATION_TEMPORARY_EXPORT"], + "update_application_temporary_export", + ), + ( + api.audit_trail.enums.AuditType["REMOVED_SITES_FROM_APPLICATION"], + "removed_sites_from_application", + ), + (api.audit_trail.enums.AuditType["ADD_SITES_TO_APPLICATION"], "add_sites_to_application"), + ( + api.audit_trail.enums.AuditType["REMOVED_EXTERNAL_LOCATIONS_FROM_APPLICATION"], + "removed_external_locations_from_application", + ), + ( + api.audit_trail.enums.AuditType["ADD_EXTERNAL_LOCATIONS_TO_APPLICATION"], + "add_external_locations_to_application", + ), + ( + api.audit_trail.enums.AuditType["REMOVED_COUNTRIES_FROM_APPLICATION"], + "removed_countries_from_application", + ), + (api.audit_trail.enums.AuditType["ADD_COUNTRIES_TO_APPLICATION"], "add_countries_to_application"), + ( + api.audit_trail.enums.AuditType["ADD_ADDITIONAL_CONTACT_TO_CASE"], + "add_additional_contact_to_case", + ), + (api.audit_trail.enums.AuditType["MOVE_CASE"], "move_case"), + (api.audit_trail.enums.AuditType["ASSIGN_CASE"], "assign_case"), + (api.audit_trail.enums.AuditType["ASSIGN_USER_TO_CASE"], "assign_user_to_case"), + (api.audit_trail.enums.AuditType["REMOVE_USER_FROM_CASE"], "remove_user_from_case"), + (api.audit_trail.enums.AuditType["REMOVE_CASE"], "remove_case"), + (api.audit_trail.enums.AuditType["REMOVE_CASE_FROM_ALL_QUEUES"], "remove_case_from_all_queues"), + ( + api.audit_trail.enums.AuditType["REMOVE_CASE_FROM_ALL_USER_ASSIGNMENTS"], + "remove_case_from_all_user_assignments", + ), + (api.audit_trail.enums.AuditType["CLC_RESPONSE"], "clc_response"), + (api.audit_trail.enums.AuditType["PV_GRADING_RESPONSE"], "pv_grading_response"), + (api.audit_trail.enums.AuditType["CREATED_CASE_NOTE"], "created_case_note"), + ( + api.audit_trail.enums.AuditType["CREATED_CASE_NOTE_WITH_MENTIONS"], + "created_case_note_with_mentions", + ), + (api.audit_trail.enums.AuditType["ECJU_QUERY"], "ecju_query"), + (api.audit_trail.enums.AuditType["ECJU_QUERY_RESPONSE"], "ecju_query_response"), + (api.audit_trail.enums.AuditType["ECJU_QUERY_MANUALLY_CLOSED"], "ecju_query_manually_closed"), + (api.audit_trail.enums.AuditType["UPDATED_STATUS"], "updated_status"), + (api.audit_trail.enums.AuditType["UPDATED_SUB_STATUS"], "updated_sub_status"), + (api.audit_trail.enums.AuditType["UPDATED_APPLICATION_NAME"], "updated_application_name"), + ( + api.audit_trail.enums.AuditType["UPDATE_APPLICATION_LETTER_REFERENCE"], + "update_application_letter_reference", + ), + ( + api.audit_trail.enums.AuditType["UPDATE_APPLICATION_F680_CLEARANCE_TYPES"], + "update_application_f680_clearance_types", + ), + ( + api.audit_trail.enums.AuditType["ADDED_APPLICATION_LETTER_REFERENCE"], + "added_application_letter_reference", + ), + ( + api.audit_trail.enums.AuditType["REMOVED_APPLICATION_LETTER_REFERENCE"], + "removed_application_letter_reference", + ), + (api.audit_trail.enums.AuditType["ASSIGNED_COUNTRIES_TO_GOOD"], "assigned_countries_to_good"), + (api.audit_trail.enums.AuditType["REMOVED_COUNTRIES_FROM_GOOD"], "removed_countries_from_good"), + (api.audit_trail.enums.AuditType["CREATED_FINAL_ADVICE"], "created_final_advice"), + (api.audit_trail.enums.AuditType["CLEARED_FINAL_ADVICE"], "cleared_final_advice"), + (api.audit_trail.enums.AuditType["CREATED_TEAM_ADVICE"], "created_team_advice"), + (api.audit_trail.enums.AuditType["CLEARED_TEAM_ADVICE"], "cleared_team_advice"), + (api.audit_trail.enums.AuditType["REVIEW_COMBINE_ADVICE"], "review_combine_advice"), + (api.audit_trail.enums.AuditType["CREATED_USER_ADVICE"], "created_user_advice"), + (api.audit_trail.enums.AuditType["CLEARED_USER_ADVICE"], "cleared_user_advice"), + (api.audit_trail.enums.AuditType["ADD_PARTY"], "add_party"), + (api.audit_trail.enums.AuditType["REMOVE_PARTY"], "remove_party"), + (api.audit_trail.enums.AuditType["UPLOAD_PARTY_DOCUMENT"], "upload_party_document"), + (api.audit_trail.enums.AuditType["DELETE_PARTY_DOCUMENT"], "delete_party_document"), + (api.audit_trail.enums.AuditType["UPLOAD_APPLICATION_DOCUMENT"], "upload_application_document"), + (api.audit_trail.enums.AuditType["DELETE_APPLICATION_DOCUMENT"], "delete_application_document"), + (api.audit_trail.enums.AuditType["UPLOAD_CASE_DOCUMENT"], "upload_case_document"), + (api.audit_trail.enums.AuditType["GENERATE_CASE_DOCUMENT"], "generate_case_document"), + (api.audit_trail.enums.AuditType["ADD_CASE_OFFICER_TO_CASE"], "add_case_officer_to_case"), + (api.audit_trail.enums.AuditType["REMOVE_CASE_OFFICER_FROM_CASE"], "remove_case_officer_from_case"), + (api.audit_trail.enums.AuditType["GRANTED_APPLICATION"], "granted_application"), + (api.audit_trail.enums.AuditType["REINSTATED_APPLICATION"], "reinstated_application"), + (api.audit_trail.enums.AuditType["FINALISED_APPLICATION"], "finalised_application"), + (api.audit_trail.enums.AuditType["UNASSIGNED_QUEUES"], "unassigned_queues"), + (api.audit_trail.enums.AuditType["UNASSIGNED"], "unassigned"), + (api.audit_trail.enums.AuditType["CREATED_DOCUMENT_TEMPLATE"], "created_document_template"), + (api.audit_trail.enums.AuditType["UPDATED_LETTER_TEMPLATE_NAME"], "updated_letter_template_name"), + ( + api.audit_trail.enums.AuditType["ADDED_LETTER_TEMPLATE_CASE_TYPES"], + "added_letter_template_case_types", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_LETTER_TEMPLATE_CASE_TYPES"], + "updated_letter_template_case_types", + ), + ( + api.audit_trail.enums.AuditType["REMOVED_LETTER_TEMPLATE_CASE_TYPES"], + "removed_letter_template_case_types", + ), + ( + api.audit_trail.enums.AuditType["ADDED_LETTER_TEMPLATE_DECISIONS"], + "added_letter_template_decisions", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_LETTER_TEMPLATE_DECISIONS"], + "updated_letter_template_decisions", + ), + ( + api.audit_trail.enums.AuditType["REMOVED_LETTER_TEMPLATE_DECISIONS"], + "removed_letter_template_decisions", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_LETTER_TEMPLATE_PARAGRAPHS"], + "updated_letter_template_paragraphs", + ), + ( + api.audit_trail.enums.AuditType["REMOVED_LETTER_TEMPLATE_PARAGRAPHS"], + "removed_letter_template_paragraphs", + ), + ( + api.audit_trail.enums.AuditType["ADDED_LETTER_TEMPLATE_PARAGRAPHS"], + "added_letter_template_paragraphs", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_LETTER_TEMPLATE_LAYOUT"], + "updated_letter_template_layout", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_LETTER_TEMPLATE_PARAGRAPHS_ORDERING"], + "updated_letter_template_paragraphs_ordering", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_LETTER_TEMPLATE_INCLUDE_DIGITAL_SIGNATURE"], + "updated_letter_template_include_digital_signature", + ), + (api.audit_trail.enums.AuditType["CREATED_PICKLIST"], "created_picklist"), + (api.audit_trail.enums.AuditType["UPDATED_PICKLIST_TEXT"], "updated_picklist_text"), + (api.audit_trail.enums.AuditType["UPDATED_PICKLIST_NAME"], "updated_picklist_name"), + (api.audit_trail.enums.AuditType["DEACTIVATE_PICKLIST"], "deactivate_picklist"), + (api.audit_trail.enums.AuditType["REACTIVATE_PICKLIST"], "reactivate_picklist"), + ( + api.audit_trail.enums.AuditType["UPDATED_EXHIBITION_DETAILS_TITLE"], + "updated_exhibition_details_title", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_EXHIBITION_DETAILS_START_DATE"], + "updated_exhibition_details_start_date", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_EXHIBITION_DETAILS_REQUIRED_BY_DATE"], + "updated_exhibition_details_required_by_date", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_EXHIBITION_DETAILS_REASON_FOR_CLEARANCE"], + "updated_exhibition_details_reason_for_clearance", + ), + (api.audit_trail.enums.AuditType["UPDATED_ROUTE_OF_GOODS"], "updated_route_of_goods"), + (api.audit_trail.enums.AuditType["UPDATED_ORGANISATION"], "updated_organisation"), + (api.audit_trail.enums.AuditType["CREATED_ORGANISATION"], "created_organisation"), + (api.audit_trail.enums.AuditType["REGISTER_ORGANISATION"], "register_organisation"), + (api.audit_trail.enums.AuditType["REJECTED_ORGANISATION"], "rejected_organisation"), + (api.audit_trail.enums.AuditType["APPROVED_ORGANISATION"], "approved_organisation"), + (api.audit_trail.enums.AuditType["REMOVED_FLAG_ON_ORGANISATION"], "removed_flag_on_organisation"), + (api.audit_trail.enums.AuditType["ADDED_FLAG_ON_ORGANISATION"], "added_flag_on_organisation"), + (api.audit_trail.enums.AuditType["RERUN_ROUTING_RULES"], "rerun_routing_rules"), + (api.audit_trail.enums.AuditType["ENFORCEMENT_CHECK"], "enforcement_check"), + (api.audit_trail.enums.AuditType["UPDATED_SITE"], "updated_site"), + (api.audit_trail.enums.AuditType["CREATED_SITE"], "created_site"), + (api.audit_trail.enums.AuditType["UPDATED_SITE_NAME"], "updated_site_name"), + (api.audit_trail.enums.AuditType["COMPLIANCE_SITE_CASE_CREATE"], "compliance_site_case_create"), + ( + api.audit_trail.enums.AuditType["COMPLIANCE_SITE_CASE_NEW_LICENCE"], + "compliance_site_case_new_licence", + ), + (api.audit_trail.enums.AuditType["ADDED_NEXT_REVIEW_DATE"], "added_next_review_date"), + (api.audit_trail.enums.AuditType["EDITED_NEXT_REVIEW_DATE"], "edited_next_review_date"), + (api.audit_trail.enums.AuditType["REMOVED_NEXT_REVIEW_DATE"], "removed_next_review_date"), + (api.audit_trail.enums.AuditType["COMPLIANCE_VISIT_CASE_CREATED"], "compliance_visit_case_created"), + (api.audit_trail.enums.AuditType["COMPLIANCE_VISIT_CASE_UPDATED"], "compliance_visit_case_updated"), + ( + api.audit_trail.enums.AuditType["COMPLIANCE_PEOPLE_PRESENT_CREATED"], + "compliance_people_present_created", + ), + ( + api.audit_trail.enums.AuditType["COMPLIANCE_PEOPLE_PRESENT_UPDATED"], + "compliance_people_present_updated", + ), + ( + api.audit_trail.enums.AuditType["COMPLIANCE_PEOPLE_PRESENT_DELETED"], + "compliance_people_present_deleted", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_GOOD_ON_DESTINATION_MATRIX"], + "updated_good_on_destination_matrix", + ), + (api.audit_trail.enums.AuditType["LICENCE_UPDATED_GOOD_USAGE"], "licence_updated_good_usage"), + (api.audit_trail.enums.AuditType["OGEL_REISSUED"], "ogel_reissued"), + (api.audit_trail.enums.AuditType["LICENCE_UPDATED_STATUS"], "licence_updated_status"), + ( + api.audit_trail.enums.AuditType["DOCUMENT_ON_ORGANISATION_CREATE"], + "document_on_organisation_create", + ), + ( + api.audit_trail.enums.AuditType["DOCUMENT_ON_ORGANISATION_DELETE"], + "document_on_organisation_delete", + ), + ( + api.audit_trail.enums.AuditType["DOCUMENT_ON_ORGANISATION_UPDATE"], + "document_on_organisation_update", + ), + (api.audit_trail.enums.AuditType["REPORT_SUMMARY_UPDATED"], "report_summary_updated"), + (api.audit_trail.enums.AuditType["COUNTERSIGN_ADVICE"], "countersign_advice"), + (api.audit_trail.enums.AuditType["UPDATED_SERIAL_NUMBERS"], "updated_serial_numbers"), + (api.audit_trail.enums.AuditType["PRODUCT_REVIEWED"], "product_reviewed"), + (api.audit_trail.enums.AuditType["LICENCE_UPDATED_PRODUCT_USAGE"], "licence_updated_product_usage"), + (api.audit_trail.enums.AuditType["CREATED_FINAL_RECOMMENDATION"], "created_final_recommendation"), + (api.audit_trail.enums.AuditType["GENERATE_DECISION_LETTER"], "generate_decision_letter"), + (api.audit_trail.enums.AuditType["DECISION_LETTER_SENT"], "decision_letter_sent"), + (api.audit_trail.enums.AuditType["LU_ADVICE"], "lu_advice"), + (api.audit_trail.enums.AuditType["LU_EDIT_ADVICE"], "lu_edit_advice"), + (api.audit_trail.enums.AuditType["LU_COUNTERSIGN"], "lu_countersign"), + (api.audit_trail.enums.AuditType["LU_EDIT_MEETING_NOTE"], "lu_edit_meeting_note"), + (api.audit_trail.enums.AuditType["LU_CREATE_MEETING_NOTE"], "lu_create_meeting_note"), + (api.audit_trail.enums.AuditType["CREATE_REFUSAL_CRITERIA"], "create_refusal_criteria"), + (api.audit_trail.enums.AuditType["EXPORTER_APPEALED_REFUSAL"], "exporter_appealed_refusal"), + (api.audit_trail.enums.AuditType["EXPORTER_CREATED_AMENDMENT"], "exporter_created_amendment"), + ], + db_index=True, + max_length=255, + ), + ), + ] diff --git a/api/audit_trail/migrations/0024_alter_audit_verb.py b/api/audit_trail/migrations/0024_alter_audit_verb.py new file mode 100644 index 0000000000..b27404b513 --- /dev/null +++ b/api/audit_trail/migrations/0024_alter_audit_verb.py @@ -0,0 +1,278 @@ +# Generated by Django 4.2.13 on 2024-06-18 15:18 + +import api.audit_trail.enums +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("audit_trail", "0023_alter_audit_verb"), + ] + + operations = [ + migrations.AlterField( + model_name="audit", + name="verb", + field=models.CharField( + choices=[ + (api.audit_trail.enums.AuditType["CREATED"], "created"), + (api.audit_trail.enums.AuditType["OGL_CREATED"], "ogl_created"), + (api.audit_trail.enums.AuditType["OGL_FIELD_EDITED"], "ogl_field_edited"), + (api.audit_trail.enums.AuditType["OGL_MULTI_FIELD_EDITED"], "ogl_multi_field_edited"), + (api.audit_trail.enums.AuditType["ADD_FLAGS"], "add_flags"), + (api.audit_trail.enums.AuditType["REMOVE_FLAGS"], "remove_flags"), + (api.audit_trail.enums.AuditType["GOOD_REVIEWED"], "good_reviewed"), + (api.audit_trail.enums.AuditType["GOOD_ADD_FLAGS"], "good_add_flags"), + (api.audit_trail.enums.AuditType["GOOD_REMOVE_FLAGS"], "good_remove_flags"), + (api.audit_trail.enums.AuditType["GOOD_ADD_REMOVE_FLAGS"], "good_add_remove_flags"), + (api.audit_trail.enums.AuditType["DESTINATION_ADD_FLAGS"], "destination_add_flags"), + (api.audit_trail.enums.AuditType["DESTINATION_REMOVE_FLAGS"], "destination_remove_flags"), + (api.audit_trail.enums.AuditType["ADD_GOOD_TO_APPLICATION"], "add_good_to_application"), + (api.audit_trail.enums.AuditType["REMOVE_GOOD_FROM_APPLICATION"], "remove_good_from_application"), + (api.audit_trail.enums.AuditType["ADD_GOOD_TYPE_TO_APPLICATION"], "add_good_type_to_application"), + ( + api.audit_trail.enums.AuditType["REMOVE_GOOD_TYPE_FROM_APPLICATION"], + "remove_good_type_from_application", + ), + ( + api.audit_trail.enums.AuditType["UPDATE_APPLICATION_END_USE_DETAIL"], + "update_application_end_use_detail", + ), + ( + api.audit_trail.enums.AuditType["UPDATE_APPLICATION_TEMPORARY_EXPORT"], + "update_application_temporary_export", + ), + ( + api.audit_trail.enums.AuditType["REMOVED_SITES_FROM_APPLICATION"], + "removed_sites_from_application", + ), + (api.audit_trail.enums.AuditType["ADD_SITES_TO_APPLICATION"], "add_sites_to_application"), + ( + api.audit_trail.enums.AuditType["REMOVED_EXTERNAL_LOCATIONS_FROM_APPLICATION"], + "removed_external_locations_from_application", + ), + ( + api.audit_trail.enums.AuditType["ADD_EXTERNAL_LOCATIONS_TO_APPLICATION"], + "add_external_locations_to_application", + ), + ( + api.audit_trail.enums.AuditType["REMOVED_COUNTRIES_FROM_APPLICATION"], + "removed_countries_from_application", + ), + (api.audit_trail.enums.AuditType["ADD_COUNTRIES_TO_APPLICATION"], "add_countries_to_application"), + ( + api.audit_trail.enums.AuditType["ADD_ADDITIONAL_CONTACT_TO_CASE"], + "add_additional_contact_to_case", + ), + (api.audit_trail.enums.AuditType["MOVE_CASE"], "move_case"), + (api.audit_trail.enums.AuditType["ASSIGN_CASE"], "assign_case"), + (api.audit_trail.enums.AuditType["ASSIGN_USER_TO_CASE"], "assign_user_to_case"), + (api.audit_trail.enums.AuditType["REMOVE_USER_FROM_CASE"], "remove_user_from_case"), + (api.audit_trail.enums.AuditType["REMOVE_CASE"], "remove_case"), + (api.audit_trail.enums.AuditType["REMOVE_CASE_FROM_ALL_QUEUES"], "remove_case_from_all_queues"), + ( + api.audit_trail.enums.AuditType["REMOVE_CASE_FROM_ALL_USER_ASSIGNMENTS"], + "remove_case_from_all_user_assignments", + ), + (api.audit_trail.enums.AuditType["CLC_RESPONSE"], "clc_response"), + (api.audit_trail.enums.AuditType["PV_GRADING_RESPONSE"], "pv_grading_response"), + (api.audit_trail.enums.AuditType["CREATED_CASE_NOTE"], "created_case_note"), + ( + api.audit_trail.enums.AuditType["CREATED_CASE_NOTE_WITH_MENTIONS"], + "created_case_note_with_mentions", + ), + (api.audit_trail.enums.AuditType["ECJU_QUERY"], "ecju_query"), + (api.audit_trail.enums.AuditType["ECJU_QUERY_RESPONSE"], "ecju_query_response"), + (api.audit_trail.enums.AuditType["ECJU_QUERY_MANUALLY_CLOSED"], "ecju_query_manually_closed"), + (api.audit_trail.enums.AuditType["UPDATED_STATUS"], "updated_status"), + (api.audit_trail.enums.AuditType["UPDATED_SUB_STATUS"], "updated_sub_status"), + (api.audit_trail.enums.AuditType["UPDATED_APPLICATION_NAME"], "updated_application_name"), + ( + api.audit_trail.enums.AuditType["UPDATE_APPLICATION_LETTER_REFERENCE"], + "update_application_letter_reference", + ), + ( + api.audit_trail.enums.AuditType["UPDATE_APPLICATION_F680_CLEARANCE_TYPES"], + "update_application_f680_clearance_types", + ), + ( + api.audit_trail.enums.AuditType["ADDED_APPLICATION_LETTER_REFERENCE"], + "added_application_letter_reference", + ), + ( + api.audit_trail.enums.AuditType["REMOVED_APPLICATION_LETTER_REFERENCE"], + "removed_application_letter_reference", + ), + (api.audit_trail.enums.AuditType["ASSIGNED_COUNTRIES_TO_GOOD"], "assigned_countries_to_good"), + (api.audit_trail.enums.AuditType["REMOVED_COUNTRIES_FROM_GOOD"], "removed_countries_from_good"), + (api.audit_trail.enums.AuditType["CREATED_FINAL_ADVICE"], "created_final_advice"), + (api.audit_trail.enums.AuditType["CLEARED_FINAL_ADVICE"], "cleared_final_advice"), + (api.audit_trail.enums.AuditType["CREATED_TEAM_ADVICE"], "created_team_advice"), + (api.audit_trail.enums.AuditType["CLEARED_TEAM_ADVICE"], "cleared_team_advice"), + (api.audit_trail.enums.AuditType["REVIEW_COMBINE_ADVICE"], "review_combine_advice"), + (api.audit_trail.enums.AuditType["CREATED_USER_ADVICE"], "created_user_advice"), + (api.audit_trail.enums.AuditType["CLEARED_USER_ADVICE"], "cleared_user_advice"), + (api.audit_trail.enums.AuditType["ADD_PARTY"], "add_party"), + (api.audit_trail.enums.AuditType["REMOVE_PARTY"], "remove_party"), + (api.audit_trail.enums.AuditType["UPLOAD_PARTY_DOCUMENT"], "upload_party_document"), + (api.audit_trail.enums.AuditType["DELETE_PARTY_DOCUMENT"], "delete_party_document"), + (api.audit_trail.enums.AuditType["UPLOAD_APPLICATION_DOCUMENT"], "upload_application_document"), + (api.audit_trail.enums.AuditType["DELETE_APPLICATION_DOCUMENT"], "delete_application_document"), + (api.audit_trail.enums.AuditType["UPLOAD_CASE_DOCUMENT"], "upload_case_document"), + (api.audit_trail.enums.AuditType["GENERATE_CASE_DOCUMENT"], "generate_case_document"), + (api.audit_trail.enums.AuditType["ADD_CASE_OFFICER_TO_CASE"], "add_case_officer_to_case"), + (api.audit_trail.enums.AuditType["REMOVE_CASE_OFFICER_FROM_CASE"], "remove_case_officer_from_case"), + (api.audit_trail.enums.AuditType["GRANTED_APPLICATION"], "granted_application"), + (api.audit_trail.enums.AuditType["REINSTATED_APPLICATION"], "reinstated_application"), + (api.audit_trail.enums.AuditType["FINALISED_APPLICATION"], "finalised_application"), + (api.audit_trail.enums.AuditType["UNASSIGNED_QUEUES"], "unassigned_queues"), + (api.audit_trail.enums.AuditType["UNASSIGNED"], "unassigned"), + (api.audit_trail.enums.AuditType["CREATED_DOCUMENT_TEMPLATE"], "created_document_template"), + (api.audit_trail.enums.AuditType["UPDATED_LETTER_TEMPLATE_NAME"], "updated_letter_template_name"), + ( + api.audit_trail.enums.AuditType["ADDED_LETTER_TEMPLATE_CASE_TYPES"], + "added_letter_template_case_types", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_LETTER_TEMPLATE_CASE_TYPES"], + "updated_letter_template_case_types", + ), + ( + api.audit_trail.enums.AuditType["REMOVED_LETTER_TEMPLATE_CASE_TYPES"], + "removed_letter_template_case_types", + ), + ( + api.audit_trail.enums.AuditType["ADDED_LETTER_TEMPLATE_DECISIONS"], + "added_letter_template_decisions", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_LETTER_TEMPLATE_DECISIONS"], + "updated_letter_template_decisions", + ), + ( + api.audit_trail.enums.AuditType["REMOVED_LETTER_TEMPLATE_DECISIONS"], + "removed_letter_template_decisions", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_LETTER_TEMPLATE_PARAGRAPHS"], + "updated_letter_template_paragraphs", + ), + ( + api.audit_trail.enums.AuditType["REMOVED_LETTER_TEMPLATE_PARAGRAPHS"], + "removed_letter_template_paragraphs", + ), + ( + api.audit_trail.enums.AuditType["ADDED_LETTER_TEMPLATE_PARAGRAPHS"], + "added_letter_template_paragraphs", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_LETTER_TEMPLATE_LAYOUT"], + "updated_letter_template_layout", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_LETTER_TEMPLATE_PARAGRAPHS_ORDERING"], + "updated_letter_template_paragraphs_ordering", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_LETTER_TEMPLATE_INCLUDE_DIGITAL_SIGNATURE"], + "updated_letter_template_include_digital_signature", + ), + (api.audit_trail.enums.AuditType["CREATED_PICKLIST"], "created_picklist"), + (api.audit_trail.enums.AuditType["UPDATED_PICKLIST_TEXT"], "updated_picklist_text"), + (api.audit_trail.enums.AuditType["UPDATED_PICKLIST_NAME"], "updated_picklist_name"), + (api.audit_trail.enums.AuditType["DEACTIVATE_PICKLIST"], "deactivate_picklist"), + (api.audit_trail.enums.AuditType["REACTIVATE_PICKLIST"], "reactivate_picklist"), + ( + api.audit_trail.enums.AuditType["UPDATED_EXHIBITION_DETAILS_TITLE"], + "updated_exhibition_details_title", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_EXHIBITION_DETAILS_START_DATE"], + "updated_exhibition_details_start_date", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_EXHIBITION_DETAILS_REQUIRED_BY_DATE"], + "updated_exhibition_details_required_by_date", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_EXHIBITION_DETAILS_REASON_FOR_CLEARANCE"], + "updated_exhibition_details_reason_for_clearance", + ), + (api.audit_trail.enums.AuditType["UPDATED_ROUTE_OF_GOODS"], "updated_route_of_goods"), + (api.audit_trail.enums.AuditType["UPDATED_ORGANISATION"], "updated_organisation"), + (api.audit_trail.enums.AuditType["CREATED_ORGANISATION"], "created_organisation"), + (api.audit_trail.enums.AuditType["REGISTER_ORGANISATION"], "register_organisation"), + (api.audit_trail.enums.AuditType["REJECTED_ORGANISATION"], "rejected_organisation"), + (api.audit_trail.enums.AuditType["APPROVED_ORGANISATION"], "approved_organisation"), + (api.audit_trail.enums.AuditType["REMOVED_FLAG_ON_ORGANISATION"], "removed_flag_on_organisation"), + (api.audit_trail.enums.AuditType["ADDED_FLAG_ON_ORGANISATION"], "added_flag_on_organisation"), + (api.audit_trail.enums.AuditType["RERUN_ROUTING_RULES"], "rerun_routing_rules"), + (api.audit_trail.enums.AuditType["ENFORCEMENT_CHECK"], "enforcement_check"), + (api.audit_trail.enums.AuditType["UPDATED_SITE"], "updated_site"), + (api.audit_trail.enums.AuditType["CREATED_SITE"], "created_site"), + (api.audit_trail.enums.AuditType["UPDATED_SITE_NAME"], "updated_site_name"), + (api.audit_trail.enums.AuditType["COMPLIANCE_SITE_CASE_CREATE"], "compliance_site_case_create"), + ( + api.audit_trail.enums.AuditType["COMPLIANCE_SITE_CASE_NEW_LICENCE"], + "compliance_site_case_new_licence", + ), + (api.audit_trail.enums.AuditType["ADDED_NEXT_REVIEW_DATE"], "added_next_review_date"), + (api.audit_trail.enums.AuditType["EDITED_NEXT_REVIEW_DATE"], "edited_next_review_date"), + (api.audit_trail.enums.AuditType["REMOVED_NEXT_REVIEW_DATE"], "removed_next_review_date"), + (api.audit_trail.enums.AuditType["COMPLIANCE_VISIT_CASE_CREATED"], "compliance_visit_case_created"), + (api.audit_trail.enums.AuditType["COMPLIANCE_VISIT_CASE_UPDATED"], "compliance_visit_case_updated"), + ( + api.audit_trail.enums.AuditType["COMPLIANCE_PEOPLE_PRESENT_CREATED"], + "compliance_people_present_created", + ), + ( + api.audit_trail.enums.AuditType["COMPLIANCE_PEOPLE_PRESENT_UPDATED"], + "compliance_people_present_updated", + ), + ( + api.audit_trail.enums.AuditType["COMPLIANCE_PEOPLE_PRESENT_DELETED"], + "compliance_people_present_deleted", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_GOOD_ON_DESTINATION_MATRIX"], + "updated_good_on_destination_matrix", + ), + (api.audit_trail.enums.AuditType["LICENCE_UPDATED_GOOD_USAGE"], "licence_updated_good_usage"), + (api.audit_trail.enums.AuditType["OGEL_REISSUED"], "ogel_reissued"), + (api.audit_trail.enums.AuditType["LICENCE_UPDATED_STATUS"], "licence_updated_status"), + ( + api.audit_trail.enums.AuditType["DOCUMENT_ON_ORGANISATION_CREATE"], + "document_on_organisation_create", + ), + ( + api.audit_trail.enums.AuditType["DOCUMENT_ON_ORGANISATION_DELETE"], + "document_on_organisation_delete", + ), + ( + api.audit_trail.enums.AuditType["DOCUMENT_ON_ORGANISATION_UPDATE"], + "document_on_organisation_update", + ), + (api.audit_trail.enums.AuditType["REPORT_SUMMARY_UPDATED"], "report_summary_updated"), + (api.audit_trail.enums.AuditType["COUNTERSIGN_ADVICE"], "countersign_advice"), + (api.audit_trail.enums.AuditType["UPDATED_SERIAL_NUMBERS"], "updated_serial_numbers"), + (api.audit_trail.enums.AuditType["PRODUCT_REVIEWED"], "product_reviewed"), + (api.audit_trail.enums.AuditType["LICENCE_UPDATED_PRODUCT_USAGE"], "licence_updated_product_usage"), + (api.audit_trail.enums.AuditType["CREATED_FINAL_RECOMMENDATION"], "created_final_recommendation"), + (api.audit_trail.enums.AuditType["GENERATE_DECISION_LETTER"], "generate_decision_letter"), + (api.audit_trail.enums.AuditType["DECISION_LETTER_SENT"], "decision_letter_sent"), + (api.audit_trail.enums.AuditType["LU_ADVICE"], "lu_advice"), + (api.audit_trail.enums.AuditType["LU_EDIT_ADVICE"], "lu_edit_advice"), + (api.audit_trail.enums.AuditType["LU_COUNTERSIGN"], "lu_countersign"), + (api.audit_trail.enums.AuditType["LU_EDIT_MEETING_NOTE"], "lu_edit_meeting_note"), + (api.audit_trail.enums.AuditType["LU_CREATE_MEETING_NOTE"], "lu_create_meeting_note"), + (api.audit_trail.enums.AuditType["CREATE_REFUSAL_CRITERIA"], "create_refusal_criteria"), + (api.audit_trail.enums.AuditType["EXPORTER_APPEALED_REFUSAL"], "exporter_appealed_refusal"), + (api.audit_trail.enums.AuditType["EXPORTER_CREATED_AMENDMENT"], "exporter_created_amendment"), + (api.audit_trail.enums.AuditType["EXPORTER_SUBMITTED_AMENDMENT"], "exporter_submitted_amendment"), + ], + db_index=True, + max_length=255, + ), + ), + ] diff --git a/api/audit_trail/migrations/0025_alter_audit_verb.py b/api/audit_trail/migrations/0025_alter_audit_verb.py new file mode 100644 index 0000000000..a10b8100d4 --- /dev/null +++ b/api/audit_trail/migrations/0025_alter_audit_verb.py @@ -0,0 +1,279 @@ +# Generated by Django 4.2.13 on 2024-06-19 09:30 + +import api.audit_trail.enums +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("audit_trail", "0024_alter_audit_verb"), + ] + + operations = [ + migrations.AlterField( + model_name="audit", + name="verb", + field=models.CharField( + choices=[ + (api.audit_trail.enums.AuditType["CREATED"], "created"), + (api.audit_trail.enums.AuditType["OGL_CREATED"], "ogl_created"), + (api.audit_trail.enums.AuditType["OGL_FIELD_EDITED"], "ogl_field_edited"), + (api.audit_trail.enums.AuditType["OGL_MULTI_FIELD_EDITED"], "ogl_multi_field_edited"), + (api.audit_trail.enums.AuditType["ADD_FLAGS"], "add_flags"), + (api.audit_trail.enums.AuditType["REMOVE_FLAGS"], "remove_flags"), + (api.audit_trail.enums.AuditType["GOOD_REVIEWED"], "good_reviewed"), + (api.audit_trail.enums.AuditType["GOOD_ADD_FLAGS"], "good_add_flags"), + (api.audit_trail.enums.AuditType["GOOD_REMOVE_FLAGS"], "good_remove_flags"), + (api.audit_trail.enums.AuditType["GOOD_ADD_REMOVE_FLAGS"], "good_add_remove_flags"), + (api.audit_trail.enums.AuditType["DESTINATION_ADD_FLAGS"], "destination_add_flags"), + (api.audit_trail.enums.AuditType["DESTINATION_REMOVE_FLAGS"], "destination_remove_flags"), + (api.audit_trail.enums.AuditType["ADD_GOOD_TO_APPLICATION"], "add_good_to_application"), + (api.audit_trail.enums.AuditType["REMOVE_GOOD_FROM_APPLICATION"], "remove_good_from_application"), + (api.audit_trail.enums.AuditType["ADD_GOOD_TYPE_TO_APPLICATION"], "add_good_type_to_application"), + ( + api.audit_trail.enums.AuditType["REMOVE_GOOD_TYPE_FROM_APPLICATION"], + "remove_good_type_from_application", + ), + ( + api.audit_trail.enums.AuditType["UPDATE_APPLICATION_END_USE_DETAIL"], + "update_application_end_use_detail", + ), + ( + api.audit_trail.enums.AuditType["UPDATE_APPLICATION_TEMPORARY_EXPORT"], + "update_application_temporary_export", + ), + ( + api.audit_trail.enums.AuditType["REMOVED_SITES_FROM_APPLICATION"], + "removed_sites_from_application", + ), + (api.audit_trail.enums.AuditType["ADD_SITES_TO_APPLICATION"], "add_sites_to_application"), + ( + api.audit_trail.enums.AuditType["REMOVED_EXTERNAL_LOCATIONS_FROM_APPLICATION"], + "removed_external_locations_from_application", + ), + ( + api.audit_trail.enums.AuditType["ADD_EXTERNAL_LOCATIONS_TO_APPLICATION"], + "add_external_locations_to_application", + ), + ( + api.audit_trail.enums.AuditType["REMOVED_COUNTRIES_FROM_APPLICATION"], + "removed_countries_from_application", + ), + (api.audit_trail.enums.AuditType["ADD_COUNTRIES_TO_APPLICATION"], "add_countries_to_application"), + ( + api.audit_trail.enums.AuditType["ADD_ADDITIONAL_CONTACT_TO_CASE"], + "add_additional_contact_to_case", + ), + (api.audit_trail.enums.AuditType["MOVE_CASE"], "move_case"), + (api.audit_trail.enums.AuditType["ASSIGN_CASE"], "assign_case"), + (api.audit_trail.enums.AuditType["ASSIGN_USER_TO_CASE"], "assign_user_to_case"), + (api.audit_trail.enums.AuditType["REMOVE_USER_FROM_CASE"], "remove_user_from_case"), + (api.audit_trail.enums.AuditType["REMOVE_CASE"], "remove_case"), + (api.audit_trail.enums.AuditType["REMOVE_CASE_FROM_ALL_QUEUES"], "remove_case_from_all_queues"), + ( + api.audit_trail.enums.AuditType["REMOVE_CASE_FROM_ALL_USER_ASSIGNMENTS"], + "remove_case_from_all_user_assignments", + ), + (api.audit_trail.enums.AuditType["CLC_RESPONSE"], "clc_response"), + (api.audit_trail.enums.AuditType["PV_GRADING_RESPONSE"], "pv_grading_response"), + (api.audit_trail.enums.AuditType["CREATED_CASE_NOTE"], "created_case_note"), + ( + api.audit_trail.enums.AuditType["CREATED_CASE_NOTE_WITH_MENTIONS"], + "created_case_note_with_mentions", + ), + (api.audit_trail.enums.AuditType["ECJU_QUERY"], "ecju_query"), + (api.audit_trail.enums.AuditType["ECJU_QUERY_RESPONSE"], "ecju_query_response"), + (api.audit_trail.enums.AuditType["ECJU_QUERY_MANUALLY_CLOSED"], "ecju_query_manually_closed"), + (api.audit_trail.enums.AuditType["UPDATED_STATUS"], "updated_status"), + (api.audit_trail.enums.AuditType["UPDATED_SUB_STATUS"], "updated_sub_status"), + (api.audit_trail.enums.AuditType["UPDATED_APPLICATION_NAME"], "updated_application_name"), + ( + api.audit_trail.enums.AuditType["UPDATE_APPLICATION_LETTER_REFERENCE"], + "update_application_letter_reference", + ), + ( + api.audit_trail.enums.AuditType["UPDATE_APPLICATION_F680_CLEARANCE_TYPES"], + "update_application_f680_clearance_types", + ), + ( + api.audit_trail.enums.AuditType["ADDED_APPLICATION_LETTER_REFERENCE"], + "added_application_letter_reference", + ), + ( + api.audit_trail.enums.AuditType["REMOVED_APPLICATION_LETTER_REFERENCE"], + "removed_application_letter_reference", + ), + (api.audit_trail.enums.AuditType["ASSIGNED_COUNTRIES_TO_GOOD"], "assigned_countries_to_good"), + (api.audit_trail.enums.AuditType["REMOVED_COUNTRIES_FROM_GOOD"], "removed_countries_from_good"), + (api.audit_trail.enums.AuditType["CREATED_FINAL_ADVICE"], "created_final_advice"), + (api.audit_trail.enums.AuditType["CLEARED_FINAL_ADVICE"], "cleared_final_advice"), + (api.audit_trail.enums.AuditType["CREATED_TEAM_ADVICE"], "created_team_advice"), + (api.audit_trail.enums.AuditType["CLEARED_TEAM_ADVICE"], "cleared_team_advice"), + (api.audit_trail.enums.AuditType["REVIEW_COMBINE_ADVICE"], "review_combine_advice"), + (api.audit_trail.enums.AuditType["CREATED_USER_ADVICE"], "created_user_advice"), + (api.audit_trail.enums.AuditType["CLEARED_USER_ADVICE"], "cleared_user_advice"), + (api.audit_trail.enums.AuditType["ADD_PARTY"], "add_party"), + (api.audit_trail.enums.AuditType["REMOVE_PARTY"], "remove_party"), + (api.audit_trail.enums.AuditType["UPLOAD_PARTY_DOCUMENT"], "upload_party_document"), + (api.audit_trail.enums.AuditType["DELETE_PARTY_DOCUMENT"], "delete_party_document"), + (api.audit_trail.enums.AuditType["UPLOAD_APPLICATION_DOCUMENT"], "upload_application_document"), + (api.audit_trail.enums.AuditType["DELETE_APPLICATION_DOCUMENT"], "delete_application_document"), + (api.audit_trail.enums.AuditType["UPLOAD_CASE_DOCUMENT"], "upload_case_document"), + (api.audit_trail.enums.AuditType["GENERATE_CASE_DOCUMENT"], "generate_case_document"), + (api.audit_trail.enums.AuditType["ADD_CASE_OFFICER_TO_CASE"], "add_case_officer_to_case"), + (api.audit_trail.enums.AuditType["REMOVE_CASE_OFFICER_FROM_CASE"], "remove_case_officer_from_case"), + (api.audit_trail.enums.AuditType["GRANTED_APPLICATION"], "granted_application"), + (api.audit_trail.enums.AuditType["REINSTATED_APPLICATION"], "reinstated_application"), + (api.audit_trail.enums.AuditType["FINALISED_APPLICATION"], "finalised_application"), + (api.audit_trail.enums.AuditType["UNASSIGNED_QUEUES"], "unassigned_queues"), + (api.audit_trail.enums.AuditType["UNASSIGNED"], "unassigned"), + (api.audit_trail.enums.AuditType["CREATED_DOCUMENT_TEMPLATE"], "created_document_template"), + (api.audit_trail.enums.AuditType["UPDATED_LETTER_TEMPLATE_NAME"], "updated_letter_template_name"), + ( + api.audit_trail.enums.AuditType["ADDED_LETTER_TEMPLATE_CASE_TYPES"], + "added_letter_template_case_types", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_LETTER_TEMPLATE_CASE_TYPES"], + "updated_letter_template_case_types", + ), + ( + api.audit_trail.enums.AuditType["REMOVED_LETTER_TEMPLATE_CASE_TYPES"], + "removed_letter_template_case_types", + ), + ( + api.audit_trail.enums.AuditType["ADDED_LETTER_TEMPLATE_DECISIONS"], + "added_letter_template_decisions", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_LETTER_TEMPLATE_DECISIONS"], + "updated_letter_template_decisions", + ), + ( + api.audit_trail.enums.AuditType["REMOVED_LETTER_TEMPLATE_DECISIONS"], + "removed_letter_template_decisions", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_LETTER_TEMPLATE_PARAGRAPHS"], + "updated_letter_template_paragraphs", + ), + ( + api.audit_trail.enums.AuditType["REMOVED_LETTER_TEMPLATE_PARAGRAPHS"], + "removed_letter_template_paragraphs", + ), + ( + api.audit_trail.enums.AuditType["ADDED_LETTER_TEMPLATE_PARAGRAPHS"], + "added_letter_template_paragraphs", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_LETTER_TEMPLATE_LAYOUT"], + "updated_letter_template_layout", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_LETTER_TEMPLATE_PARAGRAPHS_ORDERING"], + "updated_letter_template_paragraphs_ordering", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_LETTER_TEMPLATE_INCLUDE_DIGITAL_SIGNATURE"], + "updated_letter_template_include_digital_signature", + ), + (api.audit_trail.enums.AuditType["CREATED_PICKLIST"], "created_picklist"), + (api.audit_trail.enums.AuditType["UPDATED_PICKLIST_TEXT"], "updated_picklist_text"), + (api.audit_trail.enums.AuditType["UPDATED_PICKLIST_NAME"], "updated_picklist_name"), + (api.audit_trail.enums.AuditType["DEACTIVATE_PICKLIST"], "deactivate_picklist"), + (api.audit_trail.enums.AuditType["REACTIVATE_PICKLIST"], "reactivate_picklist"), + ( + api.audit_trail.enums.AuditType["UPDATED_EXHIBITION_DETAILS_TITLE"], + "updated_exhibition_details_title", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_EXHIBITION_DETAILS_START_DATE"], + "updated_exhibition_details_start_date", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_EXHIBITION_DETAILS_REQUIRED_BY_DATE"], + "updated_exhibition_details_required_by_date", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_EXHIBITION_DETAILS_REASON_FOR_CLEARANCE"], + "updated_exhibition_details_reason_for_clearance", + ), + (api.audit_trail.enums.AuditType["UPDATED_ROUTE_OF_GOODS"], "updated_route_of_goods"), + (api.audit_trail.enums.AuditType["UPDATED_ORGANISATION"], "updated_organisation"), + (api.audit_trail.enums.AuditType["CREATED_ORGANISATION"], "created_organisation"), + (api.audit_trail.enums.AuditType["REGISTER_ORGANISATION"], "register_organisation"), + (api.audit_trail.enums.AuditType["REJECTED_ORGANISATION"], "rejected_organisation"), + (api.audit_trail.enums.AuditType["APPROVED_ORGANISATION"], "approved_organisation"), + (api.audit_trail.enums.AuditType["REMOVED_FLAG_ON_ORGANISATION"], "removed_flag_on_organisation"), + (api.audit_trail.enums.AuditType["ADDED_FLAG_ON_ORGANISATION"], "added_flag_on_organisation"), + (api.audit_trail.enums.AuditType["RERUN_ROUTING_RULES"], "rerun_routing_rules"), + (api.audit_trail.enums.AuditType["ENFORCEMENT_CHECK"], "enforcement_check"), + (api.audit_trail.enums.AuditType["UPDATED_SITE"], "updated_site"), + (api.audit_trail.enums.AuditType["CREATED_SITE"], "created_site"), + (api.audit_trail.enums.AuditType["UPDATED_SITE_NAME"], "updated_site_name"), + (api.audit_trail.enums.AuditType["COMPLIANCE_SITE_CASE_CREATE"], "compliance_site_case_create"), + ( + api.audit_trail.enums.AuditType["COMPLIANCE_SITE_CASE_NEW_LICENCE"], + "compliance_site_case_new_licence", + ), + (api.audit_trail.enums.AuditType["ADDED_NEXT_REVIEW_DATE"], "added_next_review_date"), + (api.audit_trail.enums.AuditType["EDITED_NEXT_REVIEW_DATE"], "edited_next_review_date"), + (api.audit_trail.enums.AuditType["REMOVED_NEXT_REVIEW_DATE"], "removed_next_review_date"), + (api.audit_trail.enums.AuditType["COMPLIANCE_VISIT_CASE_CREATED"], "compliance_visit_case_created"), + (api.audit_trail.enums.AuditType["COMPLIANCE_VISIT_CASE_UPDATED"], "compliance_visit_case_updated"), + ( + api.audit_trail.enums.AuditType["COMPLIANCE_PEOPLE_PRESENT_CREATED"], + "compliance_people_present_created", + ), + ( + api.audit_trail.enums.AuditType["COMPLIANCE_PEOPLE_PRESENT_UPDATED"], + "compliance_people_present_updated", + ), + ( + api.audit_trail.enums.AuditType["COMPLIANCE_PEOPLE_PRESENT_DELETED"], + "compliance_people_present_deleted", + ), + ( + api.audit_trail.enums.AuditType["UPDATED_GOOD_ON_DESTINATION_MATRIX"], + "updated_good_on_destination_matrix", + ), + (api.audit_trail.enums.AuditType["LICENCE_UPDATED_GOOD_USAGE"], "licence_updated_good_usage"), + (api.audit_trail.enums.AuditType["OGEL_REISSUED"], "ogel_reissued"), + (api.audit_trail.enums.AuditType["LICENCE_UPDATED_STATUS"], "licence_updated_status"), + ( + api.audit_trail.enums.AuditType["DOCUMENT_ON_ORGANISATION_CREATE"], + "document_on_organisation_create", + ), + ( + api.audit_trail.enums.AuditType["DOCUMENT_ON_ORGANISATION_DELETE"], + "document_on_organisation_delete", + ), + ( + api.audit_trail.enums.AuditType["DOCUMENT_ON_ORGANISATION_UPDATE"], + "document_on_organisation_update", + ), + (api.audit_trail.enums.AuditType["REPORT_SUMMARY_UPDATED"], "report_summary_updated"), + (api.audit_trail.enums.AuditType["COUNTERSIGN_ADVICE"], "countersign_advice"), + (api.audit_trail.enums.AuditType["UPDATED_SERIAL_NUMBERS"], "updated_serial_numbers"), + (api.audit_trail.enums.AuditType["PRODUCT_REVIEWED"], "product_reviewed"), + (api.audit_trail.enums.AuditType["LICENCE_UPDATED_PRODUCT_USAGE"], "licence_updated_product_usage"), + (api.audit_trail.enums.AuditType["CREATED_FINAL_RECOMMENDATION"], "created_final_recommendation"), + (api.audit_trail.enums.AuditType["GENERATE_DECISION_LETTER"], "generate_decision_letter"), + (api.audit_trail.enums.AuditType["DECISION_LETTER_SENT"], "decision_letter_sent"), + (api.audit_trail.enums.AuditType["LU_ADVICE"], "lu_advice"), + (api.audit_trail.enums.AuditType["LU_EDIT_ADVICE"], "lu_edit_advice"), + (api.audit_trail.enums.AuditType["LU_COUNTERSIGN"], "lu_countersign"), + (api.audit_trail.enums.AuditType["LU_EDIT_MEETING_NOTE"], "lu_edit_meeting_note"), + (api.audit_trail.enums.AuditType["LU_CREATE_MEETING_NOTE"], "lu_create_meeting_note"), + (api.audit_trail.enums.AuditType["CREATE_REFUSAL_CRITERIA"], "create_refusal_criteria"), + (api.audit_trail.enums.AuditType["EXPORTER_APPEALED_REFUSAL"], "exporter_appealed_refusal"), + (api.audit_trail.enums.AuditType["EXPORTER_CREATED_AMENDMENT"], "exporter_created_amendment"), + (api.audit_trail.enums.AuditType["EXPORTER_SUBMITTED_AMENDMENT"], "exporter_submitted_amendment"), + (api.audit_trail.enums.AuditType["AMENDMENT_CREATED"], "amendment_created"), + ], + db_index=True, + max_length=255, + ), + ), + ] diff --git a/api/audit_trail/payload.py b/api/audit_trail/payload.py index 5184caff6c..8cffd01050 100644 --- a/api/audit_trail/payload.py +++ b/api/audit_trail/payload.py @@ -159,4 +159,7 @@ def format_payload(audit_type, payload): AuditType.LU_CREATE_MEETING_NOTE: formatters.create_lu_meeting_note, AuditType.CREATE_REFUSAL_CRITERIA: formatters.create_refusal_criteria, AuditType.EXPORTER_APPEALED_REFUSAL: " appealed refusal", + AuditType.EXPORTER_CREATED_AMENDMENT: " opened the application for a 'major edit'", + AuditType.EXPORTER_SUBMITTED_AMENDMENT: formatters.exporter_submitted_amendment, + AuditType.AMENDMENT_CREATED: formatters.amendment_created, } diff --git a/api/audit_trail/tests/test_formatters.py b/api/audit_trail/tests/test_formatters.py index 565aedbe99..089448604f 100644 --- a/api/audit_trail/tests/test_formatters.py +++ b/api/audit_trail/tests/test_formatters.py @@ -574,3 +574,13 @@ def test_update_lu_meeting_note(self, advice_status, expected_text): def test_create_lu_meeting_note(self, advice_status, expected_text): result = formatters.create_lu_meeting_note(advice_status) assert result == expected_text + + def test_exporter_submitted_amendment(self): + payload = {"amendment": {"reference_code": "some-ref"}} + result = formatters.exporter_submitted_amendment(**payload) + assert result == "created a new case for the edited application at some-ref." + + def test_amendment_created(self): + payload = {"superseded_case": {"reference_code": "some-ref"}} + result = formatters.amendment_created(**payload) + assert result == "created the case to supersede some-ref." diff --git a/api/cases/helpers.py b/api/cases/helpers.py index d06e80f799..dd3854334e 100644 --- a/api/cases/helpers.py +++ b/api/cases/helpers.py @@ -2,8 +2,6 @@ from api.audit_trail.enums import AuditType from api.common.dates import is_bank_holiday, is_weekend -from api.cases.enums import CaseTypeReferenceEnum -from api.staticdata.statuses.enums import CaseStatusEnum from api.users.models import BaseUser, GovUser, GovNotification from api.users.enums import SystemUser @@ -35,35 +33,6 @@ def get_updated_case_ids(user: GovUser): return GovNotification.objects.filter(user_id=user.pk, case__id__in=cases).values_list("case__id", flat=True) -def can_set_status(case, status): - """ - Returns true or false depending on different case conditions - """ - from api.compliance.models import ComplianceVisitCase - from api.compliance.helpers import compliance_visit_case_complete - - reference_type = case.case_type.reference - - if reference_type == CaseTypeReferenceEnum.COMP_SITE and status not in CaseStatusEnum.compliance_site_statuses: - return False - elif reference_type == CaseTypeReferenceEnum.COMP_VISIT and status not in CaseStatusEnum.compliance_visit_statuses: - return False - - if case.case_type.reference == CaseTypeReferenceEnum.COMP_VISIT and CaseStatusEnum.is_terminal(status): - comp_case = ComplianceVisitCase.objects.get(id=case.id) - if not compliance_visit_case_complete(comp_case): - return False - - if reference_type == CaseTypeReferenceEnum.CRE and status not in [ - CaseStatusEnum.CLOSED, - CaseStatusEnum.SUBMITTED, - CaseStatusEnum.RESUBMITTED, - ]: - return False - - return True - - def working_days_in_range(start_date, end_date): dates_in_range = [start_date + timedelta(n) for n in range((end_date - start_date).days)] return len([date for date in dates_in_range if (not is_bank_holiday(date) and not is_weekend(date))]) diff --git a/api/cases/managers.py b/api/cases/managers.py index 7dccf079af..40bd3b21c7 100644 --- a/api/cases/managers.py +++ b/api/cases/managers.py @@ -6,6 +6,11 @@ from django.db.models import Prefetch, Q, Sum from django.utils import timezone +from queryable_properties.managers import ( + QueryablePropertiesManager, + QueryablePropertiesQuerySet, +) + from api.cases.enums import AdviceLevel, CaseTypeEnum from api.cases.helpers import get_updated_case_ids, get_assigned_to_user_case_ids, get_assigned_as_case_officer_case_ids from api.common.enums import SortOrder @@ -24,7 +29,7 @@ from api.staticdata.statuses.libraries.get_case_status import get_case_status_by_status -class CaseQuerySet(models.QuerySet): +class CaseQuerySet(QueryablePropertiesQuerySet): """ Custom queryset for the Case model. This allows us to chain application specific filtering logic in a reusable way. @@ -236,7 +241,7 @@ def includes_refusal_recommendation_from_ogd(self): return self.filter(advice__type=AdviceType.REFUSE, advice__user__team__is_ogd=True) -class CaseManager(models.Manager): +class CaseManager(QueryablePropertiesManager): """ Custom manager for the Case model that uses CaseQuerySet and provides a reusable search functionality to the Case model. diff --git a/api/cases/models.py b/api/cases/models.py index ea771561b6..e72dcede35 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -10,7 +10,6 @@ from django.utils import timezone from api.users.enums import UserType -from rest_framework.exceptions import ValidationError from queryable_properties.managers import QueryablePropertiesManager from queryable_properties.properties import queryable_property @@ -29,8 +28,6 @@ from api.cases.libraries.reference_code import generate_reference_code from api.cases.managers import CaseManager, CaseReferenceCodeManager, AdviceManager from api.common.models import TimestampableModel -from api.core.constants import GovPermissions -from api.core.permissions import assert_user_has_permission from api.documents.models import Document from api.flags.models import Flag from api.goods.enums import PvGrading @@ -52,7 +49,6 @@ UserOrganisationRelationship, ExporterNotification, ) -from lite_content.lite_api import strings denial_reasons_logger = logging.getLogger(settings.DENIAL_REASONS_DELETION_LOGGER) @@ -198,25 +194,13 @@ def change_status(self, user, status: CaseStatus, note: Optional[str] = ""): Sets the status for the case, runs validation on various parameters, creates audit entries and also runs flagging and automation rules """ - from api.cases.helpers import can_set_status from api.audit_trail import service as audit_trail_service - from api.applications.libraries.application_helpers import can_status_be_set_by_gov_user from api.workflow.flagging_rules_automation import apply_flagging_rules_to_case from api.licences.helpers import update_licence_status from lite_routing.routing_rules_internal.routing_engine import run_routing_rules old_status = self.status.status - # Only allow the final decision if the user has the MANAGE_FINAL_ADVICE permission - if status.status == CaseStatusEnum.FINALISED: - assert_user_has_permission(user.govuser, GovPermissions.MANAGE_LICENCE_FINAL_ADVICE) - - if not can_set_status(self, status.status): - raise ValidationError({"status": [strings.Statuses.BAD_STATUS]}) - - if not can_status_be_set_by_gov_user(user.govuser, old_status, status.status, is_mod=False): - raise ValidationError({"status": ["Status cannot be set by user"]}) - self.status = status self.save() diff --git a/api/cases/serializers.py b/api/cases/serializers.py index d7c4784502..5fed53b11c 100644 --- a/api/cases/serializers.py +++ b/api/cases/serializers.py @@ -213,6 +213,7 @@ class DenialMatchOnApplicationSummarySerializer(serializers.Serializer): """ name = serializers.CharField(source="denial_entity.name") + regime_reg_ref = serializers.CharField(source="denial_entity.denial.regime_reg_ref") reference = serializers.CharField(source="denial_entity.denial.reference") category = serializers.CharField() address = serializers.CharField(source="denial_entity.address") diff --git a/api/cases/tests/test_case_search.py b/api/cases/tests/test_case_search.py index bafb697640..44ddf9b907 100644 --- a/api/cases/tests/test_case_search.py +++ b/api/cases/tests/test_case_search.py @@ -1093,6 +1093,7 @@ def test_api_success(self): "reference": self.denial_entity.denial.reference, "category": self.denial_on_application.category, "address": self.denial_entity.address, + "regime_reg_ref": self.denial_on_application.denial_entity.denial.regime_reg_ref, } ], ) diff --git a/api/cases/tests/test_get_case.py b/api/cases/tests/test_get_case.py index 7adb34535f..22167d3672 100644 --- a/api/cases/tests/test_get_case.py +++ b/api/cases/tests/test_get_case.py @@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.utils import timezone +from api.applications.tests.factories import DenialMatchOnApplicationFactory from api.audit_trail.enums import AuditType from api.audit_trail.models import Audit from api.cases.models import Case, CaseQueue @@ -21,6 +22,7 @@ from api.staticdata.trade_control.enums import TradeControlActivity, TradeControlProductCategory from test_helpers.clients import DataTestClient from api.staticdata.statuses.models import CaseStatus, CaseSubStatus +from api.users.models import ExporterUser from lite_routing.routing_rules_internal.enums import FlagsEnum @@ -44,7 +46,8 @@ def test_case_endpoint_responds_ok(self): def test_case_endpoint_responds_ok_for_amendment(self): superseded_case = self.submit_application(self.standard_application) - amendment = superseded_case.create_amendment() + exporter_user = ExporterUser.objects.first() + amendment = superseded_case.create_amendment(exporter_user) amendment = self.submit_application(amendment) url = reverse("cases:case", kwargs={"pk": superseded_case.id}) @@ -397,3 +400,46 @@ def test_cases_endpoint_with_sub_status(self): data = response.json() self.assertEqual(data["results"]["cases"][0]["sub_status"]["name"], sub_status.name) + + def test_case_returns_expected_denial_matches(self): + """ + Given a case with a denial match + When the case is retrieved + Then the denial match json data is as expected. + """ + case = self.submit_application(self.standard_application) + denial_match = DenialMatchOnApplicationFactory(application=case) + url = reverse("cases:case", kwargs={"pk": case.id}) + + expected_denial_matches = [ + { + "id": str(denial_match.id), + "application": str(case.id), + "denial_entity": { + "id": str(denial_match.denial_entity.id), + "created_by": str(denial_match.denial_entity.created_by_id), + "name": denial_match.denial_entity.name, + "address": denial_match.denial_entity.address, + "regime_reg_ref": denial_match.denial_entity.denial.regime_reg_ref, + "notifying_government": denial_match.denial_entity.denial.notifying_government, + "country": denial_match.denial_entity.country, + "denial_cle": denial_match.denial_entity.denial.denial_cle, + "item_description": denial_match.denial_entity.denial.item_description, + "end_use": denial_match.denial_entity.denial.end_use, + "is_revoked": denial_match.denial_entity.denial.is_revoked, + "is_revoked_comment": denial_match.denial_entity.denial.is_revoked_comment, + "reason_for_refusal": denial_match.denial_entity.denial.reason_for_refusal, + "entity_type": denial_match.denial_entity.entity_type, + "reference": denial_match.denial_entity.denial.reference, + "denial": str(denial_match.denial_entity.denial.id), + }, + "category": denial_match.category, + } + ] + + response = self.client.get(url, **self.gov_headers) + + response_data = response.json() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response_data["case"]["data"]["denial_matches"], expected_denial_matches) diff --git a/api/cases/tests/test_models.py b/api/cases/tests/test_models.py index dc8fa2d5d0..b0acf32ca8 100644 --- a/api/cases/tests/test_models.py +++ b/api/cases/tests/test_models.py @@ -4,10 +4,11 @@ from api.audit_trail.serializers import AuditSerializer from parameterized import parameterized -from api.cases.models import Case, BadSubStatus +from api.cases.models import BadSubStatus from api.cases.tests.factories import CaseFactory from api.staticdata.statuses.enums import CaseStatusEnum, CaseSubStatusIdEnum from api.staticdata.statuses.models import CaseStatus, CaseSubStatus +from api.users.models import ExporterUser from test_helpers.clients import DataTestClient @@ -21,7 +22,8 @@ def test_set_sub_status_invalid(self): self.assertRaises(BadSubStatus, self.case.set_sub_status, CaseSubStatusIdEnum.FINALISED__APPROVED) def test_superseded_by_amendment_exists(self): - amendment = self.case.create_amendment() + exporter_user = ExporterUser.objects.first() + amendment = self.case.create_amendment(exporter_user) assert self.case.superseded_by == amendment.case_ptr def test_superseded_by_no_amendment_exists(self): diff --git a/api/cases/tests/test_status.py b/api/cases/tests/test_status.py index eab5c88af4..ac48b2fac2 100644 --- a/api/cases/tests/test_status.py +++ b/api/cases/tests/test_status.py @@ -62,6 +62,27 @@ def test_certain_case_statuses_changes_licence_status(self, case_status, licence self.assertEqual(self.case.status.status, case_status) self.assertEqual(licence.status, licence_status) + def test_change_status_no_user_permission(self): + data = {"status": CaseStatusEnum.FINALISED} + response = self.client.patch(self.url, data=data, **self.gov_headers) + + self.case.refresh_from_db() + + # This should be a 401, but legacy... + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(self.case.status.status, "submitted") + + def test_change_status_new_status_not_allowed(self): + data = {"status": CaseStatusEnum.APPLICANT_EDITING} + response = self.client.patch(self.url, data=data, **self.gov_headers) + + self.case.refresh_from_db() + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(self.case.status.status, "submitted") + + # TODO: More tests covering different paths for status change view + class EndUserAdvisoryUpdate(DataTestClient): def setUp(self): diff --git a/api/cases/views/views.py b/api/cases/views/views.py index b2ed12a634..fa29e9f151 100644 --- a/api/cases/views/views.py +++ b/api/cases/views/views.py @@ -6,7 +6,7 @@ from django.contrib.contenttypes.models import ContentType from rest_framework import status -from rest_framework.exceptions import ParseError +from rest_framework.exceptions import ParseError, ValidationError from rest_framework.generics import ListCreateAPIView, UpdateAPIView, ListAPIView, RetrieveAPIView from rest_framework.views import APIView @@ -18,6 +18,7 @@ CountryWithFlagsSerializer, CountersignDecisionAdviceSerializer, ) +from api.applications.libraries.application_helpers import can_status_be_set_by_gov_user from api.audit_trail import service as audit_trail_service from api.audit_trail.enums import AuditType from api.cases import notify @@ -186,9 +187,12 @@ def patch(self, request, pk): Change case status """ case = get_case(pk) - case.change_status( - request.user, get_case_status_by_status(request.data.get("status")), request.data.get("note") - ) + new_status = get_case_status_by_status(request.data.get("status")) + + if not can_status_be_set_by_gov_user(request.user.govuser, case.status.status, new_status.status, is_mod=False): + raise ValidationError({"status": ["Status cannot be set by user"]}) + + case.change_status(request.user, new_status, request.data.get("note")) return JsonResponse(data={}, status=status.HTTP_200_OK) diff --git a/api/conf/celery.py b/api/conf/celery.py index 7971316e80..40c118bc24 100644 --- a/api/conf/celery.py +++ b/api/conf/celery.py @@ -3,7 +3,8 @@ from celery import Celery from celery.schedules import crontab from dbt_copilot_python.celery_health_check import healthcheck -from dbt_copilot_python.utility import is_copilot + +from django.conf import settings # Set the default Django settings module for the 'celery' program. os.environ.setdefault("DJANGO_SETTINGS_MODULE", "api.conf.settings") @@ -43,5 +44,5 @@ }, } -if is_copilot: +if settings.IS_ENV_DBT_PLATFORM: celery_app = healthcheck.setup(app) diff --git a/api/conf/settings.py b/api/conf/settings.py index 7c2ae4aafd..f72bcdb71a 100644 --- a/api/conf/settings.py +++ b/api/conf/settings.py @@ -9,9 +9,15 @@ from sentry_sdk.integrations.django import DjangoIntegration from dbt_copilot_python.network import setup_allowed_hosts +from dbt_copilot_python.database import database_url_from_env from dbt_copilot_python.utility import is_copilot +import dj_database_url + from django_log_formatter_ecs import ECSFormatter +from django_log_formatter_asim import ASIMFormatter + + from django.urls import reverse_lazy # Build paths inside the project like this: os.path.join(BASE_DIR, ...) @@ -47,6 +53,12 @@ DEBUG = env("DEBUG") +ENV = env("ENV") +VCAP_SERVICES = env.json("VCAP_SERVICES", {}) + +IS_ENV_DBT_PLATFORM = is_copilot() +IS_ENV_GOV_PAAS = bool(VCAP_SERVICES) + # Please use this to Enable/Disable the Admin site ADMIN_ENABLED = env("ADMIN_ENABLED", default=False) @@ -242,55 +254,13 @@ LETTER_TEMPLATES_DIRECTORY = os.path.join(BASE_DIR, "letter_templates", "templates", "letter_templates") -# Database -DATABASES = {"default": env.db()} # https://docs.djangoproject.com/en/2.1/ref/settings/#databases DEFAULT_AUTO_FIELD = "django.db.models.AutoField" # AWS -VCAP_SERVICES = env.json("VCAP_SERVICES", {}) S3_BUCKET_TAG_FILE_UPLOADS = "file-uploads" -if VCAP_SERVICES: - if "aws-s3-bucket" not in VCAP_SERVICES: - raise Exception("S3 Bucket not bound to environment") - - for bucket_details in VCAP_SERVICES["aws-s3-bucket"]: - if S3_BUCKET_TAG_FILE_UPLOADS in bucket_details["tags"]: - aws_credentials = bucket_details["credentials"] - AWS_ENDPOINT_URL = None - AWS_ACCESS_KEY_ID = aws_credentials["aws_access_key_id"] - AWS_SECRET_ACCESS_KEY = aws_credentials["aws_secret_access_key"] - AWS_REGION = aws_credentials["aws_region"] - AWS_STORAGE_BUCKET_NAME = aws_credentials["bucket_name"] -else: - AWS_ENDPOINT_URL = env("AWS_ENDPOINT_URL", default=None) - AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID") - AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY") - AWS_REGION = env("AWS_REGION") - AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME") - -if "redis" in VCAP_SERVICES: - REDIS_BASE_URL = VCAP_SERVICES["redis"][0]["credentials"]["uri"] -else: - REDIS_BASE_URL = env("REDIS_BASE_URL", default=None) - - -def _build_redis_url(base_url, db_number, **query_args): - encoded_query_args = urlencode(query_args) - return f"{base_url}/{db_number}?{encoded_query_args}" - - -if REDIS_BASE_URL: - # Give celery tasks their own redis DB - future uses of redis should use a different DB - REDIS_CELERY_DB = env("REDIS_CELERY_DB", default=0) - is_redis_ssl = REDIS_BASE_URL.startswith("rediss://") - url_args = {"ssl_cert_reqs": "CERT_REQUIRED"} if is_redis_ssl else {} - - CELERY_BROKER_URL = _build_redis_url(REDIS_BASE_URL, REDIS_CELERY_DB, **url_args) - CELERY_RESULT_BACKEND = CELERY_BROKER_URL - CELERY_ALWAYS_EAGER = env.bool("CELERY_ALWAYS_EAGER", False) CELERY_TASK_ALWAYS_EAGER = env.bool("CELERY_TASK_ALWAYS_EAGER", False) CELERY_TASK_STORE_EAGER_RESULT = env.bool("CELERY_TASK_STORE_EAGER_RESULT", False) @@ -339,51 +309,9 @@ def _build_redis_url(base_url, db_number, **query_args): ELASTICSEARCH_PRODUCT_INDEX_ALIAS = env.str("ELASTICSEARCH_PRODUCT_INDEX_ALIAS", "products-alias") ELASTICSEARCH_APPLICATION_INDEX_ALIAS = env.str("ELASTICSEARCH_APPLICATION_INDEX_ALIAS", "application-alias") -# Elasticsearch configuration -LITE_API_ENABLE_ES = env.bool("LITE_API_ENABLE_ES", False) -if LITE_API_ENABLE_ES: - ELASTICSEARCH_DSL = { - "default": {"hosts": env.str("ELASTICSEARCH_HOST")}, - } - - ENABLE_SPIRE_SEARCH = env.bool("ENABLE_SPIRE_SEARCH", False) - - ELASTICSEARCH_PRODUCT_INDEXES = {"LITE": ELASTICSEARCH_PRODUCT_INDEX_ALIAS} - ELASTICSEARCH_APPLICATION_INDEXES = {"LITE": ELASTICSEARCH_APPLICATION_INDEX_ALIAS} - SPIRE_APPLICATION_INDEX_NAME = env.str("SPIRE_APPLICATION_INDEX_NAME", "spire-application-alias") - SPIRE_PRODUCT_INDEX_NAME = env.str("SPIRE_PRODUCT_INDEX_NAME", "spire-products-alias") - - if ENABLE_SPIRE_SEARCH: - ELASTICSEARCH_APPLICATION_INDEXES["SPIRE"] = SPIRE_APPLICATION_INDEX_NAME - ELASTICSEARCH_PRODUCT_INDEXES["SPIRE"] = SPIRE_PRODUCT_INDEX_NAME - - INSTALLED_APPS += [ - "django_elasticsearch_dsl", - "django_elasticsearch_dsl_drf", - ] - DENIAL_REASONS_DELETION_LOGGER = "denial_reasons_deletion_logger" - -LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "formatters": { - "simple": {"format": "{asctime} {levelname} {message}", "style": "{"}, - "ecs_formatter": {"()": ECSFormatter}, - }, - "handlers": { - "stdout": {"class": "logging.StreamHandler", "formatter": "simple"}, - "ecs": {"class": "logging.StreamHandler", "formatter": "ecs_formatter"}, - "sentry": {"class": "sentry_sdk.integrations.logging.EventHandler"}, - }, - "root": {"handlers": ["stdout", "ecs"], "level": env("LOG_LEVEL").upper()}, - "loggers": { - DENIAL_REASONS_DELETION_LOGGER: {"handlers": ["sentry"], "level": logging.WARNING}, - }, -} - # Sentry if env.str("SENTRY_DSN", ""): sentry_sdk.init( @@ -420,8 +348,6 @@ def _build_redis_url(base_url, db_number, **query_args): GOV_NOTIFY_KEY = env("GOV_NOTIFY_KEY") -ENV = env("ENV") - # If EXPORTER_BASE_URL is not in env vars, build the base_url using the environment EXPORTER_BASE_URL = env("EXPORTER_BASE_URL") or f"https://exporter.lite.service.{ENV}.uktrade.digital" @@ -509,11 +435,117 @@ def _build_redis_url(base_url, db_number, **query_args): S3_BUCKET_TAG_ANONYMISER_DESTINATION = "anonymiser" -if VCAP_SERVICES: +ELASTICSEARCH_SANCTION_INDEX_ALIAS = env.str("ELASTICSEARCH_SANCTION_INDEX_ALIAS", "sanctions-alias") +ELASTICSEARCH_DENIALS_INDEX_ALIAS = env.str("ELASTICSEARCH_DENIALS_INDEX_ALIAS", "denials-alias") +ELASTICSEARCH_PRODUCT_INDEX_ALIAS = env.str("ELASTICSEARCH_PRODUCT_INDEX_ALIAS", "products-alias") +ELASTICSEARCH_APPLICATION_INDEX_ALIAS = env.str("ELASTICSEARCH_APPLICATION_INDEX_ALIAS", "application-alias") + +DB_ANONYMISER_CONFIG_LOCATION = Path(BASE_DIR) / "conf" / "anonymise_model_config.yaml" +DB_ANONYMISER_DUMP_FILE_NAME = env.str("DB_ANONYMISER_DUMP_FILE_NAME", "anonymised.sql") + + +def _build_redis_url(base_url, db_number, **query_args): + encoded_query_args = urlencode(query_args) + return f"{base_url}/{db_number}?{encoded_query_args}" + + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "sentry": {"class": "sentry_sdk.integrations.logging.EventHandler"}, + }, + "loggers": { + DENIAL_REASONS_DELETION_LOGGER: {"handlers": ["sentry"], "level": logging.WARNING}, + }, +} + +if IS_ENV_DBT_PLATFORM: + ALLOWED_HOSTS = setup_allowed_hosts(ALLOWED_HOSTS) + + DATABASES = {"default": dj_database_url.config(default=database_url_from_env("DATABASE_CREDENTIALS"))} + CELERY_BROKER_URL = env("CELERY_BROKER_URL", default=None) + CELERY_RESULT_BACKEND = CELERY_BROKER_URL + REDIS_BASE_URL = env("REDIS_BASE_URL", default=None) + + # Elasticsearch configuration + LITE_API_ENABLE_ES = env.bool("LITE_API_ENABLE_ES", False) + if LITE_API_ENABLE_ES: + ELASTICSEARCH_DSL = { + "default": {"hosts": env.str("OPENSEARCH_ENDPOINT")}, + } + + ENABLE_SPIRE_SEARCH = env.bool("ENABLE_SPIRE_SEARCH", False) + + ELASTICSEARCH_PRODUCT_INDEXES = {"LITE": ELASTICSEARCH_PRODUCT_INDEX_ALIAS} + ELASTICSEARCH_APPLICATION_INDEXES = {"LITE": ELASTICSEARCH_APPLICATION_INDEX_ALIAS} + SPIRE_APPLICATION_INDEX_NAME = env.str("SPIRE_APPLICATION_INDEX_NAME", "spire-application-alias") + SPIRE_PRODUCT_INDEX_NAME = env.str("SPIRE_PRODUCT_INDEX_NAME", "spire-products-alias") + + if ENABLE_SPIRE_SEARCH: + ELASTICSEARCH_APPLICATION_INDEXES["SPIRE"] = SPIRE_APPLICATION_INDEX_NAME + ELASTICSEARCH_PRODUCT_INDEXES["SPIRE"] = SPIRE_PRODUCT_INDEX_NAME + + INSTALLED_APPS += [ + "django_elasticsearch_dsl", + "django_elasticsearch_dsl_drf", + ] + + LOGGING.update({"formatters": {"asim_formatter": {"()": ASIMFormatter}}}) + LOGGING["handlers"].update({"asim": {"class": "logging.StreamHandler", "formatter": "asim_formatter"}}) + LOGGING.update({"root": {"handlers": ["asim"], "level": env("LOG_LEVEL").upper()}}) + +elif IS_ENV_GOV_PAAS: + # This has repeating code as this section can be deleted once migrated to DBT Platform + # Database + DATABASES = {"default": env.db()} # https://docs.djangoproject.com/en/2.1/ref/settings/#databases + # redis + REDIS_BASE_URL = VCAP_SERVICES["redis"][0]["credentials"]["uri"] + + if REDIS_BASE_URL: + # Give celery tasks their own redis DB - future uses of redis should use a different DB + REDIS_CELERY_DB = env("REDIS_CELERY_DB", default=0) + is_redis_ssl = REDIS_BASE_URL.startswith("rediss://") + url_args = {"ssl_cert_reqs": "CERT_REQUIRED"} if is_redis_ssl else {} + + CELERY_BROKER_URL = _build_redis_url(REDIS_BASE_URL, REDIS_CELERY_DB, **url_args) + CELERY_RESULT_BACKEND = CELERY_BROKER_URL + + # Elasticsearch configuration + LITE_API_ENABLE_ES = env.bool("LITE_API_ENABLE_ES", False) + if LITE_API_ENABLE_ES: + ELASTICSEARCH_DSL = { + "default": {"hosts": env.str("ELASTICSEARCH_HOST")}, + } + + ENABLE_SPIRE_SEARCH = env.bool("ENABLE_SPIRE_SEARCH", False) + + ELASTICSEARCH_PRODUCT_INDEXES = {"LITE": ELASTICSEARCH_PRODUCT_INDEX_ALIAS} + ELASTICSEARCH_APPLICATION_INDEXES = {"LITE": ELASTICSEARCH_APPLICATION_INDEX_ALIAS} + SPIRE_APPLICATION_INDEX_NAME = env.str("SPIRE_APPLICATION_INDEX_NAME", "spire-application-alias") + SPIRE_PRODUCT_INDEX_NAME = env.str("SPIRE_PRODUCT_INDEX_NAME", "spire-products-alias") + + if ENABLE_SPIRE_SEARCH: + ELASTICSEARCH_APPLICATION_INDEXES["SPIRE"] = SPIRE_APPLICATION_INDEX_NAME + ELASTICSEARCH_PRODUCT_INDEXES["SPIRE"] = SPIRE_PRODUCT_INDEX_NAME + + INSTALLED_APPS += [ + "django_elasticsearch_dsl", + "django_elasticsearch_dsl_drf", + ] + # AWS if "aws-s3-bucket" not in VCAP_SERVICES: raise Exception("S3 Bucket not bound to environment") for bucket_details in VCAP_SERVICES["aws-s3-bucket"]: + if S3_BUCKET_TAG_FILE_UPLOADS in bucket_details["tags"]: + aws_credentials = bucket_details["credentials"] + AWS_ENDPOINT_URL = None + AWS_ACCESS_KEY_ID = aws_credentials["aws_access_key_id"] + AWS_SECRET_ACCESS_KEY = aws_credentials["aws_secret_access_key"] + AWS_REGION = aws_credentials["aws_region"] + AWS_STORAGE_BUCKET_NAME = aws_credentials["bucket_name"] + if S3_BUCKET_TAG_ANONYMISER_DESTINATION in bucket_details["tags"]: aws_credentials = bucket_details["credentials"] DB_ANONYMISER_AWS_ENDPOINT_URL = None @@ -521,17 +553,62 @@ def _build_redis_url(base_url, db_number, **query_args): DB_ANONYMISER_AWS_SECRET_ACCESS_KEY = aws_credentials["aws_secret_access_key"] DB_ANONYMISER_AWS_REGION = aws_credentials["aws_region"] DB_ANONYMISER_AWS_STORAGE_BUCKET_NAME = aws_credentials["bucket_name"] + + LOGGING.update({"formatters": {"ecs_formatter": {"()": ECSFormatter}}}) + LOGGING["handlers"].update({"ecs": {"class": "logging.StreamHandler", "formatter": "ecs_formatter"}}) + LOGGING.update({"root": {"handlers": ["ecs"], "level": env("LOG_LEVEL").upper()}}) + else: + # Local configurations and CircleCI + # Database + DATABASES = {"default": env.db()} # https://docs.djangoproject.com/en/2.1/ref/settings/#databases + # redis + REDIS_BASE_URL = env("REDIS_BASE_URL", default=None) + + if REDIS_BASE_URL: + # Give celery tasks their own redis DB - future uses of redis should use a different DB + REDIS_CELERY_DB = env("REDIS_CELERY_DB", default=0) + is_redis_ssl = REDIS_BASE_URL.startswith("rediss://") + url_args = {"ssl_cert_reqs": "CERT_REQUIRED"} if is_redis_ssl else {} + + CELERY_BROKER_URL = _build_redis_url(REDIS_BASE_URL, REDIS_CELERY_DB, **url_args) + CELERY_RESULT_BACKEND = CELERY_BROKER_URL + + # Elasticsearch configuration + LITE_API_ENABLE_ES = env.bool("LITE_API_ENABLE_ES", False) + if LITE_API_ENABLE_ES: + ELASTICSEARCH_DSL = { + "default": {"hosts": env.str("ELASTICSEARCH_HOST")}, + } + + ENABLE_SPIRE_SEARCH = env.bool("ENABLE_SPIRE_SEARCH", False) + + ELASTICSEARCH_PRODUCT_INDEXES = {"LITE": ELASTICSEARCH_PRODUCT_INDEX_ALIAS} + ELASTICSEARCH_APPLICATION_INDEXES = {"LITE": ELASTICSEARCH_APPLICATION_INDEX_ALIAS} + SPIRE_APPLICATION_INDEX_NAME = env.str("SPIRE_APPLICATION_INDEX_NAME", "spire-application-alias") + SPIRE_PRODUCT_INDEX_NAME = env.str("SPIRE_PRODUCT_INDEX_NAME", "spire-products-alias") + + if ENABLE_SPIRE_SEARCH: + ELASTICSEARCH_APPLICATION_INDEXES["SPIRE"] = SPIRE_APPLICATION_INDEX_NAME + ELASTICSEARCH_PRODUCT_INDEXES["SPIRE"] = SPIRE_PRODUCT_INDEX_NAME + + INSTALLED_APPS += [ + "django_elasticsearch_dsl", + "django_elasticsearch_dsl_drf", + ] + # AWS + AWS_ENDPOINT_URL = env("AWS_ENDPOINT_URL", default=None) + AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID") + AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY") + AWS_REGION = env("AWS_REGION") + AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME") + DB_ANONYMISER_AWS_ENDPOINT_URL = AWS_ENDPOINT_URL DB_ANONYMISER_AWS_ACCESS_KEY_ID = env("DB_ANONYMISER_AWS_ACCESS_KEY_ID", default=None) DB_ANONYMISER_AWS_SECRET_ACCESS_KEY = env("DB_ANONYMISER_AWS_SECRET_ACCESS_KEY", default=None) DB_ANONYMISER_AWS_REGION = env("DB_ANONYMISER_AWS_REGION", default=None) DB_ANONYMISER_AWS_STORAGE_BUCKET_NAME = env("DB_ANONYMISER_AWS_STORAGE_BUCKET_NAME", default=None) -DB_ANONYMISER_CONFIG_LOCATION = Path(BASE_DIR) / "conf" / "anonymise_model_config.yaml" -DB_ANONYMISER_DUMP_FILE_NAME = env.str("DB_ANONYMISER_DUMP_FILE_NAME", "anonymised.sql") - - -# DBT Platform Spectic config -if is_copilot: - ALLOWED_HOSTS = setup_allowed_hosts(ALLOWED_HOSTS) + LOGGING.update({"formatters": {"simple": {"format": "{asctime} {levelname} {message}", "style": "{"}}}) + LOGGING["handlers"].update({"stdout": {"class": "logging.StreamHandler", "formatter": "simple"}}) + LOGGING.update({"root": {"handlers": ["stdout"], "level": env("LOG_LEVEL").upper()}}) diff --git a/api/core/decorators.py b/api/core/decorators.py index 473fcabe72..da52333f61 100644 --- a/api/core/decorators.py +++ b/api/core/decorators.py @@ -67,45 +67,45 @@ def inner(request, *args, **kwargs): return decorator -def application_in_state(is_editable: bool = False, is_major_editable: bool = False) -> Callable: - """ - Checks if application is in an editable or major-editable state - """ - - def decorator(func): - @wraps(func) +def application_in_status(status_check_func): + @wraps(status_check_func) + def decorator(view_func): + @wraps(view_func) def inner(request, *args, **kwargs): application_status = _get_application(request, kwargs).values_list("status__status", flat=True)[0] + has_status, error = status_check_func(application_status) + if has_status: + return view_func(request, *args, **kwargs) + return JsonResponse( + data={"errors": {"non_field_errors": [error]}}, + status=status.HTTP_400_BAD_REQUEST, + ) - if is_editable and application_status in CaseStatusEnum.read_only_statuses(): - return JsonResponse( - data={ - "errors": { - "non_field_errors": [ - strings.Applications.Generic.INVALID_OPERATION_FOR_READ_ONLY_CASE_ERROR - ] - } - }, - status=status.HTTP_400_BAD_REQUEST, - ) + return inner - if is_major_editable and application_status not in CaseStatusEnum.major_editable_statuses(): - return JsonResponse( - data={ - "errors": { - "non_field_errors": [ - strings.Applications.Generic.INVALID_OPERATION_FOR_NON_DRAFT_OR_MAJOR_EDIT_CASE_ERROR - ] - } - }, - status=status.HTTP_400_BAD_REQUEST, - ) + return decorator - return func(request, *args, **kwargs) - return inner +@application_in_status +def application_is_editable(application_status): + """ + Checks if application is editable + """ + return ( + CaseStatusEnum.is_editable(application_status), + strings.Applications.Generic.INVALID_OPERATION_FOR_READ_ONLY_CASE_ERROR, + ) - return decorator + +@application_in_status +def application_is_major_editable(application_status): + """ + Checks if application is major editable + """ + return ( + CaseStatusEnum.is_major_editable_status(application_status), + strings.Applications.Generic.INVALID_OPERATION_FOR_NON_DRAFT_OR_MAJOR_EDIT_CASE_ERROR, + ) def authorised_to_view_application(user_type: Union[Type[GovUser], Type[ExporterUser]]) -> Callable: diff --git a/api/core/search/filter_backends.py b/api/core/search/filter_backends.py index e6ed977c83..8e64fd0b74 100644 --- a/api/core/search/filter_backends.py +++ b/api/core/search/filter_backends.py @@ -13,6 +13,20 @@ class QueryStringSearchFilterBackend(BaseSearchFilterBackend): search_param = "search" + def get_search_query_params(self, request): + """Get search query params. + + :param request: Django REST framework request. + :type request: rest_framework.request.Request + :return: List of search query params. + :rtype: list + """ + query_params = request.query_params.copy() + # This is required as query_string is unable to handle a single / + query_string = query_params.get(self.search_param, "").replace("/", "//") + query_params[self.search_param] = query_string + return query_params.getlist(self.search_param, []) + query_backends = [ QueryStringQueryBackend, ] diff --git a/api/core/search/validators.py b/api/core/search/validators.py index 0c9fe38206..980f80e293 100644 --- a/api/core/search/validators.py +++ b/api/core/search/validators.py @@ -12,6 +12,9 @@ def validate_search_terms(self): query_params = self.request.GET.copy() search_term = query_params.get("search") + # This is required as query_string is unable to handle a single / + search_term = search_term.replace("/", "//") + # Validation is only required if we are using QueryStringSearchFilterBackend if filter_backends.QueryStringSearchFilterBackend not in self.filter_backends: diff --git a/api/core/tests/test_decorators.py b/api/core/tests/test_decorators.py index dbd099adea..1b75c1502a 100644 --- a/api/core/tests/test_decorators.py +++ b/api/core/tests/test_decorators.py @@ -1,13 +1,22 @@ +from parameterized import parameterized + from django.http import HttpResponse from django.test import RequestFactory from rest_framework import status +from api.applications.libraries.case_status_helpers import get_case_statuses from api.cases.enums import CaseTypeSubTypeEnum from api.core.authentication import ORGANISATION_ID -from api.core.decorators import allowed_application_types, application_in_state, authorised_to_view_application +from api.core.decorators import ( + allowed_application_types, + application_is_editable, + application_is_major_editable, + authorised_to_view_application, +) from lite_content.lite_api import strings from api.organisations.tests.factories import OrganisationFactory from api.staticdata.statuses.enums import CaseStatusEnum +from api.staticdata.statuses.libraries.get_case_status import get_case_status_by_status from api.staticdata.statuses.models import CaseStatus from test_helpers.clients import DataTestClient from api.users.models import ExporterUser, GovUser @@ -42,10 +51,13 @@ def a_view(request, *args, **kwargs): self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertTrue("This operation can only be used on applications of type:" in resp.content.decode("utf-8")) - def test_application_in_state_editable_success(self): + @parameterized.expand(get_case_statuses(read_only=False)) + def test_application_in_state_editable_success(self, editable_status): application = self.create_standard_application_case(self.organisation) + application.status = get_case_status_by_status(editable_status) + application.save() - @application_in_state(is_editable=True) + @application_is_editable def a_view(request, *args, **kwargs): return HttpResponse() @@ -58,7 +70,7 @@ def test_application_in_state_editable_failure(self): application.status = CaseStatus.objects.get(status=application_status) application.save() - @application_in_state(is_editable=True) + @application_is_editable def a_view(request, *args, **kwargs): return HttpResponse() @@ -73,7 +85,7 @@ def test_application_in_state_major_editable_success(self): application.status = CaseStatus.objects.get(status=CaseStatusEnum.major_editable_statuses()[0]) application.save() - @application_in_state(is_major_editable=True) + @application_is_major_editable def a_view(request, *args, **kwargs): return HttpResponse() @@ -85,7 +97,7 @@ def test_application_in_state_major_editable_failure(self): application.status = CaseStatus.objects.get(status=CaseStatusEnum.read_only_statuses()[0]) application.save() - @application_in_state(is_major_editable=True) + @application_is_major_editable def a_view(request, *args, **kwargs): return HttpResponse() diff --git a/api/core/tests/views.py b/api/core/tests/views.py index 75964d3f84..d5bbd7a9f4 100644 --- a/api/core/tests/views.py +++ b/api/core/tests/views.py @@ -12,6 +12,7 @@ class MisconfiguredParentFilterView(RetrieveAPIView): class ParentFilterView(RetrieveAPIView): filter_backends = (ParentFilter,) + lookup_url_kwarg = "child_pk" parent_filter_id_lookup_field = "parent_id" queryset = ChildModel.objects.all() serializer_class = ChildModelSerializer diff --git a/api/external_data/tests/__init__.py b/api/external_data/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/external_data/tests/denial_valid.csv b/api/external_data/tests/denial_valid.csv index 546972d534..032c5002c3 100644 --- a/api/external_data/tests/denial_valid.csv +++ b/api/external_data/tests/denial_valid.csv @@ -4,3 +4,5 @@ DN2000/0010,AB-CD-EF-300,Organisation Name 3,"2001 Street Name, City Name 3",Cou ,AB-XY-EF-900,The Widget Company,"2 Example Road, Example City",Example Country,Country Name X,"catch all",Extra Large Size Widget,Used in unknown industry,Risk of outcome 4,end_user DN3000/0000,AB-CD-EF-100,Organisation Name XYZ,"2000 Street Name, City Name 2",Country Name 2,Country Name 2,0A00200,Large Size Widget,Used in other industry,Risk of outcome 2,third_party DN4000/0000,AB-CD-EF-200,UK Issued,"2000 main road, some place",United Kingdom,Country Name 3,0A00300,Large Size Widget,Used in other industry,Risk of outcome 2,third_party +DN4102/0001,AB-CD-EF-400,Forward slash,"30/1 ltd",Country Name 4,Country Name 4,0A00504,Large Size Widget,Used in other industry,Risk of outcome 2,third_party +DN4103/0001,AB-CD-EF-500,c/o ltd,"forward slash",Country Name 5,Country Name 6,0A0050,Large Size Widget,Used in other industry,Risk of outcome 2,third_party diff --git a/api/external_data/tests/test_views.py b/api/external_data/tests/test_views.py index 9649641282..b99e73dfbf 100644 --- a/api/external_data/tests/test_views.py +++ b/api/external_data/tests/test_views.py @@ -9,7 +9,7 @@ from django.conf import settings from django.urls import reverse -from api.external_data import documents, models, serializers +from api.external_data import models, serializers from test_helpers.clients import DataTestClient denial_data_fields = [ @@ -24,6 +24,14 @@ class DenialViewSetTests(DataTestClient): + + def setUp(self): + super().setUp() + self.application = self.create_standard_application_case(self.organisation) + file_path = os.path.join(settings.BASE_DIR, "external_data/tests/denial_valid.csv") + with open(file_path, "rb") as f: + self.CSV_DENIAL_COUNT = len(f.readlines()) - 1 + def test_create_success(self): url = reverse("external_data:denial-list") file_path = os.path.join(settings.BASE_DIR, "external_data/tests/denial_valid.csv") @@ -32,8 +40,8 @@ def test_create_success(self): response = self.client.post(url, {"csv_file": content}, **self.gov_headers) self.assertEqual(response.status_code, 201) - self.assertEqual(models.DenialEntity.objects.count(), 5) - self.assertEqual(models.Denial.objects.count(), 5) + self.assertEqual(models.DenialEntity.objects.count(), self.CSV_DENIAL_COUNT) + self.assertEqual(models.Denial.objects.count(), self.CSV_DENIAL_COUNT) self.assertEqual( list( models.DenialEntity.objects.values( @@ -71,6 +79,18 @@ def test_create_success(self): "country": "Country Name 3", "entity_type": "third_party", }, + { + "name": "Forward slash", + "address": "30/1 ltd", + "country": "Country Name 4", + "entity_type": "third_party", + }, + { + "name": "c/o ltd", + "address": "forward slash", + "country": "Country Name 6", + "entity_type": "third_party", + }, ], ) self.assertEqual( @@ -121,6 +141,24 @@ def test_create_success(self): "end_use": "Used in other industry", "reason_for_refusal": "Risk of outcome 2", }, + { + "reference": "DN4102/0001", + "regime_reg_ref": "AB-CD-EF-400", + "notifying_government": "Country Name 4", + "denial_cle": "0A00504", + "item_description": "Large Size Widget", + "end_use": "Used in other industry", + "reason_for_refusal": "Risk of outcome 2", + }, + { + "reference": "DN4103/0001", + "regime_reg_ref": "AB-CD-EF-500", + "notifying_government": "Country Name 5", + "denial_cle": "0A0050", + "item_description": "Large Size Widget", + "end_use": "Used in other industry", + "reason_for_refusal": "Risk of outcome 2", + }, ], ) @@ -315,6 +353,14 @@ def test_create_sanitise_csv(self): class DenialSearchViewTests(DataTestClient): + + def setUp(self): + super().setUp() + self.application = self.create_standard_application_case(self.organisation) + file_path = os.path.join(settings.BASE_DIR, "external_data/tests/denial_valid.csv") + with open(file_path, "rb") as f: + self.CSV_DENIAL_COUNT = len(f.readlines()) - 1 + @pytest.mark.elasticsearch @parameterized.expand( [ @@ -330,7 +376,7 @@ def test_populate_denial_entity_objects(self, page_query): content = f.read() response = self.client.post(url, {"csv_file": content}, **self.gov_headers) self.assertEqual(response.status_code, 201) - self.assertEqual(models.DenialEntity.objects.count(), 5) + self.assertEqual(models.DenialEntity.objects.count(), self.CSV_DENIAL_COUNT) # Set one of them as revoked denial_entity = models.DenialEntity.objects.get(name="Organisation Name") @@ -398,8 +444,16 @@ def test_search_highlighting(self, search_query, expected_result): ({"search": "address:(Example)"}, ["AB-XY-EF-900"]), ({"search": "name:(UK Issued)"}, []), ({"search": "denial_cle:(catch all)"}, ["AB-XY-EF-900"]), - ({"search": "name:(Widget) OR address:(2001)"}, ["AB-CD-EF-300", "AB-XY-EF-900"]), + ( + {"search": "name:(Widget) OR address:(2001)"}, + [ + "AB-XY-EF-900", + "AB-CD-EF-300", + ], + ), ({"search": "name:(Organisation) AND address:(2000)"}, ["AB-CD-EF-100"]), + ({"search": "address:(30/1)"}, ["AB-CD-EF-400"]), + ({"search": "name:(c/o)"}, ["AB-CD-EF-500"]), ] ) def test_denial_entity_search(self, query, expected_items): @@ -418,7 +472,7 @@ def test_denial_entity_search(self, query, expected_items): self.assertEqual(response.status_code, 200) response_json = response.json() regime_reg_ref_results = [r["regime_reg_ref"] for r in response_json["results"]] - self.assertEqual(regime_reg_ref_results, expected_items) + self.assertListEqual(regime_reg_ref_results, expected_items) @pytest.mark.elasticsearch @parameterized.expand( diff --git a/api/goods/enums.py b/api/goods/enums.py index 08a9927d8a..01878a3cc9 100644 --- a/api/goods/enums.py +++ b/api/goods/enums.py @@ -14,6 +14,12 @@ class GoodStatus: (VERIFIED, "Verified"), ] + _archivable_statuses = [SUBMITTED, VERIFIED] + + @classmethod + def archivable_statuses(cls): + return cls._archivable_statuses + class ItemType: EQUIPMENT = "equipment" diff --git a/api/goods/serializers.py b/api/goods/serializers.py index 687bfc095f..679a74d677 100644 --- a/api/goods/serializers.py +++ b/api/goods/serializers.py @@ -847,6 +847,13 @@ class Meta: ) +class GoodArchiveRestoreSerializer(serializers.ModelSerializer): + + class Meta: + model = Good + fields = ("is_archived",) + + class GoodArchiveHistorySerializer(serializers.Serializer): is_archived = serializers.SerializerMethodField() actioned_on = serializers.SerializerMethodField() diff --git a/api/goods/tests/test_edit.py b/api/goods/tests/test_edit.py index 593d488dc1..2754737b18 100644 --- a/api/goods/tests/test_edit.py +++ b/api/goods/tests/test_edit.py @@ -7,6 +7,7 @@ from reversion.models import Version from api.goods.enums import ( + GoodStatus, GoodPvGraded, PvGrading, MilitaryUse, @@ -571,8 +572,12 @@ def test_edit_design_details_field_success(self): self.assertEqual(good.design_details, "design details") def test_edit_archive_status(self): - good = GoodFactory(organisation=self.organisation, item_category=ItemCategory.GROUP1_COMPONENTS) - url = reverse("goods:good_details", kwargs={"pk": str(good.id)}) + good = GoodFactory( + organisation=self.organisation, + status=GoodStatus.SUBMITTED, + item_category=ItemCategory.GROUP1_COMPONENTS, + ) + url = reverse("goods:archive_restore", kwargs={"pk": str(good.id)}) for version_count, is_archived in enumerate([True, False], start=1): request_data = {"is_archived": is_archived} @@ -580,7 +585,7 @@ def test_edit_archive_status(self): response = self.client.put(url, request_data, **self.exporter_headers) self.assertEqual(response.status_code, status.HTTP_200_OK) response = response.json() - self.assertEqual(response["good"]["is_archived"], is_archived) + self.assertEqual(response["is_archived"], is_archived) good.refresh_from_db() self.assertEqual(good.is_archived, is_archived) @@ -593,6 +598,70 @@ def test_edit_archive_status(self): for version in versions: self.assertEqual(version.revision.user, self.exporter_user.baseuser_ptr) + @parameterized.expand( + [ + [ + {"name": "Rifle", "is_archived": None}, + {"is_archived": True}, + {"name": "Rifle", "is_archived": True}, + ], + [ + {"name": "Rifle", "is_archived": True}, + {"is_archived": False}, + {"name": "Rifle", "is_archived": False}, + ], + [ + {"name": "Rifle", "is_archived": None}, + {"name": "Rifle updated", "is_archived": False}, + {"name": "Rifle", "is_archived": False}, + ], + ] + ) + def test_only_archive_field_can_be_updated(self, initial, data, expected): + good = GoodFactory(organisation=self.organisation, status=GoodStatus.SUBMITTED, **initial) + url = reverse("goods:archive_restore", kwargs={"pk": str(good.id)}) + + response = self.client.put(url, data, **self.exporter_headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = response.json() + + good.refresh_from_db() + actual = {"name": good.name, "is_archived": good.is_archived} + self.assertEqual(actual, expected) + + @parameterized.expand( + [ + [ + GoodStatus.DRAFT, + {"is_archived": True}, + status.HTTP_404_NOT_FOUND, + {"is_archived": None}, + ], + [ + GoodStatus.SUBMITTED, + {"is_archived": True}, + status.HTTP_200_OK, + {"is_archived": True}, + ], + [ + GoodStatus.VERIFIED, + {"is_archived": True}, + status.HTTP_200_OK, + {"is_archived": True}, + ], + ] + ) + def test_good_with_archivable_status_can_only_be_archived(self, good_status, data, expected_status_code, expected): + good = GoodFactory(organisation=self.organisation, status=good_status) + url = reverse("goods:archive_restore", kwargs={"pk": str(good.id)}) + + response = self.client.put(url, data, **self.exporter_headers) + self.assertEqual(response.status_code, expected_status_code) + + good.refresh_from_db() + actual = {"is_archived": good.is_archived} + self.assertEqual(actual, expected) + class GoodsAttachingTests(DataTestClient): def setUp(self): diff --git a/api/goods/tests/test_submitting_goods.py b/api/goods/tests/test_submitting_goods.py index c10e2cf304..b2e8bf8a1e 100644 --- a/api/goods/tests/test_submitting_goods.py +++ b/api/goods/tests/test_submitting_goods.py @@ -138,11 +138,8 @@ def test_submitted_good_can_be_edited(self): good = Good.objects.get() url = reverse("goods:good", kwargs={"pk": good.id}) - response = self.client.put(url, {"is_archived": True}, **self.exporter_headers) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - good.refresh_from_db() - self.assertTrue(good.is_archived) + response = self.client.put(url, {"name": "Updated name"}, **self.exporter_headers) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) def test_unsubmitted_good_can_be_edited(self): """ @@ -197,3 +194,16 @@ def test_deleted_good_removed_from_all_drafts_they_existed_in(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(Good.objects.all().count(), 0) self.assertEqual(GoodOnApplication.objects.count(), 0) + + def test_submitted_good_cannot_be_edited_good_details_url(self): + """ + Tests that the good cannot be edited after submission + """ + draft = self.create_draft_standard_application(self.organisation) + self.submit_application(application=draft) + + good = Good.objects.get() + url = reverse("goods:good_details", kwargs={"pk": good.id}) + + response = self.client.put(url, {"name": "Updated name"}, **self.exporter_headers) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/api/goods/tests/test_views.py b/api/goods/tests/test_views.py index a45af89412..1cee7173a4 100644 --- a/api/goods/tests/test_views.py +++ b/api/goods/tests/test_views.py @@ -178,8 +178,8 @@ def test_goods_list_excludes_archived_goods(self): # archive one good good = all_goods[-1] - edit_url = reverse("goods:good", kwargs={"pk": str(good.id)}) - response = self.client.put(edit_url, {"is_archived": True}, **self.exporter_headers) + archive_restore_url = reverse("goods:archive_restore", kwargs={"pk": str(good.id)}) + response = self.client.put(archive_restore_url, {"is_archived": True}, **self.exporter_headers) self.assertEqual(response.status_code, status.HTTP_200_OK) # check archive good is excluded @@ -228,3 +228,16 @@ def test_view_archived_goods_filter_by_control_list_entry(self, control_list_ent self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response_data), count) + + def test_cannot_archive_good_from_other_organisation(self): + good = GoodFactory(organisation=self.organisation, status=GoodStatus.SUBMITTED) + url = reverse("goods:archive_restore", kwargs={"pk": str(good.id)}) + + headers = self.exporter_headers.copy() + headers["HTTP_ORGANISATION_ID"] = str(good.id) + + response = self.client.put(url, {"is_archived": True}, **headers) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + good.refresh_from_db() + self.assertIsNone(good.is_archived) diff --git a/api/goods/urls.py b/api/goods/urls.py index 554e223f4c..36eab53aa9 100644 --- a/api/goods/urls.py +++ b/api/goods/urls.py @@ -6,7 +6,6 @@ urlpatterns = [ path("", views.GoodList.as_view(), name="goods"), - path("archived-goods/", views.ArchivedGoodList.as_view(), name="archived_goods"), path("/", views.GoodOverview.as_view(), name="good"), path("/attaching/", views.GoodAttaching.as_view(), name="good_attaching"), path("/details/", views.GoodTAUDetails.as_view(), name="good_details"), @@ -39,4 +38,6 @@ views.DocumentGoodOnApplicationInternalDetailView.as_view(), name="document_internal_good_on_application_detail", ), + path("archived-goods/", views.ArchivedGoodList.as_view(), name="archived_goods"), + path("/archive-restore/", views.GoodArchiveRestore.as_view(), name="archive_restore"), ] diff --git a/api/goods/views.py b/api/goods/views.py index 1b204d187e..a0f1e7334a 100644 --- a/api/goods/views.py +++ b/api/goods/views.py @@ -4,7 +4,7 @@ from django.http import JsonResponse from django.shortcuts import get_object_or_404 from rest_framework import status -from rest_framework.generics import ListAPIView, ListCreateAPIView +from rest_framework.generics import ListAPIView, ListCreateAPIView, UpdateAPIView from rest_framework.views import APIView from api.applications.models import ( @@ -46,6 +46,7 @@ GoodDocumentAvailabilitySerializer, GoodDocumentSensitivitySerializer, TinyGoodDetailsSerializer, + GoodArchiveRestoreSerializer, ) from api.applications.serializers.good import ( GoodOnApplicationInternalDocumentCreateSerializer, @@ -194,6 +195,19 @@ def get_queryset(self): return queryset.order_by("-updated_at") +class GoodArchiveRestore(UpdateAPIView): + authentication_classes = (ExporterAuthentication,) + serializer_class = GoodArchiveRestoreSerializer + + def get_queryset(self): + organisation = get_request_user_organisation_id(self.request) + + return Good.objects.filter( + organisation=organisation, + status__in=GoodStatus.archivable_statuses(), + ) + + class GoodDocumentAvailabilityCheck(APIView): """ Check document is attached to application good/product @@ -273,6 +287,9 @@ def put(self, request, pk): good = get_good(pk) data = request.data.copy() + if good.status != GoodStatus.DRAFT: + raise BadRequestError({"non_field_errors": [strings.Goods.CANNOT_EDIT_GOOD]}) + # return bad request if trying to edit software_or_technology details outside of category group 3 if good.item_category in ItemCategory.group_one and "software_or_technology_details" in data: raise BadRequestError({"non_field_errors": [strings.Goods.CANNOT_SET_DETAILS_ERROR]}) @@ -285,9 +302,6 @@ def put(self, request, pk): if good.item_category not in ItemCategory.group_two and data.get("firearm_details"): check_if_firearm_details_edited_on_unsupported_good(data) - if good.status == GoodStatus.SUBMITTED: - raise BadRequestError({"non_field_errors": [strings.Goods.CANNOT_EDIT_GOOD]}) - # check if the user is registered firearm dealer if good.item_category == ItemCategory.GROUP2_FIREARMS and good.firearm_details.type in FIREARMS_CORE_TYPES: is_rfd = has_valid_certificate( @@ -346,6 +360,12 @@ def put(self, request, pk): if good.organisation_id != get_request_user_organisation_id(request): raise PermissionDenied() + if good.status != GoodStatus.DRAFT: + return JsonResponse( + data={"errors": f"Good {good.name} is already used on a submitted application and cannot be edited"}, + status=status.HTTP_400_BAD_REQUEST, + ) + data = request.data.copy() data["organisation"] = get_request_user_organisation_id(request) diff --git a/api/gov_users/tests/test_gov_user_notifications.py b/api/gov_users/tests/test_gov_user_notifications.py index 001bd884d8..c6ada47e9f 100644 --- a/api/gov_users/tests/test_gov_user_notifications.py +++ b/api/gov_users/tests/test_gov_user_notifications.py @@ -13,7 +13,8 @@ class GovUserNotificationTests(DataTestClient): def setUp(self): super().setUp() - self.case = self.create_standard_application_case(self.organisation, "Case") + self.case = self.create_draft_standard_application(self.organisation, "Case") + self.set_application_status(self.case, CaseStatusEnum.APPLICANT_EDITING) self.audit_content_type = ContentType.objects.get_for_model(Audit) def test_edit_application_creates_new_audit_notification_success(self): diff --git a/api/search/product/tests/test_helpers.py b/api/search/product/tests/test_helpers.py index dcb8af9874..cc280c0fee 100644 --- a/api/search/product/tests/test_helpers.py +++ b/api/search/product/tests/test_helpers.py @@ -49,7 +49,7 @@ { "good": { "name": "Thermal camera", - "part_number": "IMG-1300", + "part_number": "IMG/1300", "is_good_controlled": True, "control_list_entries": ["6A005"], }, diff --git a/api/search/product/tests/test_views.py b/api/search/product/tests/test_views.py index 12d556d072..e344536efa 100644 --- a/api/search/product/tests/test_views.py +++ b/api/search/product/tests/test_views.py @@ -110,7 +110,8 @@ def test_product_search_by_name(self, query, expected_count, expected_name): [ ({"search": "ABC"}, 0), ({"search": "ABC-123"}, 1), - ({"search": "IMG-1300"}, 1), + ({"search": "IMG/1300"}, 1), + ({"search": "IMG-1300"}, 0), ({"search": "867-"}, 0), ({"search": "867-5309"}, 1), ({"search": "H2SO4"}, 1), diff --git a/api/staticdata/management/SeedCommand.py b/api/staticdata/management/SeedCommand.py index 8d9a9e7baf..9c20bcc014 100644 --- a/api/staticdata/management/SeedCommand.py +++ b/api/staticdata/management/SeedCommand.py @@ -69,7 +69,7 @@ def read_csv(filename: str): return list(reader) @staticmethod - def update_or_create(model: models.Model, rows: list): + def update_or_create(model: models.Model, rows: list, exclude=None): """ Takes a list of dicts with an id field and other properties applicable to a given model. If an object with the given id exists, it will update all @@ -77,20 +77,28 @@ def update_or_create(model: models.Model, rows: list): :param model: A given Django model to populate :param rows: A list of dictionaries (csv entries) to populate to the model """ + exclude = exclude or [] for row in rows: obj_id = row["id"] obj = model.objects.filter(id=obj_id) if not obj.exists(): + for key in exclude: + if key in row: + del row[key] model.objects.create(**row) if not settings.SUPPRESS_TEST_OUTPUT: print(f"CREATED {model.__name__}: {dict(row)}") else: - SeedCommand.update_if_not_equal(obj, row) + SeedCommand.update_if_not_equal(obj, row, exclude) @staticmethod - def update_if_not_equal(obj: QuerySet, row: dict): + def update_if_not_equal(obj: QuerySet, row: dict, exclude=None): # Can not delete the "id" key-value from `rows` as it will manipulate the data which is later used in # `delete_unused_objects` + exclude = exclude or [] + for key in exclude: + if key in row: + del row[key] attributes = {k: v for k, v in row.items() if k != "id"} obj = obj.exclude(**attributes) if obj.exists(): diff --git a/api/staticdata/management/commands/seedcasestatuses.py b/api/staticdata/management/commands/seedcasestatuses.py index fe7567377b..bb027f3466 100644 --- a/api/staticdata/management/commands/seedcasestatuses.py +++ b/api/staticdata/management/commands/seedcasestatuses.py @@ -46,7 +46,7 @@ def operation(self, *args, **options): if row["workflow_sequence"] == "None": row["workflow_sequence"] = None - self.update_or_create(CaseStatus, status_csv) + self.update_or_create(CaseStatus, status_csv, exclude=["is_terminal", "is_read_only"]) case_type_list = CaseTypeEnum.CASE_TYPE_LIST diff --git a/api/staticdata/management/commands/seedcountries.py b/api/staticdata/management/commands/seedcountries.py index e15319eb30..049d1533bf 100644 --- a/api/staticdata/management/commands/seedcountries.py +++ b/api/staticdata/management/commands/seedcountries.py @@ -28,7 +28,7 @@ def operation(self, *args, **options): if not settings.SUPPRESS_TEST_OUTPUT: print(f"CREATED {Country.__name__}: {dict(row)}") else: - SeedCommand.update_if_not_equal(obj, row) + SeedCommand.update_if_not_equal(obj, row, exclude=["is_terminal", "is_read_only"]) ids = [row["id"] for row in csv] for obj in Country.objects.all(): diff --git a/api/staticdata/statuses/enums.py b/api/staticdata/statuses/enums.py index 801b6ca62b..f765b035b1 100644 --- a/api/staticdata/statuses/enums.py +++ b/api/staticdata/statuses/enums.py @@ -40,27 +40,13 @@ class CaseStatusEnum: _system_status = [DRAFT] - _read_only_statuses = [ - APPEAL_REVIEW, - APPEAL_FINAL_REVIEW, - CHANGE_UNDER_REVIEW, - CHANGE_UNDER_FINAL_REVIEW, - CLOSED, - DEREGISTERED, - FINALISED, - REGISTERED, - REOPENED_DUE_TO_ORG_CHANGES, - UNDER_ECJU_REVIEW, - UNDER_FINAL_REVIEW, - REVOKED, - SURRENDERED, - SUSPENDED, - WITHDRAWN, - OGD_ADVICE, - OGD_CONSOLIDATION, - SUPERSEDED_BY_AMENDMENT, + _writeable_statuses = [ + DRAFT, + APPLICANT_EDITING, ] + _can_invoke_major_edit_statuses = [SUBMITTED, INITIAL_CHECKS, UNDER_REVIEW, REOPENED_FOR_CHANGES] + _major_editable_statuses = [APPLICANT_EDITING, DRAFT] _terminal_statuses = [ @@ -125,6 +111,8 @@ class CaseStatusEnum: (OGD_ADVICE, "OGD Advice"), (OGD_CONSOLIDATION, "OGD Consolidation"), (SUPERSEDED_BY_AMENDMENT, "Superseded by amendment"), + (FINAL_REVIEW_COUNTERSIGN, "Final review countersign"), + (FINAL_REVIEW_SECOND_COUNTERSIGN, "Final review second countersign"), ] priority = { @@ -166,27 +154,7 @@ class CaseStatusEnum: @classmethod def get_choices(cls): - lu_countersign_statuses = [] - lu_countersign_statuses.extend( - [ - (cls.FINAL_REVIEW_COUNTERSIGN, "Final review countersign"), - (cls.FINAL_REVIEW_SECOND_COUNTERSIGN, "Final review second countersign"), - ] - ) - - return cls.choices + lu_countersign_statuses - - @classmethod - def get_read_only_choices(cls): - lu_countersign_statuses = [] - lu_countersign_statuses.extend( - [ - cls.FINAL_REVIEW_COUNTERSIGN, - cls.FINAL_REVIEW_SECOND_COUNTERSIGN, - ] - ) - - return cls._read_only_statuses + lu_countersign_statuses + return cls.choices @classmethod def get_text(cls, status): @@ -204,7 +172,11 @@ def get_value(cls, status): @classmethod def is_read_only(cls, status): - return status in cls.get_read_only_choices() + return status in cls.read_only_statuses() + + @classmethod + def is_editable(cls, status): + return not cls.is_read_only(status) @classmethod def is_terminal(cls, status): @@ -216,12 +188,24 @@ def is_system_status(cls, status): @classmethod def read_only_statuses(cls): - return cls.get_read_only_choices() + return list(set(cls.all()) - set(cls._writeable_statuses)) @classmethod def major_editable_statuses(cls): return cls._major_editable_statuses + @classmethod + def is_major_editable_status(cls, status): + return status in cls._major_editable_statuses + + @classmethod + def can_invoke_major_edit_statuses(cls): + return cls._can_invoke_major_edit_statuses + + @classmethod + def can_invoke_major_edit(cls, status): + return status in cls._can_invoke_major_edit_statuses + @classmethod def terminal_statuses(cls): return cls._terminal_statuses @@ -237,7 +221,7 @@ def as_list(cls): @classmethod def all(cls): - return [k for k, _ in [*cls.get_choices(), (cls.DRAFT, "Draft")]] + return [getattr(cls, param) for param in dir(cls) if param.isupper()] class CaseStatusIdEnum: diff --git a/api/staticdata/statuses/migrations/0014_remove_casestatus_is_read_only_and_more.py b/api/staticdata/statuses/migrations/0014_remove_casestatus_is_read_only_and_more.py new file mode 100644 index 0000000000..57f14c12e9 --- /dev/null +++ b/api/staticdata/statuses/migrations/0014_remove_casestatus_is_read_only_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.13 on 2024-07-01 16:15 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("statuses", "0013_add_superseded_by_amendment_status"), + ] + + operations = [ + migrations.RemoveField( + model_name="casestatus", + name="is_read_only", + ), + migrations.RemoveField( + model_name="casestatus", + name="is_terminal", + ), + ] diff --git a/api/staticdata/statuses/models.py b/api/staticdata/statuses/models.py index a4c9fbdce0..c6a950274d 100644 --- a/api/staticdata/statuses/models.py +++ b/api/staticdata/statuses/models.py @@ -1,9 +1,15 @@ import uuid from django.db import models +from django.db.models import Q +from queryable_properties.managers import QueryablePropertiesManager +from queryable_properties.properties import queryable_property -class CaseStatusManager(models.Manager): +from api.staticdata.statuses.enums import CaseStatusEnum + + +class CaseStatusManager(QueryablePropertiesManager): def get_by_natural_key(self, status): return self.get(status=status) @@ -13,12 +19,36 @@ class CaseStatus(models.Model): status = models.CharField(max_length=50, unique=True) priority = models.PositiveSmallIntegerField(null=False, blank=False) workflow_sequence = models.PositiveSmallIntegerField(null=True) - is_read_only = models.BooleanField(blank=False, null=True) - is_terminal = models.BooleanField(blank=False, null=True) next_workflow_status = models.ForeignKey("CaseStatus", on_delete=models.DO_NOTHING, null=True, blank=True) objects = CaseStatusManager() + @queryable_property + def is_terminal(self): + return CaseStatusEnum.is_terminal(self.status) + + @is_terminal.filter(boolean=True) + @classmethod + def is_terminal(cls): + return Q(status__in=CaseStatusEnum.terminal_statuses()) + + @queryable_property + def is_read_only(self): + return CaseStatusEnum.is_read_only(self.status) + + @is_read_only.filter(boolean=True) + @classmethod + def is_read_only(cls): + return Q(status__in=CaseStatusEnum.read_only_statuses()) + + @property + def is_major_editable(self): + return CaseStatusEnum.is_major_editable_status(self.status) + + @property + def can_invoke_major_editable(self): + return CaseStatusEnum.can_invoke_major_edit(self.status) + def natural_key(self): return (self.status,) diff --git a/api/staticdata/statuses/serializers.py b/api/staticdata/statuses/serializers.py index b74114ab62..8d854bd163 100644 --- a/api/staticdata/statuses/serializers.py +++ b/api/staticdata/statuses/serializers.py @@ -28,6 +28,17 @@ class Meta: ) +class CaseStatusPropertiesSerializer(serializers.ModelSerializer): + class Meta: + model = CaseStatus + fields = ( + "is_terminal", + "is_read_only", + "is_major_editable", + "can_invoke_major_editable", + ) + + class CaseSubStatusSerializer(serializers.ModelSerializer): class Meta: model = CaseSubStatus diff --git a/api/staticdata/statuses/tests/__init__.py b/api/staticdata/statuses/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/staticdata/statuses/tests/test_views.py b/api/staticdata/statuses/tests/test_views.py new file mode 100644 index 0000000000..9da28cf79b --- /dev/null +++ b/api/staticdata/statuses/tests/test_views.py @@ -0,0 +1,35 @@ +import json + +from rest_framework import status + +from django.urls import reverse + +from test_helpers.clients import DataTestClient + +from api.staticdata.statuses.factories import CaseStatusFactory + + +class StatusPropertiesTests(DataTestClient): + def test_get_status(self): + case_status = CaseStatusFactory( + status="madeup", + ) + + url = reverse( + "staticdata:statuses:case_status_properties", + kwargs={ + "status": case_status.status, + }, + ) + response = self.client.get(url, **self.exporter_headers) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + json.loads(response.content), + { + "is_read_only": case_status.is_read_only, + "is_terminal": case_status.is_terminal, + "is_major_editable": case_status.is_major_editable, + "can_invoke_major_editable": case_status.can_invoke_major_editable, + }, + ) diff --git a/api/staticdata/statuses/views.py b/api/staticdata/statuses/views.py index 7ff7b60316..a3f97bd803 100644 --- a/api/staticdata/statuses/views.py +++ b/api/staticdata/statuses/views.py @@ -1,10 +1,13 @@ from django.http import JsonResponse + +from rest_framework.generics import RetrieveAPIView from rest_framework.status import HTTP_200_OK from rest_framework.views import APIView from api.cases.views.search.service import get_case_status_list from api.core.authentication import SharedAuthentication from api.staticdata.statuses.models import CaseStatus +from api.staticdata.statuses.serializers import CaseStatusPropertiesSerializer class StatusesAsList(APIView): @@ -15,12 +18,8 @@ def get(self, request): return JsonResponse(data={"statuses": statuses}, status=HTTP_200_OK) -class StatusProperties(APIView): +class StatusProperties(RetrieveAPIView): authentication_classes = (SharedAuthentication,) - - def get(self, request, status): - """Return is_read_only and is_terminal properties for a case status.""" - status_properties = CaseStatus.objects.filter(status=status).values_list("is_read_only", "is_terminal")[0] - return JsonResponse( - data={"is_read_only": status_properties[0], "is_terminal": status_properties[1]}, status=HTTP_200_OK - ) + queryset = CaseStatus.objects.all() + serializer_class = CaseStatusPropertiesSerializer + lookup_field = "status" diff --git a/makefile b/makefile index 2748baa254..0bb2c9717c 100644 --- a/makefile +++ b/makefile @@ -67,3 +67,6 @@ stop-e2e: test: ./manage.py test + +rebuild-search: + docker exec -it api pipenv run ./manage.py search_index -f --rebuild diff --git a/test_helpers/clients.py b/test_helpers/clients.py index b754d3938a..668724eba8 100644 --- a/test_helpers/clients.py +++ b/test_helpers/clients.py @@ -3,7 +3,7 @@ import uuid import sys from django.utils import timezone -from typing import List, Tuple +from typing import Tuple import django.utils.timezone from django.db import connection @@ -938,6 +938,10 @@ def get_object_from_default_bucket(self, key): Key=key, ) + def set_application_status(self, application, status_name): + application.status = get_case_status_by_status(status_name) + application.save() + @pytest.mark.performance # we need to set debug to true otherwise we can't see the amount of queries