From 198f62f9893e7191d68e808d47b382092e6cf30e Mon Sep 17 00:00:00 2001 From: Gurdeep Atwal Date: Mon, 27 Jan 2025 10:28:17 +0000 Subject: [PATCH 01/11] new exporter application history endpoint --- api/applications/exporter/serializers.py | 50 +++++++++- api/applications/exporter/urls.py | 1 + .../exporter/views/applications.py | 36 +++---- api/applications/exporter/views/mixins.py | 31 ++++++ .../exporter/views/tests/test_applications.py | 96 +++++++++++++++++++ 5 files changed, 190 insertions(+), 24 deletions(-) create mode 100644 api/applications/exporter/views/mixins.py diff --git a/api/applications/exporter/serializers.py b/api/applications/exporter/serializers.py index 842619f587..ebad79e823 100644 --- a/api/applications/exporter/serializers.py +++ b/api/applications/exporter/serializers.py @@ -1,9 +1,57 @@ +from api.cases.models import Case +from api.staticdata.statuses.libraries.get_case_status import get_status_value_from_case_status_enum +from api.staticdata.statuses.models import CaseStatus from rest_framework import serializers from api.staticdata.statuses.enums import CaseStatusEnum class ApplicationChangeStatusSerializer(serializers.Serializer): - status = serializers.ChoiceField(choices=CaseStatusEnum.all()) note = serializers.CharField(required=False, allow_blank=True, allow_null=True, max_length=2000) + + +class ApplicationStatusSerializer(serializers.ModelSerializer): + status_display = serializers.SerializerMethodField() + + class Meta: + model = CaseStatus + fields = ("status", "status_display") + + def get_status_display(self, obj): + return get_status_value_from_case_status_enum(obj.status) + + +class CaseAmendmentSerializer(serializers.Serializer): + status = serializers.SerializerMethodField() + ecju_query_count = serializers.SerializerMethodField() + reference_code = serializers.CharField() + submitted_at = serializers.DateTimeField() + id = serializers.UUIDField() + status = ApplicationStatusSerializer() + + def get_ecju_query_count(self, instance): + return instance.case_ecju_query.all().count() + + +class ApplicationHistorySerializer(serializers.ModelSerializer): + amendment_history = serializers.SerializerMethodField() + + def get_amendment_history(self, instance): + amendments = [] + case_amended = instance + + # Go backwards through amendment chain until we find the original case + while case_amended.superseded_by: + case_amended = case_amended.superseded_by + + # Travel forwards in amendment chain to find the latest amended case + while case_amended: + case_amended_data = CaseAmendmentSerializer(case_amended).data + amendments.append(case_amended_data) + case_amended = case_amended.amendment_of + return amendments + + class Meta: + model = Case + fields = ("id", "reference_code", "amendment_history") diff --git a/api/applications/exporter/urls.py b/api/applications/exporter/urls.py index c3e38e174c..31eab02a61 100644 --- a/api/applications/exporter/urls.py +++ b/api/applications/exporter/urls.py @@ -6,4 +6,5 @@ urlpatterns = [ path("/status/", applications.ApplicationChangeStatus.as_view(), name="change_status"), + path("/history/", applications.ApplicationHistory.as_view(), name="history"), ] diff --git a/api/applications/exporter/views/applications.py b/api/applications/exporter/views/applications.py index a56beb1355..f7780d1f39 100644 --- a/api/applications/exporter/views/applications.py +++ b/api/applications/exporter/views/applications.py @@ -1,41 +1,24 @@ -from django.core.exceptions import ObjectDoesNotExist -from django.http import Http404, JsonResponse +from django.http import JsonResponse from django.db import transaction -from rest_framework.generics import GenericAPIView +from api.applications.exporter.views.mixins import ExporterApplicationMixin +from api.cases.models import Case +from rest_framework.generics import GenericAPIView, RetrieveAPIView from rest_framework import status from api.applications.exporter.permissions import CaseStatusExporterChangeable -from api.applications.exporter.serializers import ApplicationChangeStatusSerializer +from api.applications.exporter.serializers import ApplicationChangeStatusSerializer, ApplicationHistorySerializer from api.applications.helpers import get_application_view_serializer -from api.applications.libraries.get_applications import get_application -from api.core.authentication import ExporterAuthentication -from api.core.exceptions import NotFoundError from api.core.permissions import IsExporterInOrganisation from api.staticdata.statuses.libraries.get_case_status import get_case_status_by_status -class ApplicationChangeStatus(GenericAPIView): - authentication_classes = (ExporterAuthentication,) +class ApplicationChangeStatus(ExporterApplicationMixin, GenericAPIView): permission_classes = [ IsExporterInOrganisation, CaseStatusExporterChangeable, ] serializer_class = ApplicationChangeStatusSerializer - def setup(self, request, *args, **kwargs): - super().setup(request, *args, **kwargs) - try: - self.application = get_application(self.kwargs["pk"]) - except (ObjectDoesNotExist, NotFoundError): - raise Http404() - - def get_object(self): - self.check_object_permissions(self.request, self.application) - return self.application - - def get_organisation(self): - return self.application.organisation - @transaction.atomic def post(self, request, pk): application = self.get_object() @@ -49,3 +32,10 @@ def post(self, request, pk): ).data return JsonResponse(data=response_data, status=status.HTTP_200_OK) + + +class ApplicationHistory(ExporterApplicationMixin, RetrieveAPIView): + + lookup_field = "pk" + queryset = Case.objects.all() + serializer_class = ApplicationHistorySerializer diff --git a/api/applications/exporter/views/mixins.py b/api/applications/exporter/views/mixins.py new file mode 100644 index 0000000000..80dd807328 --- /dev/null +++ b/api/applications/exporter/views/mixins.py @@ -0,0 +1,31 @@ +from django.core.exceptions import ObjectDoesNotExist +from django.http import Http404 +from api.core.authentication import ExporterAuthentication +from api.core.exceptions import NotFoundError + +from api.applications.libraries.get_applications import get_application +from api.core.permissions import IsExporterInOrganisation + + +class ExporterApplicationMixin: + # Mixin for views which checks the exporter is within same organisation as the application + # Checks Exporter is authenticated + + authentication_classes = (ExporterAuthentication,) + permission_classes = [ + IsExporterInOrganisation, + ] + + def setup(self, request, *args, **kwargs): + super().setup(request, *args, **kwargs) + try: + self.application = get_application(self.kwargs["pk"]) + except (ObjectDoesNotExist, NotFoundError): + raise Http404() + + def get_object(self): + self.check_object_permissions(self.request, self.application) + return self.application + + def get_organisation(self): + return self.application.organisation diff --git a/api/applications/exporter/views/tests/test_applications.py b/api/applications/exporter/views/tests/test_applications.py index 4f11f36c30..3abb181f07 100644 --- a/api/applications/exporter/views/tests/test_applications.py +++ b/api/applications/exporter/views/tests/test_applications.py @@ -1,3 +1,8 @@ +import uuid +from django.utils import timezone +from pytz import timezone as tz + +from api.cases.tests.factories import EcjuQueryFactory from parameterized import parameterized from django.urls import reverse @@ -6,6 +11,7 @@ from api.applications.tests.factories import StandardApplicationFactory from api.staticdata.statuses.enums import CaseStatusEnum from api.staticdata.statuses.models import CaseStatus +from api.cases.models import Case, Queue from api.organisations.tests.factories import OrganisationFactory from test_helpers.clients import DataTestClient @@ -77,3 +83,93 @@ def test_change_status_application_wrong_organisation(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.application.refresh_from_db() self.assertEqual(self.application.status.status, original_status) + + +class TestApplicationHistory(DataTestClient): + + def setUp(self): + super().setUp() + + self.amendment_1 = StandardApplicationFactory( + organisation=self.exporter_user.organisation, + status=CaseStatus.objects.get(status=CaseStatusEnum.SUBMITTED), + submitted_at=timezone.now(), + ) + self.amendment_1.queues.add(Queue.objects.first()) + self.amendment_1.save() + EcjuQueryFactory( + question="ECJU Query 1", case=self.amendment_1, raised_by_user=self.gov_user, responded_at=timezone.now() + ) + EcjuQueryFactory(question="ECJU Query 2", case=self.amendment_1, raised_by_user=self.gov_user, response=None) + + self.amendment_2 = self.amendment_1.create_amendment(self.exporter_user) + self.amendment_1.refresh_from_db() + self.amendment_2.submitted_at = timezone.now() + self.amendment_2.status = CaseStatus.objects.get(status=CaseStatusEnum.SUBMITTED) + self.amendment_2.reference_code = "GBSIEL/2025/0000002/P" + self.amendment_2.save() + EcjuQueryFactory(case=self.amendment_2, raised_by_user=self.gov_user) + + self.latest_case = self.amendment_2.create_amendment(self.exporter_user) + self.amendment_2.refresh_from_db() + self.latest_case.submitted_at = timezone.now() + self.latest_case.reference_code = "GBSIEL/2025/0000003/P" + self.latest_case.status = CaseStatus.objects.get(status=CaseStatusEnum.SUBMITTED) + self.latest_case.save() + self.latest_case.refresh_from_db() + + @parameterized.expand(["GBSIEL/2025/0000001/P", "GBSIEL/2025/0000002/P", "GBSIEL/2025/0000003/P"]) + def test_get_amendment_history(self, case_ref): + + case = Case.objects.get(reference_code=case_ref) + url = reverse( + "exporter_applications:history", + kwargs={ + "pk": str(case.pk), + }, + ) + + response = self.client.get(url, **self.exporter_headers) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + expected_json = { + "id": str(case.id), + "reference_code": case.reference_code, + "amendment_history": [ + { + "id": str(c.id), + "reference_code": c.reference_code, + "submitted_at": c.submitted_at.astimezone(tz("UTC")).strftime("%Y-%m-%dT%H:%M:%S.%f") + "Z", + "status": {"status": c.status.status, "status_display": CaseStatusEnum.get_text(c.status.status)}, + "ecju_query_count": c.case_ecju_query.all().count(), + } + for c in [self.latest_case, self.amendment_2, self.amendment_1] + ], + } + self.assertEqual(response.json(), expected_json) + + def test_get_history_application_not_found(self): + + url = reverse( + "exporter_applications:history", + kwargs={ + "pk": str(uuid.uuid4()), + }, + ) + response = self.client.get(url, **self.exporter_headers) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_get_history_application_wrong_organisation(self): + self.latest_case.organisation = OrganisationFactory() + self.latest_case.save() + + url = reverse( + "exporter_applications:history", + kwargs={ + "pk": str(self.latest_case.pk), + }, + ) + response = self.client.get(url, **self.exporter_headers) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) From 859b81244e6f3ca4527e2d2cf989184689a3fb58 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 28 Jan 2025 19:44:38 +0000 Subject: [PATCH 02/11] Optimise query for licence decision dw endpoint --- api/data_workspace/v2/serializers.py | 5 +---- api/data_workspace/v2/views.py | 10 +++++++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 6806c98f15..6353c94f0c 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -37,10 +37,7 @@ def get_licence_id(self, licence_decision) -> typing.Optional[uuid.UUID]: if licence_decision.decision in [LicenceDecisionType.REFUSED]: return None - latest_decision = licence_decision.case.licence_decisions.exclude( - excluded_from_statistics_reason__isnull=False - ).last() - + latest_decision = licence_decision.case.unexcluded_licence_decisions[0] if not latest_decision.licence: return None diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index dd56397740..18dd5e227a 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -70,7 +70,15 @@ class LicenceDecisionViewSet(BaseViewSet): queryset = ( LicenceDecision.objects.filter(previous_decision__isnull=True) .exclude(excluded_from_statistics_reason__isnull=False) - .prefetch_related("case__licence_decisions", "case__licence_decisions__licence") + .prefetch_related( + Prefetch( + "case__licence_decisions", + queryset=LicenceDecision.objects.exclude(excluded_from_statistics_reason__isnull=False) + .select_related("licence") + .order_by("-created_at"), + to_attr="unexcluded_licence_decisions", + ), + ) .select_related("case") .order_by("-case__reference_code") ) From dd5fb2c49ad5f72506b50aab4dfd01f7efaa0d9e Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Wed, 29 Jan 2025 12:02:18 +0000 Subject: [PATCH 03/11] Create a separate bespoke serializer for dw good endpoint --- api/data_workspace/v1/good_views.py | 15 +++++++++----- api/data_workspace/v1/serializers.py | 31 +++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/api/data_workspace/v1/good_views.py b/api/data_workspace/v1/good_views.py index 2430fe0e5b..aac8514df8 100644 --- a/api/data_workspace/v1/good_views.py +++ b/api/data_workspace/v1/good_views.py @@ -2,19 +2,24 @@ from rest_framework.pagination import LimitOffsetPagination from api.conf.pagination import CreatedAtCursorPagination -from api.goods import models, serializers from api.core.authentication import DataWorkspaceOnlyAuthentication +from api.data_workspace.v1.serializers import GoodSerializer +from api.goods.models import ( + Good, + GoodControlListEntry, +) +from api.goods.serializers import GoodControlListEntryViewSerializer class GoodListView(viewsets.ReadOnlyModelViewSet): authentication_classes = (DataWorkspaceOnlyAuthentication,) - serializer_class = serializers.GoodSerializerInternalIncludingPrecedents + serializer_class = GoodSerializer pagination_class = CreatedAtCursorPagination - queryset = models.Good.objects.all() + queryset = Good.objects.all() class GoodControlListEntryListView(viewsets.ReadOnlyModelViewSet): authentication_classes = (DataWorkspaceOnlyAuthentication,) - serializer_class = serializers.GoodControlListEntryViewSerializer + serializer_class = GoodControlListEntryViewSerializer pagination_class = LimitOffsetPagination - queryset = models.GoodControlListEntry.objects.all() + queryset = GoodControlListEntry.objects.all() diff --git a/api/data_workspace/v1/serializers.py b/api/data_workspace/v1/serializers.py index a880a8bcab..89f6ce99d3 100644 --- a/api/data_workspace/v1/serializers.py +++ b/api/data_workspace/v1/serializers.py @@ -3,10 +3,17 @@ from api.survey.models import SurveyResponse from api.teams.models import Department from api.cases.models import CaseAssignment, EcjuQuery, DepartmentSLA +from api.goods.enums import ( + GoodControlled, + GoodStatus, + ItemCategory, +) +from api.goods.models import Good from api.licences.enums import LicenceStatus from api.licences.models import Licence -from api.queues.models import Queue from api.organisations.models import Site +from api.queues.models import Queue +from api.staticdata.control_list_entries.serializers import ControlListEntrySerializer from rest_framework import serializers import api.cases.serializers as cases_serializers @@ -144,3 +151,25 @@ class SiteSerializer(serializers.ModelSerializer): class Meta: model = Site fields = "__all__" + + +class GoodSerializer(serializers.ModelSerializer): + control_list_entries = ControlListEntrySerializer(many=True) + is_good_controlled = KeyValueChoiceField(choices=GoodControlled.choices) + status = KeyValueChoiceField(choices=GoodStatus.choices) + item_category = KeyValueChoiceField(choices=ItemCategory.choices) + + class Meta: + model = Good + fields = ( + "id", + "name", + "description", + "part_number", + "control_list_entries", + "is_good_controlled", + "status", + "item_category", + "is_pv_graded", + "report_summary", + ) From c64ee441cf78cbd33e73c35bec6bb57f8feacec5 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Wed, 29 Jan 2025 12:04:54 +0000 Subject: [PATCH 04/11] Prefetch the control list entries for dw good list --- api/data_workspace/v1/good_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/data_workspace/v1/good_views.py b/api/data_workspace/v1/good_views.py index aac8514df8..b441a8d36b 100644 --- a/api/data_workspace/v1/good_views.py +++ b/api/data_workspace/v1/good_views.py @@ -15,7 +15,7 @@ class GoodListView(viewsets.ReadOnlyModelViewSet): authentication_classes = (DataWorkspaceOnlyAuthentication,) serializer_class = GoodSerializer pagination_class = CreatedAtCursorPagination - queryset = Good.objects.all() + queryset = Good.objects.prefetch_related("control_list_entries") class GoodControlListEntryListView(viewsets.ReadOnlyModelViewSet): From 31ed6c97bf5a571587348d214ec0c7115abd5e8a Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Wed, 29 Jan 2025 12:09:24 +0000 Subject: [PATCH 05/11] Add an index on the `created_at` field for `Good` This is to optimise when we are using cursor pagination in the DW endpoint --- .../0028_good_good_created_c8ec31_idx.py | 17 +++++++++++++++++ api/goods/models.py | 1 + 2 files changed, 18 insertions(+) create mode 100644 api/goods/migrations/0028_good_good_created_c8ec31_idx.py diff --git a/api/goods/migrations/0028_good_good_created_c8ec31_idx.py b/api/goods/migrations/0028_good_good_created_c8ec31_idx.py new file mode 100644 index 0000000000..839fa2c735 --- /dev/null +++ b/api/goods/migrations/0028_good_good_created_c8ec31_idx.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.17 on 2025-01-29 12:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("goods", "0027_good_is_archived"), + ] + + operations = [ + migrations.AddIndex( + model_name="good", + index=models.Index(fields=["created_at"], name="good_created_c8ec31_idx"), + ), + ] diff --git a/api/goods/models.py b/api/goods/models.py index 96875ae4ad..2d190f0b59 100644 --- a/api/goods/models.py +++ b/api/goods/models.py @@ -196,6 +196,7 @@ class Good(TimestampableModel, Trackable): class Meta: db_table = "good" ordering = ["-created_at"] + indexes = [models.Index(fields=["created_at"])] def get_precedents(self): if self.status != GoodStatus.VERIFIED: From db0e317354ea9451af22b99c7852ce536d75fc16 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Wed, 29 Jan 2025 12:39:02 +0000 Subject: [PATCH 06/11] Create a bespoke serializer for DW standard application endpoint --- .../serializers/standard_application.py | 78 +++++++++++++++++-- 1 file changed, 72 insertions(+), 6 deletions(-) diff --git a/api/applications/serializers/standard_application.py b/api/applications/serializers/standard_application.py index 6ef3de070c..bb333d1c86 100644 --- a/api/applications/serializers/standard_application.py +++ b/api/applications/serializers/standard_application.py @@ -30,7 +30,7 @@ GenericApplicationUpdateSerializer, GenericApplicationViewSerializer, ) -from .good import GoodOnApplicationViewSerializer, GoodOnApplicationDataWorkspaceSerializer +from .good import GoodOnApplicationViewSerializer from .fields import CaseStatusField @@ -155,18 +155,84 @@ def get_is_amended(self, instance): return any([is_reference_name_updated, app_letter_ref_updated, is_product_removed]) -class StandardApplicationDataWorkspaceSerializer(StandardApplicationViewSerializer): - goods = GoodOnApplicationDataWorkspaceSerializer(many=True, read_only=True) - amendment_of = serializers.UUIDField(source="amendment_of.id", default=None) - superseded_by = serializers.UUIDField(source="superseded_by.id", default=None) +class StandardApplicationDataWorkspaceSerializer(serializers.ModelSerializer): + is_amended = serializers.SerializerMethodField() + destinations = serializers.SerializerMethodField() class Meta: model = StandardApplication - fields = StandardApplicationViewSerializer.Meta.fields + ( + fields = ( + "id", + "created_at", + "updated_at", + "export_type", + "reference_code", + "submitted_at", + "name", + "activity", + "is_eu_military", + "is_informed_wmd", + "is_suspected_wmd", + "is_compliant_limitations_eu", + "is_military_end_use_controls", + "intended_end_use", + "agreed_to_foi", + "foi_reason", + "reference_number_on_information_form", + "have_you_been_informed", + "is_shipped_waybill_or_lading", + "is_temp_direct_control", + "proposed_return_date", + "sla_days", + "sla_remaining_days", + "sla_updated_at", + "last_closed_at", + "submitted_by", + "status_id", + "case_type_id", + "organisation_id", + "case_officer_id", + "copy_of_id", + "is_amended", + "destinations", + "goods_starting_point", "amendment_of", "superseded_by", ) + def get_is_amended(self, instance): + """Determines whether an application is major/minor edited using Audit logs + and returns True if either of the amends are done, False otherwise""" + audit_qs = Audit.objects.filter(target_object_id=instance.id) + is_reference_name_updated = audit_qs.filter(verb=AuditType.UPDATED_APPLICATION_NAME).exists() + is_product_removed = audit_qs.filter(verb=AuditType.REMOVE_GOOD_FROM_APPLICATION).exists() + app_letter_ref_updated = audit_qs.filter( + Q( + verb__in=[ + AuditType.ADDED_APPLICATION_LETTER_REFERENCE, + AuditType.UPDATE_APPLICATION_LETTER_REFERENCE, + AuditType.REMOVED_APPLICATION_LETTER_REFERENCE, + ] + ) + ) + # in case of doing major edits then the status is set as "Applicant editing" + # Here we are detecting the transition from "Submitted" -> "Applicant editing" + for item in audit_qs.filter(verb=AuditType.UPDATED_STATUS): + status = item.payload["status"] + if status["old"] == CaseStatusEnum.get_text(CaseStatusEnum.SUBMITTED) and status[ + "new" + ] == CaseStatusEnum.get_text(CaseStatusEnum.APPLICANT_EDITING): + return True + + return any([is_reference_name_updated, app_letter_ref_updated, is_product_removed]) + + def get_destinations(self, application): + if getattr(application, "end_user", None): + party = application.end_user.party + return {"data": {"country": {"id": party.country.pk}}} + else: + return {"data": ""} + class StandardApplicationCreateSerializer(GenericApplicationCreateSerializer): export_type = KeyValueChoiceField(choices=ApplicationExportType.choices, required=False) From 39d223c29bc46e50ba61545a35745826ddce70f3 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Wed, 29 Jan 2025 13:35:03 +0000 Subject: [PATCH 07/11] Optimise the DW standard application endpoint --- .../serializers/standard_application.py | 60 ++++++++++++------- api/data_workspace/v1/application_views.py | 48 ++++++++++++++- 2 files changed, 85 insertions(+), 23 deletions(-) diff --git a/api/applications/serializers/standard_application.py b/api/applications/serializers/standard_application.py index bb333d1c86..bb32e2f5ea 100644 --- a/api/applications/serializers/standard_application.py +++ b/api/applications/serializers/standard_application.py @@ -158,6 +158,10 @@ def get_is_amended(self, instance): class StandardApplicationDataWorkspaceSerializer(serializers.ModelSerializer): is_amended = serializers.SerializerMethodField() destinations = serializers.SerializerMethodField() + export_type = serializers.SerializerMethodField() + case_type = serializers.SerializerMethodField() + status = serializers.SerializerMethodField() + organisation = serializers.SerializerMethodField() class Meta: model = StandardApplication @@ -188,11 +192,11 @@ class Meta: "sla_updated_at", "last_closed_at", "submitted_by", - "status_id", - "case_type_id", - "organisation_id", - "case_officer_id", - "copy_of_id", + "status", + "case_type", + "organisation", + "case_officer", + "copy_of", "is_amended", "destinations", "goods_starting_point", @@ -203,21 +207,12 @@ class Meta: def get_is_amended(self, instance): """Determines whether an application is major/minor edited using Audit logs and returns True if either of the amends are done, False otherwise""" - audit_qs = Audit.objects.filter(target_object_id=instance.id) - is_reference_name_updated = audit_qs.filter(verb=AuditType.UPDATED_APPLICATION_NAME).exists() - is_product_removed = audit_qs.filter(verb=AuditType.REMOVE_GOOD_FROM_APPLICATION).exists() - app_letter_ref_updated = audit_qs.filter( - Q( - verb__in=[ - AuditType.ADDED_APPLICATION_LETTER_REFERENCE, - AuditType.UPDATE_APPLICATION_LETTER_REFERENCE, - AuditType.REMOVED_APPLICATION_LETTER_REFERENCE, - ] - ) - ) + is_reference_name_updated = bool(instance.is_reference_update_audits) + is_product_removed = bool(instance.is_product_removed_audits) + app_letter_ref_updated = bool(instance.app_letter_ref_updated_audits) # in case of doing major edits then the status is set as "Applicant editing" # Here we are detecting the transition from "Submitted" -> "Applicant editing" - for item in audit_qs.filter(verb=AuditType.UPDATED_STATUS): + for item in instance.updated_status_audits: status = item.payload["status"] if status["old"] == CaseStatusEnum.get_text(CaseStatusEnum.SUBMITTED) and status[ "new" @@ -227,12 +222,33 @@ def get_is_amended(self, instance): return any([is_reference_name_updated, app_letter_ref_updated, is_product_removed]) def get_destinations(self, application): - if getattr(application, "end_user", None): - party = application.end_user.party - return {"data": {"country": {"id": party.country.pk}}} - else: + if not application.end_users: return {"data": ""} + end_user = application.end_users[0] + return {"data": {"country": {"id": end_user.party.country_id}}} + + def get_export_type(self, application): + if hasattr(application, "export_type"): + return { + "key": application.export_type, + } + + def get_status(self, application): + return { + "id": application.status_id, + } + + def get_case_type(self, application): + return { + "id": application.case_type_id, + } + + def get_organisation(self, application): + return { + "id": application.organisation_id, + } + class StandardApplicationCreateSerializer(GenericApplicationCreateSerializer): export_type = KeyValueChoiceField(choices=ApplicationExportType.choices, required=False) diff --git a/api/data_workspace/v1/application_views.py b/api/data_workspace/v1/application_views.py index c1abe614ce..c430252766 100644 --- a/api/data_workspace/v1/application_views.py +++ b/api/data_workspace/v1/application_views.py @@ -1,16 +1,62 @@ from rest_framework import viewsets from rest_framework.pagination import LimitOffsetPagination +from django.db.models import Prefetch, Q + from api.applications import models from api.applications.serializers import standard_application, good, party, denial +from api.audit_trail.enums import AuditType +from api.audit_trail.models import Audit from api.core.authentication import DataWorkspaceOnlyAuthentication +from api.parties.enums import PartyType class StandardApplicationListView(viewsets.ReadOnlyModelViewSet): authentication_classes = (DataWorkspaceOnlyAuthentication,) serializer_class = standard_application.StandardApplicationDataWorkspaceSerializer pagination_class = LimitOffsetPagination - queryset = models.StandardApplication.objects.all() + queryset = models.StandardApplication.objects.prefetch_related( + Prefetch( + "parties", + queryset=models.PartyOnApplication.objects.filter( + deleted_at__isnull=True, + party__type=PartyType.END_USER, + ).select_related("party"), + to_attr="end_users", + ), + Prefetch( + "audit_trail", + queryset=Audit.objects.filter( + verb=AuditType.UPDATED_APPLICATION_NAME, + ), + to_attr="is_reference_update_audits", + ), + Prefetch( + "audit_trail", + queryset=Audit.objects.filter( + verb=AuditType.REMOVE_GOOD_FROM_APPLICATION, + ), + to_attr="is_product_removed_audits", + ), + Prefetch( + "audit_trail", + queryset=Audit.objects.filter( + Q( + verb__in=[ + AuditType.ADDED_APPLICATION_LETTER_REFERENCE, + AuditType.UPDATE_APPLICATION_LETTER_REFERENCE, + AuditType.REMOVED_APPLICATION_LETTER_REFERENCE, + ] + ) + ), + to_attr="app_letter_ref_updated_audits", + ), + Prefetch( + "audit_trail", + queryset=Audit.objects.filter(verb=AuditType.UPDATED_STATUS), + to_attr="updated_status_audits", + ), + ) class GoodOnApplicationListView(viewsets.ReadOnlyModelViewSet): From 4a5e9890b88523a2f479bb6982aa018baa8c5903 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Wed, 29 Jan 2025 15:58:20 +0000 Subject: [PATCH 08/11] Optimise good on application DW endpoint --- api/applications/serializers/good.py | 53 ++++++++++++++++++---------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/api/applications/serializers/good.py b/api/applications/serializers/good.py index 9fee397c6b..434c8135d7 100644 --- a/api/applications/serializers/good.py +++ b/api/applications/serializers/good.py @@ -30,7 +30,6 @@ from api.goods.serializers import ( GoodSerializerInternal, FirearmDetailsSerializer, - GoodSerializerInternalIncludingPrecedents, ) from api.gov_users.serializers import GovUserSimpleSerializer from api.licences.models import GoodOnLicence @@ -172,28 +171,46 @@ def update(self, instance, validated_data): return super().update(instance, validated_data) -class GoodOnApplicationDataWorkspaceSerializer(GoodOnApplicationViewSerializer): - good = GoodSerializerInternalIncludingPrecedents(read_only=True) - good_application_documents = serializers.SerializerMethodField() - good_application_internal_documents = serializers.SerializerMethodField() +class GoodOnApplicationDataWorkspaceSerializer(serializers.ModelSerializer): + unit = serializers.SerializerMethodField() + good = serializers.SerializerMethodField() + firearm_details = serializers.SerializerMethodField() + is_good_controlled = serializers.SerializerMethodField() class Meta: model = GoodOnApplication - base_fields = list(GoodOnApplicationViewSerializer.Meta.fields) - fields = base_fields + [ - "good_application_documents", - "good_application_internal_documents", - ] - - def get_good_application_documents(self, instance): - documents = GoodOnApplicationDocument.objects.filter( - application=instance.application, good=instance.good, safe=True + fields = ( + "id", + "created_at", + "updated_at", + "quantity", + "unit", + "value", + "is_good_incorporated", + "application_id", + "comment", + "report_summary", + "end_use_control", + "is_precedent", + "good", + "firearm_details", + "is_good_controlled", + "is_trigger_list_guidelines_applicable", + "is_nca_applicable", + "nsg_assessment_note", ) - return GoodOnApplicationDocumentViewSerializer(documents, many=True).data - def get_good_application_internal_documents(self, instance): - documents = GoodOnApplicationInternalDocument.objects.filter(good_on_application=instance.id, safe=True) - return GoodOnApplicationInternalDocumentViewSerializer(documents, many=True).data + def get_unit(self, instance): + return {"key": instance.unit} + + def get_good(self, instance): + return {"id": instance.good_id} + + def get_firearm_details(self, instance): + return {"id": instance.firearm_details_id} + + def get_is_good_controlled(self, instance): + return {"key": instance.is_good_controlled} class GoodOnApplicationCreateSerializer(serializers.ModelSerializer): From 05b4e9ceb100e737c61cfe7e8246c4b81f6aeaf7 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Thu, 30 Jan 2025 10:52:06 +0000 Subject: [PATCH 09/11] Update tests for standard application dw endpoint --- .../serializers/standard_application.py | 20 ++ .../v1/tests/test_applications_views.py | 173 ++++-------------- 2 files changed, 60 insertions(+), 133 deletions(-) diff --git a/api/applications/serializers/standard_application.py b/api/applications/serializers/standard_application.py index bb32e2f5ea..c47fc68ae6 100644 --- a/api/applications/serializers/standard_application.py +++ b/api/applications/serializers/standard_application.py @@ -162,6 +162,9 @@ class StandardApplicationDataWorkspaceSerializer(serializers.ModelSerializer): case_type = serializers.SerializerMethodField() status = serializers.SerializerMethodField() organisation = serializers.SerializerMethodField() + submitted_by = serializers.SerializerMethodField() + superseded_by = serializers.SerializerMethodField() + amendment_of = serializers.SerializerMethodField() class Meta: model = StandardApplication @@ -249,6 +252,23 @@ def get_organisation(self, application): "id": application.organisation_id, } + def get_submitted_by(self, application): + return ( + f"{application.submitted_by.first_name} {application.submitted_by.last_name}" + if application.submitted_by + else "" + ) + + def get_superseded_by(self, application): + if not application.superseded_by: + return None + return str(application.superseded_by.pk) + + def get_amendment_of(self, application): + if not application.amendment_of: + return None + return str(application.amendment_of.pk) + class StandardApplicationCreateSerializer(GenericApplicationCreateSerializer): export_type = KeyValueChoiceField(choices=ApplicationExportType.choices, required=False) diff --git a/api/data_workspace/v1/tests/test_applications_views.py b/api/data_workspace/v1/tests/test_applications_views.py index 54f03bd3ec..7e9ab9456b 100644 --- a/api/data_workspace/v1/tests/test_applications_views.py +++ b/api/data_workspace/v1/tests/test_applications_views.py @@ -55,133 +55,42 @@ def test_dw_standard_application_GET(self): "previous": None, "results": [ { - "activity": "Trade", - "additional_documents": [], - "agreed_to_foi": None, - "amendment_of": None, - "appeal": None, - "appeal_deadline": None, - "case": str(standard_application.id), - "case_officer": None, - "case_type": { - "id": "00000000-0000-0000-0000-000000000004", - "reference": {"key": "siel", "value": "Standard Individual Export " "Licence"}, - "sub_type": {"key": "standard", "value": "Standard Licence"}, - "type": {"key": "application", "value": "Application"}, - }, - "compliant_limitations_eu_ref": None, - "consignee": None, + "id": str(standard_application.pk), "created_at": drf_str_datetime(standard_application.created_at), - "denial_matches": [], - "destinations": {"data": "", "type": "end_user"}, - "end_user": None, - "export_type": {"key": "temporary", "value": "Temporary"}, - "f1686_approval_date": None, - "f1686_contracting_authority": None, - "f1686_reference_number": None, - "f680_reference_number": None, - "foi_reason": "", - "goods": [], - "goods_locations": {}, - "goods_recipients": "direct_to_end_user", - "goods_starting_point": "GB", - "have_you_been_informed": "yes", - "id": str(standard_application.id), - "inactive_parties": [], - "informed_wmd_ref": None, - "intended_end_use": "this is our intended end use", - "is_amended": False, - "is_compliant_limitations_eu": None, + "updated_at": drf_str_datetime(standard_application.updated_at), + "export_type": {"key": "temporary"}, + "reference_code": standard_application.reference_code, + "submitted_at": None, + "name": "Test", + "activity": "Trade", "is_eu_military": False, "is_informed_wmd": False, - "is_major_editable": False, + "is_suspected_wmd": False, + "is_compliant_limitations_eu": None, "is_military_end_use_controls": False, - "is_mod_security_approved": False, + "intended_end_use": "this is our intended end use", + "agreed_to_foi": None, + "foi_reason": "", + "reference_number_on_information_form": "123", + "have_you_been_informed": "yes", "is_shipped_waybill_or_lading": True, - "is_suspected_wmd": False, "is_temp_direct_control": None, - "last_closed_at": None, - "licence": None, - "military_end_use_controls_ref": None, - "name": "Test", - "non_waybill_or_lading_route_details": None, - "organisation": { - "created_at": drf_str_datetime(organisation.created_at), - "documents": [], - "eori_number": organisation.eori_number, - "flags": None, - "id": str(organisation.id), - "name": organisation.name, - "phone_number": "", - "primary_site": { - "address": { - "address_line_1": organisation.primary_site.address.address_line_1, - "address_line_2": organisation.primary_site.address.address_line_2, - "city": organisation.primary_site.address.city, - "country": { - "id": organisation.primary_site.address.country.id, - "is_eu": organisation.primary_site.address.country.is_eu, - "name": organisation.primary_site.address.country.name, - "report_name": organisation.primary_site.address.country.report_name, - "type": organisation.primary_site.address.country.type, - }, - "id": str(organisation.primary_site.address.id), - "postcode": organisation.primary_site.address.postcode, - "region": organisation.primary_site.address.region, - }, - "id": str(organisation.primary_site.id), - "name": organisation.primary_site.name, - "records_located_at": { - "address": { - "address_line_1": organisation.primary_site.site_records_located_at.address.address_line_1, - "address_line_2": organisation.primary_site.site_records_located_at.address.address_line_2, - "city": organisation.primary_site.site_records_located_at.address.city, - "country": { - "name": organisation.primary_site.site_records_located_at.address.country.name, - }, - "postcode": organisation.primary_site.site_records_located_at.address.postcode, - "region": organisation.primary_site.site_records_located_at.address.region, - }, - "id": str(organisation.primary_site.site_records_located_at.id), - "name": organisation.primary_site.site_records_located_at.name, - }, - }, - "registration_number": organisation.registration_number, - "sic_number": organisation.sic_number, - "status": {"key": "active", "value": "Active"}, - "type": {"key": "commercial", "value": "Commercial Organisation"}, - "updated_at": drf_str_datetime(organisation.updated_at), - "vat_number": organisation.vat_number, - "website": "", - }, - "other_security_approval_details": None, "proposed_return_date": None, - "reference_code": standard_application.reference_code, - "reference_number_on_information_form": "123", - "sanction_matches": [], - "security_approvals": None, "sla_days": 0, "sla_remaining_days": None, "sla_updated_at": None, - "status": { - "id": "00000000-0000-0000-0000-000000000001", - "key": "submitted", - "value": "Submitted", - }, - "sub_status": None, - "subject_to_itar_controls": None, - "submitted_at": None, + "last_closed_at": None, "submitted_by": standard_application.submitted_by.baseuser_ptr.get_full_name(), + "status": {"id": "00000000-0000-0000-0000-000000000001"}, + "case_type": {"id": "00000000-0000-0000-0000-000000000004"}, + "organisation": {"id": str(standard_application.organisation.pk)}, + "case_officer": None, + "copy_of": None, + "is_amended": False, + "destinations": {"data": ""}, + "goods_starting_point": "GB", + "amendment_of": None, "superseded_by": None, - "suspected_wmd_ref": None, - "temp_direct_control_details": None, - "temp_export_details": None, - "third_parties": [], - "trade_control_activity": {"key": None, "value": None}, - "trade_control_product_categories": [], - "ultimate_end_users": [], - "updated_at": drf_str_datetime(standard_application.updated_at), - "usage": "Trade", } ], }, @@ -228,21 +137,20 @@ def test_dw_good_on_application_views_OPTIONS(self): actual_keys = response.json()["actions"]["GET"].keys() expected_keys = [ "id", - "good", - "application", + "created_at", + "updated_at", "quantity", "unit", "value", "is_good_incorporated", - "flags", - "is_good_controlled", - "control_list_entries", + "application_id", + "comment", "report_summary", - "firearm_details", - "good_application_documents", - "good_application_internal_documents", + "end_use_control", "is_precedent", - "nsg_list_type", + "good", + "firearm_details", + "is_good_controlled", "is_trigger_list_guidelines_applicable", "is_nca_applicable", "nsg_assessment_note", @@ -269,21 +177,20 @@ def test_dw_good_on_application_views_GET(self): actual_keys = response.json()["results"][0].keys() expected_keys = [ "id", - "good", - "application", + "created_at", + "updated_at", "quantity", "unit", "value", "is_good_incorporated", - "flags", - "is_good_controlled", - "control_list_entries", + "application_id", + "comment", "report_summary", - "firearm_details", - "good_application_documents", - "good_application_internal_documents", + "end_use_control", "is_precedent", - "nsg_list_type", + "good", + "firearm_details", + "is_good_controlled", "is_trigger_list_guidelines_applicable", "is_nca_applicable", "nsg_assessment_note", From a0afa54c54c3a2873e397142565c2617d01f127c Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Thu, 30 Jan 2025 11:34:37 +0000 Subject: [PATCH 10/11] Fix DW good tests --- .../v1/tests/test_good_views.py | 26 ++----------------- 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/api/data_workspace/v1/tests/test_good_views.py b/api/data_workspace/v1/tests/test_good_views.py index 353d413ae1..c824f45b9d 100644 --- a/api/data_workspace/v1/tests/test_good_views.py +++ b/api/data_workspace/v1/tests/test_good_views.py @@ -22,34 +22,12 @@ def test_goods(self): "name", "description", "part_number", - "no_part_number_comments", "control_list_entries", - "comment", "is_good_controlled", - "report_summary", - "report_summary_prefix", - "report_summary_subject", - "flags", - "documents", - "is_pv_graded", - "grading_comment", - "pv_grading_details", "status", "item_category", - "is_military_use", - "is_component", - "uses_information_security", - "modified_military_use_details", - "component_details", - "information_security_details", - "is_document_available", - "is_document_sensitive", - "no_document_comments", - "software_or_technology_details", - "firearm_details", - "is_precedent", - "precedents", - "product_description", + "is_pv_graded", + "report_summary", ] ) From 8a1ff52b310d0b27e30114f95ed5ade488403b5b Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Thu, 30 Jan 2025 12:01:50 +0000 Subject: [PATCH 11/11] Use foreign key id directly for amendment --- api/applications/serializers/standard_application.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/applications/serializers/standard_application.py b/api/applications/serializers/standard_application.py index c47fc68ae6..5c80907577 100644 --- a/api/applications/serializers/standard_application.py +++ b/api/applications/serializers/standard_application.py @@ -265,9 +265,9 @@ def get_superseded_by(self, application): return str(application.superseded_by.pk) def get_amendment_of(self, application): - if not application.amendment_of: + if not application.amendment_of_id: return None - return str(application.amendment_of.pk) + return str(application.amendment_of_id) class StandardApplicationCreateSerializer(GenericApplicationCreateSerializer):