diff --git a/api/applications/tests/factories.py b/api/applications/tests/factories.py index 90463f10eb..43423bafb3 100644 --- a/api/applications/tests/factories.py +++ b/api/applications/tests/factories.py @@ -1,5 +1,5 @@ import factory - +import factory.fuzzy from faker import Faker from api.applications.enums import ApplicationExportType, ApplicationExportLicenceOfficialType @@ -14,7 +14,8 @@ GoodOnApplicationInternalDocument, StandardApplication, ) -from api.cases.enums import CaseTypeEnum +from api.cases.enums import AdviceLevel, AdviceType, CaseTypeEnum +from api.cases.models import Advice from api.external_data.models import Denial, DenialEntity, SanctionMatch from api.documents.tests.factories import DocumentFactory from api.staticdata.statuses.models import CaseStatus @@ -240,3 +241,26 @@ def _create(cls, model_class, *args, **kwargs): PartyOnApplicationFactory(application=obj, party=ThirdPartyFactory(organisation=obj.organisation)) return obj + + +class AdviceFactory(factory.django.DjangoModelFactory): + user = factory.SubFactory(GovUserFactory) + case = factory.SubFactory(StandardApplicationFactory) + type = factory.fuzzy.FuzzyChoice(AdviceType.choices, getter=lambda t: t[0]) + level = factory.fuzzy.FuzzyChoice(AdviceLevel.choices, getter=lambda t: t[0]) + + class Meta: + model = Advice + + +class FinalAdviceOnApplicationFactory(StandardApplicationFactory): + + @classmethod + def _create(cls, model_class, *args, **kwargs): + obj = model_class(*args, **kwargs) + obj.status = get_case_status_by_status(CaseStatusEnum.UNDER_FINAL_REVIEW) + obj.save() + + AdviceFactory(case=obj, level=AdviceLevel.FINAL) + + return obj diff --git a/api/conf/exporter_urls.py b/api/conf/exporter_urls.py index d14a5d0b5e..c95eee8a7b 100644 --- a/api/conf/exporter_urls.py +++ b/api/conf/exporter_urls.py @@ -2,4 +2,5 @@ urlpatterns = [ path("applications/", include("api.applications.exporter.urls")), + path("static/", include("api.staticdata.exporter.urls")), ] diff --git a/api/organisations/serializers.py b/api/organisations/serializers.py index a88d21c999..6d5469c423 100644 --- a/api/organisations/serializers.py +++ b/api/organisations/serializers.py @@ -388,9 +388,7 @@ def get_flags(self, instance): return list(instance.flags.values("id", "name", "colour", "label", "priority")) def get_documents(self, instance): - queryset = instance.document_on_organisations.order_by("document_type", "-expiry_date").distinct( - "document_type" - ) + queryset = instance.document_on_organisations.order_by("document_type", "-created_at").distinct("document_type") serializer = DocumentOnOrganisationSerializer(queryset, many=True) return serializer.data diff --git a/api/organisations/tests/test_documents.py b/api/organisations/tests/test_documents.py index 0908215396..dd231f39b7 100644 --- a/api/organisations/tests/test_documents.py +++ b/api/organisations/tests/test_documents.py @@ -1,4 +1,4 @@ -import datetime +from datetime import datetime, timedelta from unittest import mock @@ -29,9 +29,12 @@ def test_create_organisation_document(self, mock_virus_scan, mock_s3_operations_ mock_virus_scan.return_value = False url = reverse("organisations:documents", kwargs={"pk": self.organisation.pk}) + + future_date = datetime.now() + timedelta(days=365) + future_date_formatted = future_date.strftime("%Y-%m-%d") data = { "document": {"name": "some-document", "s3_key": "some-document", "size": 476}, - "expiry_date": "2026-01-01", + "expiry_date": future_date_formatted, "reference_code": "123", "document_type": OrganisationDocumentType.FIREARM_SECTION_FIVE, } @@ -46,7 +49,8 @@ def test_create_organisation_document(self, mock_virus_scan, mock_s3_operations_ self.assertEqual(instance.document.s3_key, "some-document") self.assertEqual(instance.reference_code, "123") self.assertEqual(instance.document.size, 476) - self.assertEqual(instance.expiry_date, datetime.date(2026, 1, 1)) + + self.assertEqual(instance.expiry_date.strftime("%Y-%m-%d"), future_date_formatted) self.assertEqual(instance.document_type, OrganisationDocumentType.FIREARM_SECTION_FIVE) self.assertEqual(instance.organisation, self.organisation) @@ -58,9 +62,10 @@ def test_create_organisation_document_other_organisation(self, mock_virus_scan, other_organisation, _ = self.create_organisation_with_exporter_user() url = reverse("organisations:documents", kwargs={"pk": other_organisation.pk}) + future_date = datetime.now() + timedelta(days=365) data = { "document": {"name": "some-document", "s3_key": "some-document", "size": 476}, - "expiry_date": "2026-01-01", + "expiry_date": future_date, "reference_code": "123", "document_type": OrganisationDocumentType.FIREARM_SECTION_FIVE, } @@ -74,18 +79,21 @@ def test_list_organisation_documents(self): document__s3_key="thisisakey", document__safe=True, organisation=self.organisation, + expiry_date=datetime.now() + timedelta(days=4 * 365), ) DocumentOnOrganisationFactory.create( document__name="some-document-two", document__s3_key="thisisakey", document__safe=True, organisation=self.organisation, + expiry_date=datetime.now() + timedelta(days=3 * 365), ) DocumentOnOrganisationFactory.create( document__name="some-document-three", document__s3_key="thisisakey", document__safe=True, organisation=self.organisation, + expiry_date=datetime.now() + timedelta(days=2 * 365), ) other_organisation, _ = self.create_organisation_with_exporter_user() DocumentOnOrganisationFactory.create( @@ -93,6 +101,7 @@ def test_list_organisation_documents(self): document__s3_key="thisisakey", document__safe=True, organisation=other_organisation, + expiry_date=datetime.now() + timedelta(days=365), ) url = reverse("organisations:documents", kwargs={"pk": self.organisation.pk}) @@ -111,9 +120,11 @@ def test_list_organisation_documents_other_organisation(self): self.assertEqual(response.status_code, 403) def test_retrieve_organisation_documents(self): + future_date = datetime.now() + timedelta(days=365) + future_date_formatted = future_date.strftime("%d %B %Y") document_on_application = DocumentOnOrganisationFactory.create( organisation=self.organisation, - expiry_date=datetime.date(2026, 1, 1), + expiry_date=future_date, document_type=OrganisationDocumentType.FIREARM_SECTION_FIVE, reference_code="123", document__name="some-document-one", @@ -128,14 +139,13 @@ def test_retrieve_organisation_documents(self): ) response = self.client.get(url, **self.exporter_headers) - self.assertEqual(response.status_code, 200) self.assertEqual( response.json(), { "id": str(document_on_application.pk), - "expiry_date": "01 January 2026", + "expiry_date": future_date_formatted, "document_type": "section-five-certificate", "organisation": str(self.organisation.id), "is_expired": False, @@ -151,11 +161,46 @@ def test_retrieve_organisation_documents(self): }, ) + def test_retrieve_documents_on_organisation_details(self): + # We create 2 documents on the organisation + + DocumentOnOrganisationFactory.create( + organisation=self.organisation, + expiry_date=datetime.now() + timedelta(days=365), + document_type=OrganisationDocumentType.REGISTERED_FIREARM_DEALER_CERTIFICATE, + reference_code="123", + document__name="some-document-one", + document__s3_key="some-document-one", + document__size=476, + document__safe=True, + created_at=datetime.now() - timedelta(hours=1), + ) + DocumentOnOrganisationFactory.create( + organisation=self.organisation, + expiry_date=datetime.now() + timedelta(days=365), + document_type=OrganisationDocumentType.REGISTERED_FIREARM_DEALER_CERTIFICATE, + reference_code="321", + document__name="some-document-two", + document__s3_key="some-document-two", + document__size=476, + document__safe=True, + created_at=datetime.now(), + ) + + url = reverse("organisations:organisation", kwargs={"pk": self.organisation.pk}) + + response = self.client.get(url, **self.exporter_headers) + + # We check that the first document delivered to organisation details is the most recently created + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["documents"][0]["document"]["name"], "some-document-two") + def test_retrieve_organisation_documents_invalid_organisation(self): other_organisation, _ = self.create_organisation_with_exporter_user() document_on_application = DocumentOnOrganisationFactory.create( organisation=other_organisation, - expiry_date=datetime.date(2026, 1, 1), + expiry_date=datetime.now() + timedelta(days=365), document_type=OrganisationDocumentType.FIREARM_SECTION_FIVE, reference_code="123", document__name="some-document-one", @@ -215,11 +260,12 @@ def test_update_organisation_documents(self): "document_on_application_pk": document_on_application.pk, }, ) - + future_date = datetime.now() + timedelta(days=365) + future_date_formatted = future_date.strftime("%Y-%m-%d") response = self.client.put( url, data={ - "expiry_date": "2030-12-12", + "expiry_date": future_date_formatted, "reference_code": "567", }, **self.exporter_headers, @@ -228,8 +274,8 @@ def test_update_organisation_documents(self): document_on_application.refresh_from_db() self.assertEqual( - document_on_application.expiry_date, - datetime.date(2030, 12, 12), + document_on_application.expiry_date.strftime("%Y-%m-%d"), + future_date_formatted, ) self.assertEqual( document_on_application.reference_code, @@ -240,6 +286,7 @@ def test_update_organisation_documents_other_organisation(self): other_organisation, _ = self.create_organisation_with_exporter_user() document_on_application = DocumentOnOrganisationFactory.create(organisation=other_organisation) + future_date = datetime.now() + timedelta(days=365) url = reverse( "organisations:documents", kwargs={ @@ -251,7 +298,7 @@ def test_update_organisation_documents_other_organisation(self): response = self.client.put( url, data={ - "expiry_date": "2030-12-12", + "expiry_date": future_date, "reference_code": "567", }, **self.exporter_headers, diff --git a/api/staticdata/exporter/control_list_entries/serializers.py b/api/staticdata/exporter/control_list_entries/serializers.py new file mode 100644 index 0000000000..a16605bcd6 --- /dev/null +++ b/api/staticdata/exporter/control_list_entries/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from api.staticdata.control_list_entries.models import ControlListEntry + + +class ControlListEntriesListSerializer(serializers.ModelSerializer): + class Meta: + model = ControlListEntry + fields = ("rating", "text") diff --git a/api/staticdata/exporter/control_list_entries/tests/test_views.py b/api/staticdata/exporter/control_list_entries/tests/test_views.py new file mode 100644 index 0000000000..ef7770e185 --- /dev/null +++ b/api/staticdata/exporter/control_list_entries/tests/test_views.py @@ -0,0 +1,48 @@ +from rest_framework import status +from rest_framework.reverse import reverse + +from api.staticdata.control_list_entries.models import ControlListEntry +from api.staticdata.control_list_entries.factories import ControlListEntriesFactory +from test_helpers.clients import DataTestClient + + +class ControlListEntriesListTests(DataTestClient): + def setUp(self): + self.url = reverse("exporter_staticdata:control_list_entries:control_list_entries") + super().setUp() + + def test_list_view_success(self): + response = self.client.get(self.url, **self.exporter_headers) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assertTrue(len(response.json()) > 0) + + for cle in response.json(): + self.assertEqual(list(cle.keys()), ["rating", "text"]) + + def test_list_view_failure_bad_headers(self): + response = self.client.get(self.url, **self.gov_headers) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_view_success_exact_response(self): + # Set up empty CLE db table for this test only + ControlListEntry.objects.all().delete() + + cle_1 = ControlListEntriesFactory(rating="ABC123", controlled=True) + cle_2 = ControlListEntriesFactory(rating="1Z101", controlled=True) + cle_3 = ControlListEntriesFactory(rating="ZXYW", controlled=True) + + response = self.client.get(self.url, **self.exporter_headers) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assertEqual( + response.json(), + [ + {"rating": cle_1.rating, "text": cle_1.text}, + {"rating": cle_2.rating, "text": cle_2.text}, + {"rating": cle_3.rating, "text": cle_3.text}, + ], + ) diff --git a/api/staticdata/exporter/control_list_entries/urls.py b/api/staticdata/exporter/control_list_entries/urls.py new file mode 100644 index 0000000000..1e39f4ae63 --- /dev/null +++ b/api/staticdata/exporter/control_list_entries/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from api.staticdata.exporter.control_list_entries import views + +app_name = "control_list_entries" + +urlpatterns = [ + path("", views.ControlListEntriesList.as_view(), name="control_list_entries"), +] diff --git a/api/staticdata/exporter/control_list_entries/views.py b/api/staticdata/exporter/control_list_entries/views.py new file mode 100644 index 0000000000..d3097850a7 --- /dev/null +++ b/api/staticdata/exporter/control_list_entries/views.py @@ -0,0 +1,12 @@ +from rest_framework import generics + +from api.core.authentication import ExporterAuthentication +from api.staticdata.control_list_entries.models import ControlListEntry +from api.staticdata.exporter.control_list_entries.serializers import ControlListEntriesListSerializer + + +class ControlListEntriesList(generics.ListAPIView): + authentication_classes = (ExporterAuthentication,) + pagination_class = None + serializer_class = ControlListEntriesListSerializer + queryset = ControlListEntry.objects.filter(controlled=True) diff --git a/api/staticdata/exporter/urls.py b/api/staticdata/exporter/urls.py new file mode 100644 index 0000000000..0cddab9611 --- /dev/null +++ b/api/staticdata/exporter/urls.py @@ -0,0 +1,7 @@ +from django.urls import path, include + +app_name = "exporter_staticdata" + +urlpatterns = [ + path("control-list-entries/", include("api.staticdata.exporter.control_list_entries.urls")), +] diff --git a/api/support/management/commands/remove_case_final_advice.py b/api/support/management/commands/remove_case_final_advice.py new file mode 100644 index 0000000000..d0404b909c --- /dev/null +++ b/api/support/management/commands/remove_case_final_advice.py @@ -0,0 +1,85 @@ +import logging + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from api.audit_trail.enums import AuditType +from api.cases.enums import AdviceLevel +from api.cases.models import Case +from api.queues.models import Queue +from api.audit_trail import service as audit_trail_service + +from api.staticdata.statuses.enums import CaseStatusEnum +from api.staticdata.statuses.libraries.get_case_status import get_case_status_by_status +from lite_routing.routing_rules_internal.enums import QueuesEnum + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = """ + Command to delete Final advice when a case is ready to finalise + + When an application goes back to TAU and they change their mind we need to + invalidate all the final advice. This is unsed where cases are ready to proceed + with finial review. i.e a refusal. + """ + + def add_arguments(self, parser): + parser.add_argument( + "--case_reference", + type=str, + nargs="?", + help="Reference code of the Case (eg GBSIEL/2023/0000001/P)", + ) + parser.add_argument("--dry_run", help="Is it a test run?", action="store_true") + + def handle(self, *args, **options): + case_reference = options.pop("case_reference") + dry_run = options.pop("dry_run") + + with transaction.atomic(): + try: + case = Case.objects.get(reference_code=case_reference) + except Case.DoesNotExist as e: + logger.error("Invalid Case reference %s, does not exist", case_reference) + raise CommandError(e) + + final_advice = case.advice.filter(level=AdviceLevel.FINAL) + + # Ensure final advice given by LU + if not final_advice.exists(): + logger.error("Invalid Advice data, no final advice on this case") + raise CommandError(Exception("Invalid Advice data, no final advice on this case")) + + if not dry_run: + # Move the Case to 'LU Post-Circulation Cases to Finalise' queue + # as it needs to be in this queue to finalise and issue + # also need to ensure the status is under final review. + case.queues.set(Queue.objects.filter(id=QueuesEnum.LU_POST_CIRC)) + + for item in final_advice: + item.delete() + + audit_trail_service.create_system_user_audit( + verb=AuditType.DEVELOPER_INTERVENTION, + target=case, + payload={ + "additional_text": "Removed final advice.", + }, + ) + # If the case isn't under final review update the status + if case.status.status != CaseStatusEnum.UNDER_FINAL_REVIEW: + prev_case_status = case.status.status + case.status = get_case_status_by_status(CaseStatusEnum.UNDER_FINAL_REVIEW) + case.save() + audit_trail_service.create_system_user_audit( + verb=AuditType.UPDATED_STATUS, + target=case, + payload={ + "status": {"new": case.status.status, "old": prev_case_status}, + "additional_text": "", + }, + ) + + logging.info("[%s] can now be finalised by LU to issue a licence", case_reference) diff --git a/api/support/management/commands/tests/test_remove_case_final_advice.py b/api/support/management/commands/tests/test_remove_case_final_advice.py new file mode 100644 index 0000000000..7f811936c4 --- /dev/null +++ b/api/support/management/commands/tests/test_remove_case_final_advice.py @@ -0,0 +1,91 @@ +from api.staticdata.statuses.libraries.get_case_status import get_case_status_by_status +import pytest + +from django.core.management import call_command, CommandError + +from api.applications.tests.factories import AdviceFactory, FinalAdviceOnApplicationFactory +from api.audit_trail.enums import AuditType +from api.audit_trail.models import Audit +from api.cases.enums import AdviceLevel +from api.staticdata.statuses.enums import CaseStatusEnum +from test_helpers.clients import DataTestClient + + +class RemoveCaseFinalAdviceMgmtCommandTests(DataTestClient): + + def setUp(self): + super().setUp() + self.application = FinalAdviceOnApplicationFactory() + self.application.advice.set([AdviceFactory(level=AdviceLevel.USER)]) + + def test_remove_case_final_advice_command(self): + + self.application.status = get_case_status_by_status(CaseStatusEnum.SUBMITTED) + self.application.save() + + self.assertEqual(self.application.advice.filter(level=AdviceLevel.FINAL).exists(), True) + self.assertEqual(self.application.advice.all().count(), 2) + + call_command("remove_case_final_advice", case_reference=self.application.reference_code) + + self.application.refresh_from_db() + self.assertEqual(self.application.status.status, CaseStatusEnum.UNDER_FINAL_REVIEW) + self.assertEqual(self.application.advice.filter(level=AdviceLevel.FINAL).exists(), False) + self.assertEqual(self.application.advice.all().count(), 1) + + audit_dev = Audit.objects.get(verb=AuditType.DEVELOPER_INTERVENTION) + self.assertEqual(audit_dev.actor, self.system_user) + self.assertEqual(audit_dev.target.id, self.application.id) + + self.assertEqual( + audit_dev.payload, + { + "additional_text": "Removed final advice.", + }, + ) + + audit = Audit.objects.get(verb=AuditType.UPDATED_STATUS) + self.assertEqual(audit.actor, self.system_user) + self.assertEqual(audit.target.id, self.application.id) + + self.assertEqual( + audit.payload, + { + "status": {"new": self.application.status.status, "old": CaseStatusEnum.SUBMITTED}, + "additional_text": "", + }, + ) + + def test_remove_case_final_advice_command_status_not_updated(self): + + call_command("remove_case_final_advice", case_reference=self.application.reference_code) + + self.assertEqual(Audit.objects.filter(verb=AuditType.UPDATED_STATUS).exists(), False) + + def test_remove_case_status_change_command_dry_run(self): + + self.assertEqual(self.application.advice.filter(level=AdviceLevel.FINAL).exists(), True) + self.assertEqual(self.application.advice.all().count(), 2) + + call_command("remove_case_final_advice", "--dry_run", case_reference=self.application.reference_code) + + self.application.refresh_from_db() + self.assertEqual(self.application.advice.filter(level=AdviceLevel.FINAL).exists(), True) + self.assertEqual(self.application.advice.all().count(), 2) + self.assertEqual(Audit.objects.filter(verb=AuditType.DEVELOPER_INTERVENTION).exists(), False) + + def test_remove_case_final_advice_command_no_final_advise(self): + + self.application.advice.filter(level=AdviceLevel.FINAL).delete() + with pytest.raises(CommandError): + call_command("remove_case_final_advice", case_reference=self.application.reference_code) + + def test_remove_case_final_advice_command_missing_case_ref(self): + + self.application.advice.filter(level=AdviceLevel.FINAL).delete() + self.assertEqual(self.application.advice.all().count(), 1) + + with pytest.raises(CommandError): + call_command("remove_case_final_advice", case_reference="bad-ref") + + self.assertEqual(self.application.advice.all().count(), 1)