From 68386732016d2b356cfed0b9c81ee422b254bb4c Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Mon, 23 Dec 2024 19:36:15 +0000 Subject: [PATCH 01/32] Add endpoint that creates approval recommendations in bulk At the moment this endpoint does nothing --- api/queues/urls.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/queues/urls.py b/api/queues/urls.py index c4834dbd4c..521b04462e 100644 --- a/api/queues/urls.py +++ b/api/queues/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from api.queues.views import queues, case_assignments +from api.queues.views import bulk_approval, queues, case_assignments app_name = "queues" @@ -12,4 +12,5 @@ case_assignments.CaseAssignments.as_view(), name="case_assignments", ), + path("/bulk-approval/", bulk_approval.BulkApprovalCreateView.as_view(), name="bulk_approval"), ] From 2b2c20bd88f67d7b4dd9b393ccb9771597f8e9b7 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Tue, 24 Dec 2024 11:03:46 +0000 Subject: [PATCH 02/32] Add serializer for creating advice records during bulk approval --- api/applications/serializers/advice.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/api/applications/serializers/advice.py b/api/applications/serializers/advice.py index 34bf31c94a..30ad00fc82 100644 --- a/api/applications/serializers/advice.py +++ b/api/applications/serializers/advice.py @@ -303,3 +303,28 @@ def get_flags(self, instance): return list(instance.flags.filter(status=FlagStatuses.ACTIVE).values("id", "name", "colour", "label")) else: return list(instance.flags.values("id", "name", "colour", "label")) + + +class BulkApprovalAdviceSerializer(serializers.ModelSerializer): + user = serializers.PrimaryKeyRelatedField(queryset=GovUser.objects.filter(status=UserStatuses.ACTIVE)) + case = serializers.PrimaryKeyRelatedField(queryset=Case.objects.all()) + + class Meta: + model = Advice + fields = ( + "case", + "user", + "type", + "text", + "proviso", + "note", + "level", + "footnote", + "footnote_required", + "good", + "consignee", + "end_user", + "ultimate_end_user", + "third_party", + "country", + ) From 8b3b53d9086b1f2bcc8eea81add6b273309775fa Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Tue, 24 Dec 2024 11:12:37 +0000 Subject: [PATCH 03/32] Create advice records for multiple cases Users can bulk approve multiple cases and we create approve recommendation for all those cases. --- api/queues/views/bulk_approval.py | 61 +++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 api/queues/views/bulk_approval.py diff --git a/api/queues/views/bulk_approval.py b/api/queues/views/bulk_approval.py new file mode 100644 index 0000000000..0273c4d69a --- /dev/null +++ b/api/queues/views/bulk_approval.py @@ -0,0 +1,61 @@ +from django.db import transaction +from django.http import JsonResponse +from rest_framework import status +from rest_framework.generics import CreateAPIView + +from api.applications.models import StandardApplication +from api.applications.serializers.advice import BulkApprovalAdviceSerializer +from api.cases.enums import AdviceLevel, AdviceType +from api.cases.models import Advice, Case +from api.core.authentication import GovAuthentication + + +class BulkApprovalCreateView(CreateAPIView): + authentication_classes = (GovAuthentication,) + # TODO: Add permission classes + serializer_class = BulkApprovalAdviceSerializer + + def setup(self, request, *args, **kwargs): + super().setup(request, *args, **kwargs) + self.case_ids = [] + + def get_advice_data(self, request, application): + subjects = [("good", good_on_application.good.id) for good_on_application in application.goods.all()] + [ + (poa.party.type, poa.party.id) for poa in application.parties.all() + ] + return [ + { + "level": AdviceLevel.USER, + "type": AdviceType.APPROVE, + "case": str(application.id), + "user": request.user, + subject_name: str(subject_id), + "denial_reasons": [], + **self.advice, + } + for subject_name, subject_id in subjects + ] + + def build_instances_data(self, request): + input_data = request.data.copy() + self.case_ids = input_data.get("case_ids", []) + self.advice = input_data.get("advice", {}) + payload = [] + applications = StandardApplication.objects.filter(id__in=self.case_ids) + for application in applications: + advice_data = self.get_advice_data(request, application) + payload.extend(advice_data) + + return payload + + @transaction.atomic + def create(self, request, *args, **kwargs): + data = self.build_instances_data(request) + serializer = self.get_serializer(data=data, many=True) + serializer.is_valid(raise_exception=True) + + super().perform_create(serializer) + return JsonResponse( + {}, + status=status.HTTP_201_CREATED, + ) From ddbf5632144b3226066e7ad90f0f8394ce0a5813 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Tue, 24 Dec 2024 13:49:24 +0000 Subject: [PATCH 04/32] Move the cases forward as per the process after bulk approval --- api/queues/views/bulk_approval.py | 38 +++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/api/queues/views/bulk_approval.py b/api/queues/views/bulk_approval.py index 0273c4d69a..19ab294eb0 100644 --- a/api/queues/views/bulk_approval.py +++ b/api/queues/views/bulk_approval.py @@ -5,9 +5,13 @@ from api.applications.models import StandardApplication from api.applications.serializers.advice import BulkApprovalAdviceSerializer +from api.audit_trail import service as audit_trail_service +from api.audit_trail.enums import AuditType from api.cases.enums import AdviceLevel, AdviceType -from api.cases.models import Advice, Case +from api.cases.models import Case, CaseAssignment from api.core.authentication import GovAuthentication +from api.queues.models import Queue +from api.workflow.user_queue_assignment import user_queue_assignment_workflow class BulkApprovalCreateView(CreateAPIView): @@ -18,15 +22,41 @@ class BulkApprovalCreateView(CreateAPIView): def setup(self, request, *args, **kwargs): super().setup(request, *args, **kwargs) self.case_ids = [] + self.queue = Queue.objects.get(id=kwargs["pk"]) + + def move_case_forward(self, request, case): + assignments = ( + CaseAssignment.objects.select_related("queue").filter(case=case, queue=self.queue).order_by("queue__name") + ) + + # Unassign existing case advisors to be able to move forward + if assignments: + assignments.delete() + + # Run routing rules and move the case forward + user_queue_assignment_workflow([self.queue], case) + + audit_trail_service.create( + actor=request.user, + verb=AuditType.UNASSIGNED_QUEUES, + target=case, + payload={"queues": [self.queue.name], "additional_text": ""}, + ) + + def move_cases_forward(self, request, cases): + for case in cases: + self.move_case_forward(request, case) def get_advice_data(self, request, application): subjects = [("good", good_on_application.good.id) for good_on_application in application.goods.all()] + [ (poa.party.type, poa.party.id) for poa in application.parties.all() ] + proviso = self.advice.get("proviso", "") + advice_type = AdviceType.PROVISO if proviso else AdviceType.APPROVE return [ { "level": AdviceLevel.USER, - "type": AdviceType.APPROVE, + "type": advice_type, "case": str(application.id), "user": request.user, subject_name: str(subject_id), @@ -40,6 +70,7 @@ def build_instances_data(self, request): input_data = request.data.copy() self.case_ids = input_data.get("case_ids", []) self.advice = input_data.get("advice", {}) + self.cases = Case.objects.filter(id__in=self.case_ids) payload = [] applications = StandardApplication.objects.filter(id__in=self.case_ids) for application in applications: @@ -55,6 +86,9 @@ def create(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) super().perform_create(serializer) + + self.move_cases_forward(request, self.cases) + return JsonResponse( {}, status=status.HTTP_201_CREATED, From ca00c96301fe0ae90a3f1c0a6e48bbbc81b2daf6 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Fri, 27 Dec 2024 11:42:43 +0000 Subject: [PATCH 05/32] Include number of cases approved in response --- api/queues/views/bulk_approval.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/queues/views/bulk_approval.py b/api/queues/views/bulk_approval.py index 19ab294eb0..1d52ddc619 100644 --- a/api/queues/views/bulk_approval.py +++ b/api/queues/views/bulk_approval.py @@ -90,6 +90,6 @@ def create(self, request, *args, **kwargs): self.move_cases_forward(request, self.cases) return JsonResponse( - {}, + {"case_ids": self.case_ids}, status=status.HTTP_201_CREATED, ) From 31694c54cc58470edb4fd95d0f15d026caee3332 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Mon, 6 Jan 2025 14:13:17 +0000 Subject: [PATCH 06/32] Add permission classes for Bulk approval view Initially only allowed for MoD teams --- api/core/permissions.py | 14 ++++++++++++++ api/queues/views/bulk_approval.py | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/api/core/permissions.py b/api/core/permissions.py index ce8b1c362f..e1445bff9c 100644 --- a/api/core/permissions.py +++ b/api/core/permissions.py @@ -6,6 +6,15 @@ from api.organisations.models import Organisation from api.users.models import GovUser +from lite_routing.routing_rules_internal.enums import TeamIdEnum + +TEAMS_ALLOWED_TO_BULK_APPROVE = [ + TeamIdEnum.MOD_CAPPROT, + TeamIdEnum.MOD_DI, + TeamIdEnum.MOD_DSR, + TeamIdEnum.MOD_DSTL, +] + def assert_user_has_permission(user, permission, organisation: Organisation = None): if isinstance(user, GovUser): @@ -52,3 +61,8 @@ def has_permission(self, request, view): class CanCaseworkersIssueLicence(permissions.BasePermission): def has_permission(self, request, view): return check_user_has_permission(request.user.govuser, GovPermissions.MANAGE_LICENCE_FINAL_ADVICE) + + +class CanCaseworkerBulkApprove(permissions.BasePermission): + def has_permission(self, request, view): + return str(request.user.govuser.team_id) in TEAMS_ALLOWED_TO_BULK_APPROVE diff --git a/api/queues/views/bulk_approval.py b/api/queues/views/bulk_approval.py index 1d52ddc619..891775b174 100644 --- a/api/queues/views/bulk_approval.py +++ b/api/queues/views/bulk_approval.py @@ -10,13 +10,14 @@ from api.cases.enums import AdviceLevel, AdviceType from api.cases.models import Case, CaseAssignment from api.core.authentication import GovAuthentication +from api.core.permissions import CanCaseworkerBulkApprove from api.queues.models import Queue from api.workflow.user_queue_assignment import user_queue_assignment_workflow class BulkApprovalCreateView(CreateAPIView): authentication_classes = (GovAuthentication,) - # TODO: Add permission classes + permission_classes = [CanCaseworkerBulkApprove] serializer_class = BulkApprovalAdviceSerializer def setup(self, request, *args, **kwargs): From 3dd0125f85f25a0c95445b42d059b3e243cd8107 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Mon, 6 Jan 2025 16:49:26 +0000 Subject: [PATCH 07/32] Add new audit event when multiple cases are approved in bulk --- api/audit_trail/enums.py | 1 + api/audit_trail/formatters.py | 4 + ...029_add_create_bulk_approval_audit_verb.py | 288 ++++++++++++++++++ api/audit_trail/payload.py | 1 + api/cases/views/search/activity.py | 15 + api/queues/views/bulk_approval.py | 16 + 6 files changed, 325 insertions(+) create mode 100644 api/audit_trail/migrations/0029_add_create_bulk_approval_audit_verb.py diff --git a/api/audit_trail/enums.py b/api/audit_trail/enums.py index 77dff0a48d..1d0ed43cf5 100644 --- a/api/audit_trail/enums.py +++ b/api/audit_trail/enums.py @@ -146,6 +146,7 @@ class AuditType(LiteEnum): AMENDMENT_CREATED = autostr() DEVELOPER_INTERVENTION = autostr() ADD_EXPORTER_USER_TO_ORGANISATION = autostr() + CREATE_BULK_APPROVAL_RECOMMENDATION = autostr() def human_readable(self): """ diff --git a/api/audit_trail/formatters.py b/api/audit_trail/formatters.py index d084e821e5..d25e090c67 100644 --- a/api/audit_trail/formatters.py +++ b/api/audit_trail/formatters.py @@ -351,3 +351,7 @@ def exporter_submitted_amendment(**payload): def amendment_created(**payload): return f"created the case to supersede {payload['superseded_case']['reference_code']}." + + +def create_bulk_approval_recommendation(**payload): + return f"approved the case in bulk approval on {payload['queue']} queue." diff --git a/api/audit_trail/migrations/0029_add_create_bulk_approval_audit_verb.py b/api/audit_trail/migrations/0029_add_create_bulk_approval_audit_verb.py new file mode 100644 index 0000000000..cbb9feea85 --- /dev/null +++ b/api/audit_trail/migrations/0029_add_create_bulk_approval_audit_verb.py @@ -0,0 +1,288 @@ +# Generated by Django 4.2.16 on 2025-01-06 16:34 + +import api.audit_trail.enums +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("audit_trail", "0028_alter_audit_action_object_object_id_and_more"), + ] + + 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"), + (api.audit_trail.enums.AuditType["DEVELOPER_INTERVENTION"], "developer_intervention"), + ( + api.audit_trail.enums.AuditType["ADD_EXPORTER_USER_TO_ORGANISATION"], + "add_exporter_user_to_organisation", + ), + ( + api.audit_trail.enums.AuditType["CREATE_BULK_APPROVAL_RECOMMENDATION"], + "create_bulk_approval_recommendation", + ), + ], + db_index=True, + max_length=255, + ), + ), + ] diff --git a/api/audit_trail/payload.py b/api/audit_trail/payload.py index a7a4227976..82e5d00a3c 100644 --- a/api/audit_trail/payload.py +++ b/api/audit_trail/payload.py @@ -164,4 +164,5 @@ def format_payload(audit_type, payload): AuditType.AMENDMENT_CREATED: formatters.amendment_created, AuditType.DEVELOPER_INTERVENTION: "updated application information", AuditType.ADD_EXPORTER_USER_TO_ORGANISATION: " added exporter {exporter_email} to sites {site_names}", + AuditType.CREATE_BULK_APPROVAL_RECOMMENDATION: formatters.create_bulk_approval_recommendation, } diff --git a/api/cases/views/search/activity.py b/api/cases/views/search/activity.py index 8a3e9bb7e3..0c7039c4bb 100644 --- a/api/cases/views/search/activity.py +++ b/api/cases/views/search/activity.py @@ -3,6 +3,8 @@ from rest_framework import status from rest_framework.views import APIView +from api.audit_trail.enums import AuditType +from api.audit_trail.models import Audit from api.audit_trail import service as audit_trail_service from api.audit_trail.serializers import AuditSerializer from api.cases.models import Case @@ -18,6 +20,19 @@ def get(self, request, pk): audit_trail_qs = audit_trail_service.filter_object_activity( object_id=pk, object_content_type=content_type, **filter_data ) + case = Case.objects.get(pk=pk) + bulk_approval_events = [ + event + for event in Audit.objects.filter(verb=AuditType.CREATE_BULK_APPROVAL_RECOMMENDATION) + if case.reference_code in event.payload["case_references"] + ] + if filter_data["team"]: + bulk_approval_events = [ + event for event in bulk_approval_events if event.payload.get("team_id") == filter_data["team"] + ] + bulk_approval_qs = Audit.objects.filter(id__in=[event.id for event in bulk_approval_events]) + + audit_trail_qs = audit_trail_qs | bulk_approval_qs data = AuditSerializer(audit_trail_qs, many=True).data diff --git a/api/queues/views/bulk_approval.py b/api/queues/views/bulk_approval.py index 891775b174..22b41c881c 100644 --- a/api/queues/views/bulk_approval.py +++ b/api/queues/views/bulk_approval.py @@ -80,6 +80,20 @@ def build_instances_data(self, request): return payload + def create_audit_event(self, request, cases): + audit_trail_service.create( + actor=request.user, + verb=AuditType.CREATE_BULK_APPROVAL_RECOMMENDATION, + target=self.queue, + payload={ + "case_references": [case.reference_code for case in cases], + "decision": AdviceType.APPROVE, + "level": AdviceLevel.USER, + "queue": self.queue.name, + "team_id": str(request.user.govuser.team_id), + }, + ) + @transaction.atomic def create(self, request, *args, **kwargs): data = self.build_instances_data(request) @@ -88,6 +102,8 @@ def create(self, request, *args, **kwargs): super().perform_create(serializer) + self.create_audit_event(request, self.cases) + self.move_cases_forward(request, self.cases) return JsonResponse( From 9cff9da708ce6fb2e948adfdc9eeecb5f507fc00 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Tue, 7 Jan 2025 15:46:59 +0000 Subject: [PATCH 08/32] Refactor Case activity view to use filter backends This is to make it easier to include Bulk approval audit events in notes and timeline. --- api/cases/views/search/activity.py | 84 +++++++++++++++------- api/cases/views/search/activity_filters.py | 54 ++++++++++++++ 2 files changed, 114 insertions(+), 24 deletions(-) create mode 100644 api/cases/views/search/activity_filters.py diff --git a/api/cases/views/search/activity.py b/api/cases/views/search/activity.py index 0c7039c4bb..f5b923d46a 100644 --- a/api/cases/views/search/activity.py +++ b/api/cases/views/search/activity.py @@ -2,41 +2,77 @@ from django.http import JsonResponse from rest_framework import status from rest_framework.views import APIView +from rest_framework.generics import ListAPIView from api.audit_trail.enums import AuditType from api.audit_trail.models import Audit from api.audit_trail import service as audit_trail_service from api.audit_trail.serializers import AuditSerializer +from api.cases.views.search.activity_filters import ( + AuditEventCaseFilter, + AuditEventExporterUserFilter, + AuditEventMentionsFilter, + AuditEventTeamFilter, +) from api.cases.models import Case from api.core.authentication import GovAuthentication -class CaseActivityView(APIView): +class CaseActivityView(ListAPIView): authentication_classes = (GovAuthentication,) + filter_backends = [ + AuditEventCaseFilter, + AuditEventExporterUserFilter, + AuditEventMentionsFilter, + AuditEventTeamFilter, + ] + serializer_class = AuditSerializer - def get(self, request, pk): - filter_data = audit_trail_service.get_filters(request.GET) - content_type = ContentType.objects.get_for_model(Case) - audit_trail_qs = audit_trail_service.filter_object_activity( - object_id=pk, object_content_type=content_type, **filter_data - ) - case = Case.objects.get(pk=pk) - bulk_approval_events = [ - event - for event in Audit.objects.filter(verb=AuditType.CREATE_BULK_APPROVAL_RECOMMENDATION) - if case.reference_code in event.payload["case_references"] - ] - if filter_data["team"]: - bulk_approval_events = [ - event for event in bulk_approval_events if event.payload.get("team_id") == filter_data["team"] - ] - bulk_approval_qs = Audit.objects.filter(id__in=[event.id for event in bulk_approval_events]) - - audit_trail_qs = audit_trail_qs | bulk_approval_qs - - data = AuditSerializer(audit_trail_qs, many=True).data - - return JsonResponse(data={"activity": data}, status=status.HTTP_200_OK) + def get_queryset(self): + queryset = Audit.objects.all() + case_activity_qs = AuditEventCaseFilter().filter_queryset(self.request, queryset, self) + filtered_qs = AuditEventTeamFilter().filter_queryset(self.request, case_activity_qs, self) + filtered_qs = AuditEventExporterUserFilter().filter_queryset(self.request, filtered_qs, self) + filtered_qs = AuditEventMentionsFilter().filter_queryset(self.request, filtered_qs, self) + + return filtered_qs + + def list(self, request, *args, **kwargs): + queryset = self.get_queryset() + + serializer = self.get_serializer(queryset, many=True) + print(audit_trail_service.get_filters(request.GET)) + print(queryset.count()) + # breakpoint() + return JsonResponse(data={"activity": serializer.data}, status=status.HTTP_200_OK) + + +# class CaseActivityView(APIView): +# authentication_classes = (GovAuthentication,) + +# def get(self, request, pk): +# filter_data = audit_trail_service.get_filters(request.GET) +# content_type = ContentType.objects.get_for_model(Case) +# audit_trail_qs = audit_trail_service.filter_object_activity( +# object_id=pk, object_content_type=content_type, **filter_data +# ) +# case = Case.objects.get(pk=pk) +# bulk_approval_events = [ +# event +# for event in Audit.objects.filter(verb=AuditType.CREATE_BULK_APPROVAL_RECOMMENDATION) +# if case.reference_code in event.payload["case_references"] +# ] +# if filter_data["team"]: +# bulk_approval_events = [ +# event for event in bulk_approval_events if event.payload.get("team_id") == filter_data["team"] +# ] +# bulk_approval_qs = Audit.objects.filter(id__in=[event.id for event in bulk_approval_events]) + +# audit_trail_qs = audit_trail_qs | bulk_approval_qs + +# data = AuditSerializer(audit_trail_qs, many=True).data + +# return JsonResponse(data={"activity": data}, status=status.HTTP_200_OK) class CaseActivityFiltersView(APIView): diff --git a/api/cases/views/search/activity_filters.py b/api/cases/views/search/activity_filters.py new file mode 100644 index 0000000000..404ff02d9d --- /dev/null +++ b/api/cases/views/search/activity_filters.py @@ -0,0 +1,54 @@ +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q +from rest_framework import filters + +from api.audit_trail.enums import AuditType +from api.audit_trail.models import Audit +from api.cases.models import Case +from api.users.models import ExporterUser, GovUser + + +class AuditEventCaseFilter(filters.BaseFilterBackend): + + def filter_queryset(self, request, queryset, view): + pk = view.kwargs["pk"] + content_type = ContentType.objects.get_for_model(Case) + queryset = Audit.objects.filter( + Q(action_object_object_id=pk, action_object_content_type=content_type) + | Q(target_object_id=pk, target_content_type=content_type) + ) + return queryset + + +class AuditEventTeamFilter(filters.BaseFilterBackend): + + def filter_queryset(self, request, queryset, view): + team = request.query_params.get("team_id") + if not team: + return queryset + + gov_content_type = ContentType.objects.get_for_model(GovUser) + user_ids = queryset.filter(actor_content_type=gov_content_type).values_list("actor_object_id", flat=True) + team_user_ids = GovUser.objects.filter(pk__in=list(user_ids), team=team).values_list("pk", flat=True) + return queryset.filter(actor_object_id__in=list(team_user_ids)) + + +class AuditEventExporterUserFilter(filters.BaseFilterBackend): + + def filter_queryset(self, request, queryset, view): + user_type = request.query_params.get("user_type") + if not user_type: + return queryset + + user_type_content_type = ContentType.objects.get_for_model(ExporterUser) + return queryset.filter(actor_content_type=user_type_content_type) + + +class AuditEventMentionsFilter(filters.BaseFilterBackend): + + def filter_queryset(self, request, queryset, view): + audit_type = request.query_params.get("activity_type") + if not audit_type: + return queryset + + return queryset.filter(verb=AuditType.CREATED_CASE_NOTE_WITH_MENTIONS) From 7e9b4ece6130cfe7c1e0f60dd297d5848e34803b Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Tue, 7 Jan 2025 17:00:24 +0000 Subject: [PATCH 09/32] Combine bulk approval audit events to the case audit events Bulk approval audit event is not specific to a particular case hence filter them separately and combine into the case activity events. --- api/cases/views/search/activity.py | 48 +++------------------- api/cases/views/search/activity_filters.py | 24 +++++++++++ 2 files changed, 29 insertions(+), 43 deletions(-) diff --git a/api/cases/views/search/activity.py b/api/cases/views/search/activity.py index f5b923d46a..2097a0227d 100644 --- a/api/cases/views/search/activity.py +++ b/api/cases/views/search/activity.py @@ -4,11 +4,11 @@ from rest_framework.views import APIView from rest_framework.generics import ListAPIView -from api.audit_trail.enums import AuditType from api.audit_trail.models import Audit from api.audit_trail import service as audit_trail_service from api.audit_trail.serializers import AuditSerializer from api.cases.views.search.activity_filters import ( + AuditEventBulkApprovalEventsFilter, AuditEventCaseFilter, AuditEventExporterUserFilter, AuditEventMentionsFilter, @@ -25,54 +25,16 @@ class CaseActivityView(ListAPIView): AuditEventExporterUserFilter, AuditEventMentionsFilter, AuditEventTeamFilter, + AuditEventBulkApprovalEventsFilter, ] serializer_class = AuditSerializer - - def get_queryset(self): - queryset = Audit.objects.all() - case_activity_qs = AuditEventCaseFilter().filter_queryset(self.request, queryset, self) - filtered_qs = AuditEventTeamFilter().filter_queryset(self.request, case_activity_qs, self) - filtered_qs = AuditEventExporterUserFilter().filter_queryset(self.request, filtered_qs, self) - filtered_qs = AuditEventMentionsFilter().filter_queryset(self.request, filtered_qs, self) - - return filtered_qs + queryset = Audit.objects.all() def list(self, request, *args, **kwargs): - queryset = self.get_queryset() - + queryset = self.filter_queryset(self.get_queryset()) serializer = self.get_serializer(queryset, many=True) - print(audit_trail_service.get_filters(request.GET)) - print(queryset.count()) - # breakpoint() - return JsonResponse(data={"activity": serializer.data}, status=status.HTTP_200_OK) - -# class CaseActivityView(APIView): -# authentication_classes = (GovAuthentication,) - -# def get(self, request, pk): -# filter_data = audit_trail_service.get_filters(request.GET) -# content_type = ContentType.objects.get_for_model(Case) -# audit_trail_qs = audit_trail_service.filter_object_activity( -# object_id=pk, object_content_type=content_type, **filter_data -# ) -# case = Case.objects.get(pk=pk) -# bulk_approval_events = [ -# event -# for event in Audit.objects.filter(verb=AuditType.CREATE_BULK_APPROVAL_RECOMMENDATION) -# if case.reference_code in event.payload["case_references"] -# ] -# if filter_data["team"]: -# bulk_approval_events = [ -# event for event in bulk_approval_events if event.payload.get("team_id") == filter_data["team"] -# ] -# bulk_approval_qs = Audit.objects.filter(id__in=[event.id for event in bulk_approval_events]) - -# audit_trail_qs = audit_trail_qs | bulk_approval_qs - -# data = AuditSerializer(audit_trail_qs, many=True).data - -# return JsonResponse(data={"activity": data}, status=status.HTTP_200_OK) + return JsonResponse(data={"activity": serializer.data}, status=status.HTTP_200_OK) class CaseActivityFiltersView(APIView): diff --git a/api/cases/views/search/activity_filters.py b/api/cases/views/search/activity_filters.py index 404ff02d9d..c5505d678a 100644 --- a/api/cases/views/search/activity_filters.py +++ b/api/cases/views/search/activity_filters.py @@ -52,3 +52,27 @@ def filter_queryset(self, request, queryset, view): return queryset return queryset.filter(verb=AuditType.CREATED_CASE_NOTE_WITH_MENTIONS) + + +class AuditEventBulkApprovalEventsFilter(filters.BaseFilterBackend): + + def filter_queryset(self, request, queryset, view): + user_type = request.query_params.get("user_type") + audit_type = request.query_params.get("activity_type") + if user_type or audit_type: + return queryset + + case = Case.objects.get(pk=view.kwargs["pk"]) + bulk_approval_events = [ + event + for event in Audit.objects.filter(verb=AuditType.CREATE_BULK_APPROVAL_RECOMMENDATION) + if case.reference_code in event.payload["case_references"] + ] + + team_id = request.query_params.get("team_id") + if team_id: + bulk_approval_events = [event for event in bulk_approval_events if event.payload.get("team_id") == team_id] + + bulk_approval_qs = Audit.objects.filter(id__in=[event.id for event in bulk_approval_events]) + + return queryset | bulk_approval_qs From 70913ef43e0ef51f097972d6b8d823ad17dfd1ed Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Tue, 7 Jan 2025 18:05:18 +0000 Subject: [PATCH 10/32] Add unit tests for Bulk approval --- api/queues/tests/conftest.py | 202 +++++++++++++++++++++++++ api/queues/tests/test_bulk_approval.py | 126 +++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 api/queues/tests/conftest.py create mode 100644 api/queues/tests/test_bulk_approval.py diff --git a/api/queues/tests/conftest.py b/api/queues/tests/conftest.py new file mode 100644 index 0000000000..21160b4c14 --- /dev/null +++ b/api/queues/tests/conftest.py @@ -0,0 +1,202 @@ +import pytest + +from django.urls import reverse +from rest_framework.test import APIClient + +from api.applications.tests.factories import DraftStandardApplicationFactory +from api.core.constants import ExporterPermissions, GovPermissions, Roles +from api.organisations.tests.factories import OrganisationFactory +from api.parties.tests.factories import PartyDocumentFactory +from api.teams.models import Team +from api.users.libraries.user_to_token import user_to_token +from api.users.models import BaseUser, Permission +from api.users.enums import SystemUser, UserType +from api.users.tests.factories import ( + BaseUserFactory, + ExporterUserFactory, + GovUserFactory, + RoleFactory, + UserOrganisationRelationshipFactory, +) + +from lite_routing.routing_rules_internal.enums import TeamIdEnum + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def setup(gov_user): + pass + + +@pytest.fixture() +def exporter_user(): + return ExporterUserFactory() + + +@pytest.fixture() +def exporter_user_permissions(): + for permission in ExporterPermissions: + Permission.objects.get_or_create(id=permission.name, name=permission.value, type=UserType.EXPORTER.value) + + +@pytest.fixture() +def organisation(exporter_user_permissions, exporter_user): + organisation = OrganisationFactory() + + UserOrganisationRelationshipFactory( + organisation=organisation, + role__permissions=[ExporterPermissions.SUBMIT_LICENCE_APPLICATION.name], + user=exporter_user, + ) + + return organisation + + +@pytest.fixture() +def exporter_headers(exporter_user, organisation): + return { + "HTTP_EXPORTER_USER_TOKEN": user_to_token(exporter_user.baseuser_ptr), + "HTTP_ORGANISATION_ID": str(organisation.id), + } + + +@pytest.fixture(autouse=True) +def system_user(): + if BaseUser.objects.filter(id=SystemUser.id).exists(): + return BaseUser.objects.get(id=SystemUser.id) + else: + return BaseUserFactory(id=SystemUser.id) + + +@pytest.fixture() +def gov_headers(gov_user): + return {"HTTP_GOV_USER_TOKEN": user_to_token(gov_user.baseuser_ptr)} + + +@pytest.fixture() +def gov_user(): + gov_user = GovUserFactory() + gov_user.role = RoleFactory( + id=Roles.INTERNAL_DEFAULT_ROLE_ID, type=UserType.INTERNAL.value, name=Roles.INTERNAL_DEFAULT_ROLE_NAME + ) + gov_user.save() + + return gov_user + + +@pytest.fixture() +def lu_case_officer(gov_user_permissions): + gov_user = GovUserFactory() + gov_user.role = RoleFactory(name="Case officer", type=UserType.INTERNAL) + gov_user.role.permissions.set( + [ + GovPermissions.MANAGE_LICENCE_FINAL_ADVICE.name, + GovPermissions.MANAGE_LICENCE_DURATION.name, + GovPermissions.REOPEN_CLOSED_CASES.name, + ] + ) + gov_user.save() + return gov_user + + +@pytest.fixture() +def lu_case_officer_headers(lu_case_officer): + return {"HTTP_GOV_USER_TOKEN": user_to_token(lu_case_officer.baseuser_ptr)} + + +@pytest.fixture() +def fcdo_officer(): + gov_user = GovUserFactory() + gov_user.team = Team.objects.get(name="FCDO") + gov_user.save() + return gov_user + + +@pytest.fixture() +def fcdo_officer_headers(fcdo_officer): + return {"HTTP_GOV_USER_TOKEN": user_to_token(fcdo_officer.baseuser_ptr)} + + +@pytest.fixture() +def fcdo_countersigner(gov_user_permissions): + gov_user = GovUserFactory() + gov_user.team = Team.objects.get(name="FCDO") + gov_user.role = RoleFactory(name="FCDO Countersigner", type=UserType.INTERNAL) + gov_user.role.permissions.set( + [ + GovPermissions.MANAGE_TEAM_ADVICE.name, + ] + ) + gov_user.save() + return gov_user + + +@pytest.fixture() +def fcdo_countersigner_headers(fcdo_countersigner): + return {"HTTP_GOV_USER_TOKEN": user_to_token(fcdo_countersigner.baseuser_ptr)} + + +@pytest.fixture() +def mod_officer(): + gov_user = GovUserFactory() + gov_user.team = Team.objects.get(id=TeamIdEnum.MOD_CAPPROT) + gov_user.save() + return gov_user + + +@pytest.fixture() +def mod_officer_headers(mod_officer): + return {"HTTP_GOV_USER_TOKEN": user_to_token(mod_officer.baseuser_ptr)} + + +@pytest.fixture() +def gov_user_permissions(): + for permission in GovPermissions: + Permission.objects.get_or_create(id=permission.name, name=permission.value, type=UserType.INTERNAL.value) + + +@pytest.fixture() +def api_client(): + return APIClient() + + +@pytest.fixture +def draft_standard_application(organisation): + application = DraftStandardApplicationFactory(organisation=organisation) + PartyDocumentFactory( + party=application.end_user.party, + s3_key="end-user-undertaking", + safe=True, + ) + return application + + +@pytest.fixture +def submit_application(api_client, exporter_headers, mocker): + def _submit_application(draft_application): + mocker.patch("api.documents.libraries.s3_operations.upload_bytes_file", return_value=None) + response = api_client.put( + reverse( + "applications:application_submit", + kwargs={ + "pk": draft_application.pk, + }, + ), + data={ + "submit_declaration": True, + "agreed_to_declaration_text": "i agree", + }, + **exporter_headers, + ) + assert response.status_code == 200, response.json()["errors"] + + draft_application.refresh_from_db() + return draft_application + + return _submit_application + + +@pytest.fixture +def standard_case(draft_standard_application, submit_application): + return submit_application(draft_standard_application) diff --git a/api/queues/tests/test_bulk_approval.py b/api/queues/tests/test_bulk_approval.py new file mode 100644 index 0000000000..539c3fa23c --- /dev/null +++ b/api/queues/tests/test_bulk_approval.py @@ -0,0 +1,126 @@ +import pytest + +from django.urls import reverse + +from api.applications.models import StandardApplication +from api.applications.tests.factories import DraftStandardApplicationFactory +from api.audit_trail.enums import AuditType +from api.audit_trail.models import Audit +from api.cases.enums import AdviceLevel, AdviceType +from api.parties.tests.factories import PartyDocumentFactory +from api.queues.models import Queue +from api.staticdata.statuses.enums import CaseStatusEnum +from api.staticdata.statuses.models import CaseStatus + +from lite_routing.routing_rules_internal.enums import QueuesEnum, TeamIdEnum + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def fcdo_bulk_approval_url(): + return reverse("queues:bulk_approval", kwargs={"pk": QueuesEnum.FCDO}) + + +@pytest.fixture +def mod_bulk_approval_url(): + return reverse("queues:bulk_approval", kwargs={"pk": QueuesEnum.MOD_CAPPROT}) + + +@pytest.fixture +def multiple_cases_ogd_queue(organisation, submit_application): + + def _multiple_cases_ogd_queue(queue_id, count=5): + ogd_advice = CaseStatus.objects.get(status=CaseStatusEnum.OGD_ADVICE) + draft_applications = [DraftStandardApplicationFactory(organisation=organisation) for i in range(count)] + _ = [ + PartyDocumentFactory( + party=application.end_user.party, + s3_key="party-document", + safe=True, + ) + for application in draft_applications + ] + cases = [submit_application(application) for application in draft_applications] + for case in cases: + case.status = ogd_advice + case.save() + case.queues.add(*[queue_id]) + + return cases + + return _multiple_cases_ogd_queue + + +def case_subjects(case): + application = StandardApplication.objects.get(id=case.id) + return list(application.goods.all()) + list(application.parties.all()) + + +def test_user_bulk_approves_cases(api_client, mod_officer_headers, mod_bulk_approval_url, multiple_cases_ogd_queue): + cases = multiple_cases_ogd_queue(QueuesEnum.MOD_CAPPROT, count=25) + data = { + "case_ids": [str(case.id) for case in cases], + "advice": { + "text": "No concerns", + "proviso": "", + "note": "", + "footnote_required": False, + "footnote": "", + "team": TeamIdEnum.MOD_CAPPROT, + }, + } + response = api_client.post(mod_bulk_approval_url, data=data, **mod_officer_headers) + assert response.status_code == 201 + + for case in cases: + assert case.advice.filter( + level=AdviceLevel.USER, + type=AdviceType.APPROVE, + team_id=TeamIdEnum.MOD_CAPPROT, + ).count() == len(case_subjects(case)) + + assert QueuesEnum.MOD_CAPPROT not in [str(queue.id) for queue in case.queues.all()] + assert QueuesEnum.MOD_ECJU_REVIEW_AND_COMBINE in [str(queue.id) for queue in case.queues.all()] + + audit_event = Audit.objects.get( + verb=AuditType.CREATE_BULK_APPROVAL_RECOMMENDATION, target_object_id=QueuesEnum.MOD_CAPPROT + ) + assert audit_event.payload == { + "case_references": [case.reference_code for case in cases], + "decision": AdviceType.APPROVE, + "level": AdviceLevel.USER, + "queue": Queue.objects.get(id=QueuesEnum.MOD_CAPPROT).name, + "team_id": TeamIdEnum.MOD_CAPPROT, + } + + +def test_user_bulk_approves_fails_for_unsupported_users( + api_client, fcdo_officer_headers, fcdo_bulk_approval_url, multiple_cases_ogd_queue +): + cases = multiple_cases_ogd_queue(QueuesEnum.FCDO) + data = { + "case_ids": [str(case.id) for case in cases], + "advice": { + "text": "No concerns", + "proviso": "", + "note": "", + "footnote_required": False, + "footnote": "", + "team": TeamIdEnum.FCDO, + }, + } + response = api_client.post(fcdo_bulk_approval_url, data=data, **fcdo_officer_headers) + assert response.status_code == 403 + + for case in cases: + assert ( + case.advice.filter( + level=AdviceLevel.USER, + type=AdviceType.APPROVE, + team_id=TeamIdEnum.FCDO, + ).count() + == 0 + ) + + assert QueuesEnum.FCDO in [str(queue.id) for queue in case.queues.all()] From 257fa114121c49cc83cc5e2bac480632b27693b5 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Tue, 7 Jan 2025 18:09:23 +0000 Subject: [PATCH 11/32] Include number of cases approved at once in audit event --- api/queues/tests/test_bulk_approval.py | 1 + api/queues/views/bulk_approval.py | 1 + 2 files changed, 2 insertions(+) diff --git a/api/queues/tests/test_bulk_approval.py b/api/queues/tests/test_bulk_approval.py index 539c3fa23c..3564e8a3c2 100644 --- a/api/queues/tests/test_bulk_approval.py +++ b/api/queues/tests/test_bulk_approval.py @@ -92,6 +92,7 @@ def test_user_bulk_approves_cases(api_client, mod_officer_headers, mod_bulk_appr "level": AdviceLevel.USER, "queue": Queue.objects.get(id=QueuesEnum.MOD_CAPPROT).name, "team_id": TeamIdEnum.MOD_CAPPROT, + "count": len(cases), } diff --git a/api/queues/views/bulk_approval.py b/api/queues/views/bulk_approval.py index 22b41c881c..ba80c13cef 100644 --- a/api/queues/views/bulk_approval.py +++ b/api/queues/views/bulk_approval.py @@ -91,6 +91,7 @@ def create_audit_event(self, request, cases): "level": AdviceLevel.USER, "queue": self.queue.name, "team_id": str(request.user.govuser.team_id), + "count": len(cases), }, ) From 7106a9b8efad3b3ab1dde5ecdcf9d96edcc5c5aa Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Tue, 7 Jan 2025 21:42:06 +0000 Subject: [PATCH 12/32] Fix duplicate role error in tests --- api/queues/tests/conftest.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/api/queues/tests/conftest.py b/api/queues/tests/conftest.py index 21160b4c14..65be58dc0e 100644 --- a/api/queues/tests/conftest.py +++ b/api/queues/tests/conftest.py @@ -76,13 +76,7 @@ def gov_headers(gov_user): @pytest.fixture() def gov_user(): - gov_user = GovUserFactory() - gov_user.role = RoleFactory( - id=Roles.INTERNAL_DEFAULT_ROLE_ID, type=UserType.INTERNAL.value, name=Roles.INTERNAL_DEFAULT_ROLE_NAME - ) - gov_user.save() - - return gov_user + return GovUserFactory() @pytest.fixture() From f388a2e69f843ece8c6dbc241e97ddb2f5253727 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Tue, 7 Jan 2025 23:08:00 +0000 Subject: [PATCH 13/32] Update bulk approve allowed filter to check for allowed queues A team can have multiple queues and we want to enable for specific queues instead of all queues of that team. --- api/core/permissions.py | 18 ++++++++++-------- api/queues/tests/conftest.py | 2 +- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/api/core/permissions.py b/api/core/permissions.py index e1445bff9c..f4caf6d447 100644 --- a/api/core/permissions.py +++ b/api/core/permissions.py @@ -6,13 +6,14 @@ from api.organisations.models import Organisation from api.users.models import GovUser -from lite_routing.routing_rules_internal.enums import TeamIdEnum - -TEAMS_ALLOWED_TO_BULK_APPROVE = [ - TeamIdEnum.MOD_CAPPROT, - TeamIdEnum.MOD_DI, - TeamIdEnum.MOD_DSR, - TeamIdEnum.MOD_DSTL, +from lite_routing.routing_rules_internal.enums import QueuesEnum + +BULK_APPROVE_ALLOWED_QUEUES = [ + QueuesEnum.MOD_CAPPROT, + QueuesEnum.MOD_DI_DIRECT, + QueuesEnum.MOD_DI_INDIRECT, + QueuesEnum.MOD_DSR, + QueuesEnum.MOD_DSTL, ] @@ -65,4 +66,5 @@ def has_permission(self, request, view): class CanCaseworkerBulkApprove(permissions.BasePermission): def has_permission(self, request, view): - return str(request.user.govuser.team_id) in TEAMS_ALLOWED_TO_BULK_APPROVE + queue_pk = view.kwargs["pk"] + return str(queue_pk) in BULK_APPROVE_ALLOWED_QUEUES diff --git a/api/queues/tests/conftest.py b/api/queues/tests/conftest.py index 65be58dc0e..45b8a00ff5 100644 --- a/api/queues/tests/conftest.py +++ b/api/queues/tests/conftest.py @@ -4,7 +4,7 @@ from rest_framework.test import APIClient from api.applications.tests.factories import DraftStandardApplicationFactory -from api.core.constants import ExporterPermissions, GovPermissions, Roles +from api.core.constants import ExporterPermissions, GovPermissions from api.organisations.tests.factories import OrganisationFactory from api.parties.tests.factories import PartyDocumentFactory from api.teams.models import Team From 910d466a108e7dc9cc1a870a8eecf6470e6291ab Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Wed, 8 Jan 2025 16:15:22 +0000 Subject: [PATCH 14/32] Add bulk approval audit event for each case Following review comments it is updated to emit event for each case. This avoids special code to extract and combine these events to show in notes and timeline. This also helps to examine payload and identify relevant events for the special code to work. --- api/audit_trail/formatters.py | 2 +- api/cases/views/search/activity.py | 2 -- api/cases/views/search/activity_filters.py | 24 -------------- api/queues/tests/test_bulk_approval.py | 25 ++++++++------- api/queues/views/bulk_approval.py | 37 +++++++++++++--------- 5 files changed, 36 insertions(+), 54 deletions(-) diff --git a/api/audit_trail/formatters.py b/api/audit_trail/formatters.py index d25e090c67..ce4fabd23d 100644 --- a/api/audit_trail/formatters.py +++ b/api/audit_trail/formatters.py @@ -354,4 +354,4 @@ def amendment_created(**payload): def create_bulk_approval_recommendation(**payload): - return f"approved the case in bulk approval on {payload['queue']} queue." + return f"added bulk approval recommendation on {payload['queue']} queue." diff --git a/api/cases/views/search/activity.py b/api/cases/views/search/activity.py index 2097a0227d..e3e13dbe80 100644 --- a/api/cases/views/search/activity.py +++ b/api/cases/views/search/activity.py @@ -8,7 +8,6 @@ from api.audit_trail import service as audit_trail_service from api.audit_trail.serializers import AuditSerializer from api.cases.views.search.activity_filters import ( - AuditEventBulkApprovalEventsFilter, AuditEventCaseFilter, AuditEventExporterUserFilter, AuditEventMentionsFilter, @@ -25,7 +24,6 @@ class CaseActivityView(ListAPIView): AuditEventExporterUserFilter, AuditEventMentionsFilter, AuditEventTeamFilter, - AuditEventBulkApprovalEventsFilter, ] serializer_class = AuditSerializer queryset = Audit.objects.all() diff --git a/api/cases/views/search/activity_filters.py b/api/cases/views/search/activity_filters.py index c5505d678a..404ff02d9d 100644 --- a/api/cases/views/search/activity_filters.py +++ b/api/cases/views/search/activity_filters.py @@ -52,27 +52,3 @@ def filter_queryset(self, request, queryset, view): return queryset return queryset.filter(verb=AuditType.CREATED_CASE_NOTE_WITH_MENTIONS) - - -class AuditEventBulkApprovalEventsFilter(filters.BaseFilterBackend): - - def filter_queryset(self, request, queryset, view): - user_type = request.query_params.get("user_type") - audit_type = request.query_params.get("activity_type") - if user_type or audit_type: - return queryset - - case = Case.objects.get(pk=view.kwargs["pk"]) - bulk_approval_events = [ - event - for event in Audit.objects.filter(verb=AuditType.CREATE_BULK_APPROVAL_RECOMMENDATION) - if case.reference_code in event.payload["case_references"] - ] - - team_id = request.query_params.get("team_id") - if team_id: - bulk_approval_events = [event for event in bulk_approval_events if event.payload.get("team_id") == team_id] - - bulk_approval_qs = Audit.objects.filter(id__in=[event.id for event in bulk_approval_events]) - - return queryset | bulk_approval_qs diff --git a/api/queues/tests/test_bulk_approval.py b/api/queues/tests/test_bulk_approval.py index 3564e8a3c2..778449cf5c 100644 --- a/api/queues/tests/test_bulk_approval.py +++ b/api/queues/tests/test_bulk_approval.py @@ -82,18 +82,15 @@ def test_user_bulk_approves_cases(api_client, mod_officer_headers, mod_bulk_appr assert QueuesEnum.MOD_CAPPROT not in [str(queue.id) for queue in case.queues.all()] assert QueuesEnum.MOD_ECJU_REVIEW_AND_COMBINE in [str(queue.id) for queue in case.queues.all()] - - audit_event = Audit.objects.get( - verb=AuditType.CREATE_BULK_APPROVAL_RECOMMENDATION, target_object_id=QueuesEnum.MOD_CAPPROT - ) - assert audit_event.payload == { - "case_references": [case.reference_code for case in cases], - "decision": AdviceType.APPROVE, - "level": AdviceLevel.USER, - "queue": Queue.objects.get(id=QueuesEnum.MOD_CAPPROT).name, - "team_id": TeamIdEnum.MOD_CAPPROT, - "count": len(cases), - } + audit_event = Audit.objects.get(target_object_id=case.id, verb=AuditType.CREATE_BULK_APPROVAL_RECOMMENDATION) + assert audit_event.payload == { + "case_references": [case.reference_code for case in cases], + "decision": AdviceType.APPROVE, + "level": AdviceLevel.USER, + "queue": Queue.objects.get(id=QueuesEnum.MOD_CAPPROT).name, + "team_id": TeamIdEnum.MOD_CAPPROT, + "count": len(cases), + } def test_user_bulk_approves_fails_for_unsupported_users( @@ -125,3 +122,7 @@ def test_user_bulk_approves_fails_for_unsupported_users( ) assert QueuesEnum.FCDO in [str(queue.id) for queue in case.queues.all()] + assert ( + Audit.objects.filter(target_object_id=case.id, verb=AuditType.CREATE_BULK_APPROVAL_RECOMMENDATION).exists() + is False + ) diff --git a/api/queues/views/bulk_approval.py b/api/queues/views/bulk_approval.py index ba80c13cef..184e4b4d63 100644 --- a/api/queues/views/bulk_approval.py +++ b/api/queues/views/bulk_approval.py @@ -7,6 +7,7 @@ from api.applications.serializers.advice import BulkApprovalAdviceSerializer 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.cases.enums import AdviceLevel, AdviceType from api.cases.models import Case, CaseAssignment from api.core.authentication import GovAuthentication @@ -80,20 +81,26 @@ def build_instances_data(self, request): return payload - def create_audit_event(self, request, cases): - audit_trail_service.create( - actor=request.user, - verb=AuditType.CREATE_BULK_APPROVAL_RECOMMENDATION, - target=self.queue, - payload={ - "case_references": [case.reference_code for case in cases], - "decision": AdviceType.APPROVE, - "level": AdviceLevel.USER, - "queue": self.queue.name, - "team_id": str(request.user.govuser.team_id), - "count": len(cases), - }, - ) + def create_audit_events(self, request, cases): + case_references = [case.reference_code for case in cases] + events = [ + Audit( + actor=request.user.govuser, + verb=AuditType.CREATE_BULK_APPROVAL_RECOMMENDATION, + target=case, + payload={ + "case_references": case_references, + "decision": AdviceType.APPROVE, + "level": AdviceLevel.USER, + "queue": self.queue.name, + "team_id": str(request.user.govuser.team_id), + "count": len(cases), + }, + ) + for case in cases + ] + + Audit.objects.bulk_create(events) @transaction.atomic def create(self, request, *args, **kwargs): @@ -103,7 +110,7 @@ def create(self, request, *args, **kwargs): super().perform_create(serializer) - self.create_audit_event(request, self.cases) + self.create_audit_events(request, self.cases) self.move_cases_forward(request, self.cases) From ab26b40533eec831fbf5fe0c50b5dd3842f072a3 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Wed, 8 Jan 2025 18:34:19 +0000 Subject: [PATCH 15/32] Re-home bulk approval into a caseworker specific endpoint --- api/conf/caseworker_urls.py | 1 + api/queues/caseworker/__init__.py | 0 api/queues/caseworker/tests/__init__.py | 0 api/queues/{ => caseworker}/tests/conftest.py | 10 ++++++++-- .../{ => caseworker}/tests/test_bulk_approval.py | 4 ++-- api/queues/caseworker/urls.py | 9 +++++++++ api/queues/caseworker/views/__init__.py | 0 api/queues/{ => caseworker}/views/bulk_approval.py | 0 api/queues/urls.py | 3 +-- 9 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 api/queues/caseworker/__init__.py create mode 100644 api/queues/caseworker/tests/__init__.py rename api/queues/{ => caseworker}/tests/conftest.py (95%) rename api/queues/{ => caseworker}/tests/test_bulk_approval.py (96%) create mode 100644 api/queues/caseworker/urls.py create mode 100644 api/queues/caseworker/views/__init__.py rename api/queues/{ => caseworker}/views/bulk_approval.py (100%) diff --git a/api/conf/caseworker_urls.py b/api/conf/caseworker_urls.py index 23c99218bd..e26f3e0336 100644 --- a/api/conf/caseworker_urls.py +++ b/api/conf/caseworker_urls.py @@ -1,6 +1,7 @@ from django.urls import path, include urlpatterns = [ + path("queues/", include("api.queues.caseworker.urls")), path("applications/", include("api.applications.caseworker.urls")), path("organisations/", include("api.organisations.caseworker.urls")), path("static/", include("api.staticdata.caseworker.urls")), diff --git a/api/queues/caseworker/__init__.py b/api/queues/caseworker/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/queues/caseworker/tests/__init__.py b/api/queues/caseworker/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/queues/tests/conftest.py b/api/queues/caseworker/tests/conftest.py similarity index 95% rename from api/queues/tests/conftest.py rename to api/queues/caseworker/tests/conftest.py index 45b8a00ff5..21160b4c14 100644 --- a/api/queues/tests/conftest.py +++ b/api/queues/caseworker/tests/conftest.py @@ -4,7 +4,7 @@ from rest_framework.test import APIClient from api.applications.tests.factories import DraftStandardApplicationFactory -from api.core.constants import ExporterPermissions, GovPermissions +from api.core.constants import ExporterPermissions, GovPermissions, Roles from api.organisations.tests.factories import OrganisationFactory from api.parties.tests.factories import PartyDocumentFactory from api.teams.models import Team @@ -76,7 +76,13 @@ def gov_headers(gov_user): @pytest.fixture() def gov_user(): - return GovUserFactory() + gov_user = GovUserFactory() + gov_user.role = RoleFactory( + id=Roles.INTERNAL_DEFAULT_ROLE_ID, type=UserType.INTERNAL.value, name=Roles.INTERNAL_DEFAULT_ROLE_NAME + ) + gov_user.save() + + return gov_user @pytest.fixture() diff --git a/api/queues/tests/test_bulk_approval.py b/api/queues/caseworker/tests/test_bulk_approval.py similarity index 96% rename from api/queues/tests/test_bulk_approval.py rename to api/queues/caseworker/tests/test_bulk_approval.py index 778449cf5c..88971ab577 100644 --- a/api/queues/tests/test_bulk_approval.py +++ b/api/queues/caseworker/tests/test_bulk_approval.py @@ -19,12 +19,12 @@ @pytest.fixture def fcdo_bulk_approval_url(): - return reverse("queues:bulk_approval", kwargs={"pk": QueuesEnum.FCDO}) + return reverse("caseworker_queues:bulk_approval", kwargs={"pk": QueuesEnum.FCDO}) @pytest.fixture def mod_bulk_approval_url(): - return reverse("queues:bulk_approval", kwargs={"pk": QueuesEnum.MOD_CAPPROT}) + return reverse("caseworker_queues:bulk_approval", kwargs={"pk": QueuesEnum.MOD_CAPPROT}) @pytest.fixture diff --git a/api/queues/caseworker/urls.py b/api/queues/caseworker/urls.py new file mode 100644 index 0000000000..6a141adc3f --- /dev/null +++ b/api/queues/caseworker/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from api.queues.caseworker.views import bulk_approval + +app_name = "caseworker_queues" + +urlpatterns = [ + path("/bulk-approval/", bulk_approval.BulkApprovalCreateView.as_view(), name="bulk_approval"), +] diff --git a/api/queues/caseworker/views/__init__.py b/api/queues/caseworker/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/queues/views/bulk_approval.py b/api/queues/caseworker/views/bulk_approval.py similarity index 100% rename from api/queues/views/bulk_approval.py rename to api/queues/caseworker/views/bulk_approval.py diff --git a/api/queues/urls.py b/api/queues/urls.py index 521b04462e..c4834dbd4c 100644 --- a/api/queues/urls.py +++ b/api/queues/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from api.queues.views import bulk_approval, queues, case_assignments +from api.queues.views import queues, case_assignments app_name = "queues" @@ -12,5 +12,4 @@ case_assignments.CaseAssignments.as_view(), name="case_assignments", ), - path("/bulk-approval/", bulk_approval.BulkApprovalCreateView.as_view(), name="bulk_approval"), ] From 0823d0f5f36504dc8bc2ca8b1f6a7fc5b3c1c3a7 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Thu, 9 Jan 2025 12:31:08 +0000 Subject: [PATCH 16/32] Validate input data using serializer before creating Advice records Following review comments we now use a serializer to validate input data before transforming the data to contruct Advice records. This will ensure the API contract is enforced and validated with serializer. --- api/queues/caseworker/serializers.py | 60 +++++++++++++++++++ .../caseworker/tests/test_bulk_approval.py | 2 +- api/queues/caseworker/views/bulk_approval.py | 57 +++++------------- 3 files changed, 76 insertions(+), 43 deletions(-) create mode 100644 api/queues/caseworker/serializers.py diff --git a/api/queues/caseworker/serializers.py b/api/queues/caseworker/serializers.py new file mode 100644 index 0000000000..3ae799cca9 --- /dev/null +++ b/api/queues/caseworker/serializers.py @@ -0,0 +1,60 @@ +from rest_framework import serializers + +from api.applications.serializers.advice import BulkApprovalAdviceSerializer +from api.cases.enums import AdviceLevel, AdviceType +from api.cases.models import Case +from api.core.serializers import PrimaryKeyRelatedField +from api.teams.models import Team + + +class BulkApprovalAdviceDataSerializer(serializers.Serializer): + text = serializers.CharField() + proviso = serializers.CharField(allow_blank=True, allow_null=True) + note = serializers.CharField(allow_blank=True, allow_null=True) + footnote_required = serializers.BooleanField(allow_null=True) + footnote = serializers.CharField(allow_blank=True, allow_null=True) + team = PrimaryKeyRelatedField(queryset=Team.objects.filter(is_ogd=True)) + + +class BulkApprovalSerializer(serializers.Serializer): + cases = PrimaryKeyRelatedField(many=True, queryset=Case.objects.all()) + advice = BulkApprovalAdviceDataSerializer() + + def get_advice_data(self, application, advice_fields): + user = self.context["user"] + subjects = [("good", good_on_application.good.id) for good_on_application in application.goods.all()] + [ + (poa.party.type, poa.party.id) for poa in application.parties.all() + ] + proviso = advice_fields.get("proviso", "") + advice_type = AdviceType.PROVISO if proviso else AdviceType.APPROVE + return [ + { + "level": AdviceLevel.USER, + "type": advice_type, + "case": str(application.id), + "user": user.govuser, + subject_name: str(subject_id), + "denial_reasons": [], + **advice_fields, + } + for subject_name, subject_id in subjects + ] + + def build_instances_data(self, validated_data): + data = validated_data.copy() + cases = data.get("cases", []) + advice_fields = data.get("advice", {}) + instances_data = [] + for case in cases: + advice_data = self.get_advice_data(case.baseapplication, advice_fields) + instances_data.extend(advice_data) + + return instances_data + + def create(self, validated_data): + data = self.build_instances_data(validated_data) + advice_serializer = BulkApprovalAdviceSerializer(data=data, many=True) + advice_serializer.is_valid(raise_exception=True) + instances = advice_serializer.save() + + return instances diff --git a/api/queues/caseworker/tests/test_bulk_approval.py b/api/queues/caseworker/tests/test_bulk_approval.py index 88971ab577..01ba05ab47 100644 --- a/api/queues/caseworker/tests/test_bulk_approval.py +++ b/api/queues/caseworker/tests/test_bulk_approval.py @@ -60,7 +60,7 @@ def case_subjects(case): def test_user_bulk_approves_cases(api_client, mod_officer_headers, mod_bulk_approval_url, multiple_cases_ogd_queue): cases = multiple_cases_ogd_queue(QueuesEnum.MOD_CAPPROT, count=25) data = { - "case_ids": [str(case.id) for case in cases], + "cases": [str(case.id) for case in cases], "advice": { "text": "No concerns", "proviso": "", diff --git a/api/queues/caseworker/views/bulk_approval.py b/api/queues/caseworker/views/bulk_approval.py index 184e4b4d63..16ef1d1dbc 100644 --- a/api/queues/caseworker/views/bulk_approval.py +++ b/api/queues/caseworker/views/bulk_approval.py @@ -3,15 +3,14 @@ from rest_framework import status from rest_framework.generics import CreateAPIView -from api.applications.models import StandardApplication -from api.applications.serializers.advice import BulkApprovalAdviceSerializer 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.cases.enums import AdviceLevel, AdviceType -from api.cases.models import Case, CaseAssignment +from api.cases.models import CaseAssignment from api.core.authentication import GovAuthentication from api.core.permissions import CanCaseworkerBulkApprove +from api.queues.caseworker.serializers import BulkApprovalSerializer from api.queues.models import Queue from api.workflow.user_queue_assignment import user_queue_assignment_workflow @@ -19,13 +18,19 @@ class BulkApprovalCreateView(CreateAPIView): authentication_classes = (GovAuthentication,) permission_classes = [CanCaseworkerBulkApprove] - serializer_class = BulkApprovalAdviceSerializer + serializer_class = BulkApprovalSerializer def setup(self, request, *args, **kwargs): super().setup(request, *args, **kwargs) self.case_ids = [] self.queue = Queue.objects.get(id=kwargs["pk"]) + def get_serializer_context(self): + context = super().get_serializer_context() + + context["user"] = self.request.user + return context + def move_case_forward(self, request, case): assignments = ( CaseAssignment.objects.select_related("queue").filter(case=case, queue=self.queue).order_by("queue__name") @@ -49,38 +54,6 @@ def move_cases_forward(self, request, cases): for case in cases: self.move_case_forward(request, case) - def get_advice_data(self, request, application): - subjects = [("good", good_on_application.good.id) for good_on_application in application.goods.all()] + [ - (poa.party.type, poa.party.id) for poa in application.parties.all() - ] - proviso = self.advice.get("proviso", "") - advice_type = AdviceType.PROVISO if proviso else AdviceType.APPROVE - return [ - { - "level": AdviceLevel.USER, - "type": advice_type, - "case": str(application.id), - "user": request.user, - subject_name: str(subject_id), - "denial_reasons": [], - **self.advice, - } - for subject_name, subject_id in subjects - ] - - def build_instances_data(self, request): - input_data = request.data.copy() - self.case_ids = input_data.get("case_ids", []) - self.advice = input_data.get("advice", {}) - self.cases = Case.objects.filter(id__in=self.case_ids) - payload = [] - applications = StandardApplication.objects.filter(id__in=self.case_ids) - for application in applications: - advice_data = self.get_advice_data(request, application) - payload.extend(advice_data) - - return payload - def create_audit_events(self, request, cases): case_references = [case.reference_code for case in cases] events = [ @@ -104,17 +77,17 @@ def create_audit_events(self, request, cases): @transaction.atomic def create(self, request, *args, **kwargs): - data = self.build_instances_data(request) - serializer = self.get_serializer(data=data, many=True) + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - super().perform_create(serializer) - self.create_audit_events(request, self.cases) + cases = serializer.validated_data["cases"] + + self.create_audit_events(request, cases) - self.move_cases_forward(request, self.cases) + self.move_cases_forward(request, cases) return JsonResponse( - {"case_ids": self.case_ids}, + {"cases": [case.reference_code for case in cases]}, status=status.HTTP_201_CREATED, ) From 9246b367416dfd7b7dc91a0357a5ae7aac806f9c Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Thu, 9 Jan 2025 12:55:39 +0000 Subject: [PATCH 17/32] Refactor moving case forward as a method in the model This is repeated in few places so best to keep in the model. --- api/cases/models.py | 22 ++++++++++++++++++ api/queues/caseworker/views/bulk_approval.py | 24 +------------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/api/cases/models.py b/api/cases/models.py index 4280a673bf..936ecfb805 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -336,6 +336,28 @@ def no_licence_required(self): notify_exporter_no_licence_required(self) + def move_case_forward(self, queue, user): + from api.audit_trail import service as audit_trail_service + from api.workflow.user_queue_assignment import user_queue_assignment_workflow + + assignments = ( + CaseAssignment.objects.select_related("queue").filter(case=self, queue=queue).order_by("queue__name") + ) + + # Unassign existing case advisors to be able to move forward + if assignments: + assignments.delete() + + # Run routing rules and move the case forward + user_queue_assignment_workflow([queue], self) + + audit_trail_service.create( + actor=user, + verb=AuditType.UNASSIGNED_QUEUES, + target=self, + payload={"queues": [queue.name], "additional_text": ""}, + ) + @transaction.atomic def finalise(self, user, decisions, note): from api.audit_trail import service as audit_trail_service diff --git a/api/queues/caseworker/views/bulk_approval.py b/api/queues/caseworker/views/bulk_approval.py index 16ef1d1dbc..03a20599bd 100644 --- a/api/queues/caseworker/views/bulk_approval.py +++ b/api/queues/caseworker/views/bulk_approval.py @@ -3,16 +3,13 @@ from rest_framework import status from rest_framework.generics import CreateAPIView -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.cases.enums import AdviceLevel, AdviceType -from api.cases.models import CaseAssignment from api.core.authentication import GovAuthentication from api.core.permissions import CanCaseworkerBulkApprove from api.queues.caseworker.serializers import BulkApprovalSerializer from api.queues.models import Queue -from api.workflow.user_queue_assignment import user_queue_assignment_workflow class BulkApprovalCreateView(CreateAPIView): @@ -31,28 +28,9 @@ def get_serializer_context(self): context["user"] = self.request.user return context - def move_case_forward(self, request, case): - assignments = ( - CaseAssignment.objects.select_related("queue").filter(case=case, queue=self.queue).order_by("queue__name") - ) - - # Unassign existing case advisors to be able to move forward - if assignments: - assignments.delete() - - # Run routing rules and move the case forward - user_queue_assignment_workflow([self.queue], case) - - audit_trail_service.create( - actor=request.user, - verb=AuditType.UNASSIGNED_QUEUES, - target=case, - payload={"queues": [self.queue.name], "additional_text": ""}, - ) - def move_cases_forward(self, request, cases): for case in cases: - self.move_case_forward(request, case) + case.move_case_forward(self.queue, request.user) def create_audit_events(self, request, cases): case_references = [case.reference_code for case in cases] From 590b0d13741c65aa12ffe8db5e8773419c9c06ed Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Thu, 9 Jan 2025 13:11:41 +0000 Subject: [PATCH 18/32] Revert "Combine bulk approval audit events to the case audit events" This reverts commit 7e9b4ece6130cfe7c1e0f60dd297d5848e34803b. --- api/cases/views/search/activity.py | 46 ++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/api/cases/views/search/activity.py b/api/cases/views/search/activity.py index e3e13dbe80..f5b923d46a 100644 --- a/api/cases/views/search/activity.py +++ b/api/cases/views/search/activity.py @@ -4,6 +4,7 @@ from rest_framework.views import APIView from rest_framework.generics import ListAPIView +from api.audit_trail.enums import AuditType from api.audit_trail.models import Audit from api.audit_trail import service as audit_trail_service from api.audit_trail.serializers import AuditSerializer @@ -26,15 +27,54 @@ class CaseActivityView(ListAPIView): AuditEventTeamFilter, ] serializer_class = AuditSerializer - queryset = Audit.objects.all() + + def get_queryset(self): + queryset = Audit.objects.all() + case_activity_qs = AuditEventCaseFilter().filter_queryset(self.request, queryset, self) + filtered_qs = AuditEventTeamFilter().filter_queryset(self.request, case_activity_qs, self) + filtered_qs = AuditEventExporterUserFilter().filter_queryset(self.request, filtered_qs, self) + filtered_qs = AuditEventMentionsFilter().filter_queryset(self.request, filtered_qs, self) + + return filtered_qs def list(self, request, *args, **kwargs): - queryset = self.filter_queryset(self.get_queryset()) - serializer = self.get_serializer(queryset, many=True) + queryset = self.get_queryset() + serializer = self.get_serializer(queryset, many=True) + print(audit_trail_service.get_filters(request.GET)) + print(queryset.count()) + # breakpoint() return JsonResponse(data={"activity": serializer.data}, status=status.HTTP_200_OK) +# class CaseActivityView(APIView): +# authentication_classes = (GovAuthentication,) + +# def get(self, request, pk): +# filter_data = audit_trail_service.get_filters(request.GET) +# content_type = ContentType.objects.get_for_model(Case) +# audit_trail_qs = audit_trail_service.filter_object_activity( +# object_id=pk, object_content_type=content_type, **filter_data +# ) +# case = Case.objects.get(pk=pk) +# bulk_approval_events = [ +# event +# for event in Audit.objects.filter(verb=AuditType.CREATE_BULK_APPROVAL_RECOMMENDATION) +# if case.reference_code in event.payload["case_references"] +# ] +# if filter_data["team"]: +# bulk_approval_events = [ +# event for event in bulk_approval_events if event.payload.get("team_id") == filter_data["team"] +# ] +# bulk_approval_qs = Audit.objects.filter(id__in=[event.id for event in bulk_approval_events]) + +# audit_trail_qs = audit_trail_qs | bulk_approval_qs + +# data = AuditSerializer(audit_trail_qs, many=True).data + +# return JsonResponse(data={"activity": data}, status=status.HTTP_200_OK) + + class CaseActivityFiltersView(APIView): authentication_classes = (GovAuthentication,) From 8b45c6117602ef4b8f42e29edb7be47e1856780a Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Thu, 9 Jan 2025 13:11:58 +0000 Subject: [PATCH 19/32] Revert "Refactor Case activity view to use filter backends" This reverts commit 9cff9da708ce6fb2e948adfdc9eeecb5f507fc00. --- api/cases/views/search/activity.py | 84 +++++++--------------- api/cases/views/search/activity_filters.py | 54 -------------- 2 files changed, 24 insertions(+), 114 deletions(-) delete mode 100644 api/cases/views/search/activity_filters.py diff --git a/api/cases/views/search/activity.py b/api/cases/views/search/activity.py index f5b923d46a..0c7039c4bb 100644 --- a/api/cases/views/search/activity.py +++ b/api/cases/views/search/activity.py @@ -2,77 +2,41 @@ from django.http import JsonResponse from rest_framework import status from rest_framework.views import APIView -from rest_framework.generics import ListAPIView from api.audit_trail.enums import AuditType from api.audit_trail.models import Audit from api.audit_trail import service as audit_trail_service from api.audit_trail.serializers import AuditSerializer -from api.cases.views.search.activity_filters import ( - AuditEventCaseFilter, - AuditEventExporterUserFilter, - AuditEventMentionsFilter, - AuditEventTeamFilter, -) from api.cases.models import Case from api.core.authentication import GovAuthentication -class CaseActivityView(ListAPIView): +class CaseActivityView(APIView): authentication_classes = (GovAuthentication,) - filter_backends = [ - AuditEventCaseFilter, - AuditEventExporterUserFilter, - AuditEventMentionsFilter, - AuditEventTeamFilter, - ] - serializer_class = AuditSerializer - def get_queryset(self): - queryset = Audit.objects.all() - case_activity_qs = AuditEventCaseFilter().filter_queryset(self.request, queryset, self) - filtered_qs = AuditEventTeamFilter().filter_queryset(self.request, case_activity_qs, self) - filtered_qs = AuditEventExporterUserFilter().filter_queryset(self.request, filtered_qs, self) - filtered_qs = AuditEventMentionsFilter().filter_queryset(self.request, filtered_qs, self) - - return filtered_qs - - def list(self, request, *args, **kwargs): - queryset = self.get_queryset() - - serializer = self.get_serializer(queryset, many=True) - print(audit_trail_service.get_filters(request.GET)) - print(queryset.count()) - # breakpoint() - return JsonResponse(data={"activity": serializer.data}, status=status.HTTP_200_OK) - - -# class CaseActivityView(APIView): -# authentication_classes = (GovAuthentication,) - -# def get(self, request, pk): -# filter_data = audit_trail_service.get_filters(request.GET) -# content_type = ContentType.objects.get_for_model(Case) -# audit_trail_qs = audit_trail_service.filter_object_activity( -# object_id=pk, object_content_type=content_type, **filter_data -# ) -# case = Case.objects.get(pk=pk) -# bulk_approval_events = [ -# event -# for event in Audit.objects.filter(verb=AuditType.CREATE_BULK_APPROVAL_RECOMMENDATION) -# if case.reference_code in event.payload["case_references"] -# ] -# if filter_data["team"]: -# bulk_approval_events = [ -# event for event in bulk_approval_events if event.payload.get("team_id") == filter_data["team"] -# ] -# bulk_approval_qs = Audit.objects.filter(id__in=[event.id for event in bulk_approval_events]) - -# audit_trail_qs = audit_trail_qs | bulk_approval_qs - -# data = AuditSerializer(audit_trail_qs, many=True).data - -# return JsonResponse(data={"activity": data}, status=status.HTTP_200_OK) + def get(self, request, pk): + filter_data = audit_trail_service.get_filters(request.GET) + content_type = ContentType.objects.get_for_model(Case) + audit_trail_qs = audit_trail_service.filter_object_activity( + object_id=pk, object_content_type=content_type, **filter_data + ) + case = Case.objects.get(pk=pk) + bulk_approval_events = [ + event + for event in Audit.objects.filter(verb=AuditType.CREATE_BULK_APPROVAL_RECOMMENDATION) + if case.reference_code in event.payload["case_references"] + ] + if filter_data["team"]: + bulk_approval_events = [ + event for event in bulk_approval_events if event.payload.get("team_id") == filter_data["team"] + ] + bulk_approval_qs = Audit.objects.filter(id__in=[event.id for event in bulk_approval_events]) + + audit_trail_qs = audit_trail_qs | bulk_approval_qs + + data = AuditSerializer(audit_trail_qs, many=True).data + + return JsonResponse(data={"activity": data}, status=status.HTTP_200_OK) class CaseActivityFiltersView(APIView): diff --git a/api/cases/views/search/activity_filters.py b/api/cases/views/search/activity_filters.py deleted file mode 100644 index 404ff02d9d..0000000000 --- a/api/cases/views/search/activity_filters.py +++ /dev/null @@ -1,54 +0,0 @@ -from django.contrib.contenttypes.models import ContentType -from django.db.models import Q -from rest_framework import filters - -from api.audit_trail.enums import AuditType -from api.audit_trail.models import Audit -from api.cases.models import Case -from api.users.models import ExporterUser, GovUser - - -class AuditEventCaseFilter(filters.BaseFilterBackend): - - def filter_queryset(self, request, queryset, view): - pk = view.kwargs["pk"] - content_type = ContentType.objects.get_for_model(Case) - queryset = Audit.objects.filter( - Q(action_object_object_id=pk, action_object_content_type=content_type) - | Q(target_object_id=pk, target_content_type=content_type) - ) - return queryset - - -class AuditEventTeamFilter(filters.BaseFilterBackend): - - def filter_queryset(self, request, queryset, view): - team = request.query_params.get("team_id") - if not team: - return queryset - - gov_content_type = ContentType.objects.get_for_model(GovUser) - user_ids = queryset.filter(actor_content_type=gov_content_type).values_list("actor_object_id", flat=True) - team_user_ids = GovUser.objects.filter(pk__in=list(user_ids), team=team).values_list("pk", flat=True) - return queryset.filter(actor_object_id__in=list(team_user_ids)) - - -class AuditEventExporterUserFilter(filters.BaseFilterBackend): - - def filter_queryset(self, request, queryset, view): - user_type = request.query_params.get("user_type") - if not user_type: - return queryset - - user_type_content_type = ContentType.objects.get_for_model(ExporterUser) - return queryset.filter(actor_content_type=user_type_content_type) - - -class AuditEventMentionsFilter(filters.BaseFilterBackend): - - def filter_queryset(self, request, queryset, view): - audit_type = request.query_params.get("activity_type") - if not audit_type: - return queryset - - return queryset.filter(verb=AuditType.CREATED_CASE_NOTE_WITH_MENTIONS) From bab1530fe713a047dfa205f0c48b7c87d58e06e5 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Thu, 9 Jan 2025 13:21:18 +0000 Subject: [PATCH 20/32] Remove temporary debug code --- api/cases/views/search/activity.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/api/cases/views/search/activity.py b/api/cases/views/search/activity.py index 0c7039c4bb..8a3e9bb7e3 100644 --- a/api/cases/views/search/activity.py +++ b/api/cases/views/search/activity.py @@ -3,8 +3,6 @@ from rest_framework import status from rest_framework.views import APIView -from api.audit_trail.enums import AuditType -from api.audit_trail.models import Audit from api.audit_trail import service as audit_trail_service from api.audit_trail.serializers import AuditSerializer from api.cases.models import Case @@ -20,19 +18,6 @@ def get(self, request, pk): audit_trail_qs = audit_trail_service.filter_object_activity( object_id=pk, object_content_type=content_type, **filter_data ) - case = Case.objects.get(pk=pk) - bulk_approval_events = [ - event - for event in Audit.objects.filter(verb=AuditType.CREATE_BULK_APPROVAL_RECOMMENDATION) - if case.reference_code in event.payload["case_references"] - ] - if filter_data["team"]: - bulk_approval_events = [ - event for event in bulk_approval_events if event.payload.get("team_id") == filter_data["team"] - ] - bulk_approval_qs = Audit.objects.filter(id__in=[event.id for event in bulk_approval_events]) - - audit_trail_qs = audit_trail_qs | bulk_approval_qs data = AuditSerializer(audit_trail_qs, many=True).data From 6aefc889b99471ad22702868566cd70d7911d6d1 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Thu, 9 Jan 2025 13:41:40 +0000 Subject: [PATCH 21/32] Add missing conftest file --- api/queues/tests/conftest.py | 196 +++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 api/queues/tests/conftest.py diff --git a/api/queues/tests/conftest.py b/api/queues/tests/conftest.py new file mode 100644 index 0000000000..65be58dc0e --- /dev/null +++ b/api/queues/tests/conftest.py @@ -0,0 +1,196 @@ +import pytest + +from django.urls import reverse +from rest_framework.test import APIClient + +from api.applications.tests.factories import DraftStandardApplicationFactory +from api.core.constants import ExporterPermissions, GovPermissions, Roles +from api.organisations.tests.factories import OrganisationFactory +from api.parties.tests.factories import PartyDocumentFactory +from api.teams.models import Team +from api.users.libraries.user_to_token import user_to_token +from api.users.models import BaseUser, Permission +from api.users.enums import SystemUser, UserType +from api.users.tests.factories import ( + BaseUserFactory, + ExporterUserFactory, + GovUserFactory, + RoleFactory, + UserOrganisationRelationshipFactory, +) + +from lite_routing.routing_rules_internal.enums import TeamIdEnum + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def setup(gov_user): + pass + + +@pytest.fixture() +def exporter_user(): + return ExporterUserFactory() + + +@pytest.fixture() +def exporter_user_permissions(): + for permission in ExporterPermissions: + Permission.objects.get_or_create(id=permission.name, name=permission.value, type=UserType.EXPORTER.value) + + +@pytest.fixture() +def organisation(exporter_user_permissions, exporter_user): + organisation = OrganisationFactory() + + UserOrganisationRelationshipFactory( + organisation=organisation, + role__permissions=[ExporterPermissions.SUBMIT_LICENCE_APPLICATION.name], + user=exporter_user, + ) + + return organisation + + +@pytest.fixture() +def exporter_headers(exporter_user, organisation): + return { + "HTTP_EXPORTER_USER_TOKEN": user_to_token(exporter_user.baseuser_ptr), + "HTTP_ORGANISATION_ID": str(organisation.id), + } + + +@pytest.fixture(autouse=True) +def system_user(): + if BaseUser.objects.filter(id=SystemUser.id).exists(): + return BaseUser.objects.get(id=SystemUser.id) + else: + return BaseUserFactory(id=SystemUser.id) + + +@pytest.fixture() +def gov_headers(gov_user): + return {"HTTP_GOV_USER_TOKEN": user_to_token(gov_user.baseuser_ptr)} + + +@pytest.fixture() +def gov_user(): + return GovUserFactory() + + +@pytest.fixture() +def lu_case_officer(gov_user_permissions): + gov_user = GovUserFactory() + gov_user.role = RoleFactory(name="Case officer", type=UserType.INTERNAL) + gov_user.role.permissions.set( + [ + GovPermissions.MANAGE_LICENCE_FINAL_ADVICE.name, + GovPermissions.MANAGE_LICENCE_DURATION.name, + GovPermissions.REOPEN_CLOSED_CASES.name, + ] + ) + gov_user.save() + return gov_user + + +@pytest.fixture() +def lu_case_officer_headers(lu_case_officer): + return {"HTTP_GOV_USER_TOKEN": user_to_token(lu_case_officer.baseuser_ptr)} + + +@pytest.fixture() +def fcdo_officer(): + gov_user = GovUserFactory() + gov_user.team = Team.objects.get(name="FCDO") + gov_user.save() + return gov_user + + +@pytest.fixture() +def fcdo_officer_headers(fcdo_officer): + return {"HTTP_GOV_USER_TOKEN": user_to_token(fcdo_officer.baseuser_ptr)} + + +@pytest.fixture() +def fcdo_countersigner(gov_user_permissions): + gov_user = GovUserFactory() + gov_user.team = Team.objects.get(name="FCDO") + gov_user.role = RoleFactory(name="FCDO Countersigner", type=UserType.INTERNAL) + gov_user.role.permissions.set( + [ + GovPermissions.MANAGE_TEAM_ADVICE.name, + ] + ) + gov_user.save() + return gov_user + + +@pytest.fixture() +def fcdo_countersigner_headers(fcdo_countersigner): + return {"HTTP_GOV_USER_TOKEN": user_to_token(fcdo_countersigner.baseuser_ptr)} + + +@pytest.fixture() +def mod_officer(): + gov_user = GovUserFactory() + gov_user.team = Team.objects.get(id=TeamIdEnum.MOD_CAPPROT) + gov_user.save() + return gov_user + + +@pytest.fixture() +def mod_officer_headers(mod_officer): + return {"HTTP_GOV_USER_TOKEN": user_to_token(mod_officer.baseuser_ptr)} + + +@pytest.fixture() +def gov_user_permissions(): + for permission in GovPermissions: + Permission.objects.get_or_create(id=permission.name, name=permission.value, type=UserType.INTERNAL.value) + + +@pytest.fixture() +def api_client(): + return APIClient() + + +@pytest.fixture +def draft_standard_application(organisation): + application = DraftStandardApplicationFactory(organisation=organisation) + PartyDocumentFactory( + party=application.end_user.party, + s3_key="end-user-undertaking", + safe=True, + ) + return application + + +@pytest.fixture +def submit_application(api_client, exporter_headers, mocker): + def _submit_application(draft_application): + mocker.patch("api.documents.libraries.s3_operations.upload_bytes_file", return_value=None) + response = api_client.put( + reverse( + "applications:application_submit", + kwargs={ + "pk": draft_application.pk, + }, + ), + data={ + "submit_declaration": True, + "agreed_to_declaration_text": "i agree", + }, + **exporter_headers, + ) + assert response.status_code == 200, response.json()["errors"] + + draft_application.refresh_from_db() + return draft_application + + return _submit_application + + +@pytest.fixture +def standard_case(draft_standard_application, submit_application): + return submit_application(draft_standard_application) From f6a17d490b3e2db3eb862a6bed8a0238640003fd Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Fri, 10 Jan 2025 13:30:52 +0000 Subject: [PATCH 22/32] Add unit test for the move case forward function The test takes a case that is assigned to an OGD queue and moves it forward. To be able to verify that the current queue is unassigned and next queue as per routing rules is assigned correctly the case is initially processed through product assessments and pre-circ. This is to prevent triggering of fallback routing rules which otherwise doesn't unassign the current queue and in addition routes to this fallback queue as this rules relies on presence of specific audit events. Though this verifies next queue as per routing rules this is not be considered a test for routing rules. --- .../caseworker/tests/test_bulk_approval.py | 95 ++++++++++++++++--- 1 file changed, 82 insertions(+), 13 deletions(-) diff --git a/api/queues/caseworker/tests/test_bulk_approval.py b/api/queues/caseworker/tests/test_bulk_approval.py index 01ba05ab47..79d3d3ae38 100644 --- a/api/queues/caseworker/tests/test_bulk_approval.py +++ b/api/queues/caseworker/tests/test_bulk_approval.py @@ -7,10 +7,15 @@ from api.audit_trail.enums import AuditType from api.audit_trail.models import Audit from api.cases.enums import AdviceLevel, AdviceType +from api.cases.models import CaseAssignment +from api.cases.tests.factories import CaseAssignmentFactory from api.parties.tests.factories import PartyDocumentFactory from api.queues.models import Queue +from api.staticdata.control_list_entries.models import ControlListEntry from api.staticdata.statuses.enums import CaseStatusEnum from api.staticdata.statuses.models import CaseStatus +from api.teams.models import Team +from api.users.tests.factories import GovUserFactory from lite_routing.routing_rules_internal.enums import QueuesEnum, TeamIdEnum @@ -28,28 +33,49 @@ def mod_bulk_approval_url(): @pytest.fixture -def multiple_cases_ogd_queue(organisation, submit_application): +def get_cases_with_ogd_queue_assigned(organisation, submit_application): - def _multiple_cases_ogd_queue(queue_id, count=5): - ogd_advice = CaseStatus.objects.get(status=CaseStatusEnum.OGD_ADVICE) - draft_applications = [DraftStandardApplicationFactory(organisation=organisation) for i in range(count)] + def _get_cases_with_ogd_queue_assigned(queue_id, count=5): + applications = [DraftStandardApplicationFactory(organisation=organisation) for i in range(count)] _ = [ PartyDocumentFactory( party=application.end_user.party, s3_key="party-document", safe=True, ) - for application in draft_applications + for application in applications ] - cases = [submit_application(application) for application in draft_applications] + cases = [submit_application(application) for application in applications] + + cle = ControlListEntry.objects.get(rating="PL9002b") + gov_user = GovUserFactory() + gov_user.team = Team.objects.get(id=TeamIdEnum.LICENSING_UNIT) + gov_user.save() + queue = Queue.objects.get(id=QueuesEnum.LU_PRE_CIRC) + + for application in applications: + for good_on_application in application.goods.all(): + good_on_application.is_good_controlled = True + good_on_application.control_list_entries.add(*[cle]) + good_on_application.good.control_list_entries.add(*[cle]) + for case in cases: - case.status = ogd_advice + case.status = CaseStatus.objects.get(status=CaseStatusEnum.UNDER_REVIEW) case.save() - case.queues.add(*[queue_id]) + case.queues.set([QueuesEnum.LU_PRE_CIRC]) + case.refresh_from_db() + + # Circulate to OGDs + case.move_case_forward(queue, gov_user) + + # Reset queue with the specified OGD queue only + queue = Queue.objects.get(id=queue_id) + case.refresh_from_db() + case.queues.set([queue]) return cases - return _multiple_cases_ogd_queue + return _get_cases_with_ogd_queue_assigned def case_subjects(case): @@ -57,8 +83,10 @@ def case_subjects(case): return list(application.goods.all()) + list(application.parties.all()) -def test_user_bulk_approves_cases(api_client, mod_officer_headers, mod_bulk_approval_url, multiple_cases_ogd_queue): - cases = multiple_cases_ogd_queue(QueuesEnum.MOD_CAPPROT, count=25) +def test_user_bulk_approves_cases( + api_client, mod_officer_headers, mod_bulk_approval_url, get_cases_with_ogd_queue_assigned +): + cases = get_cases_with_ogd_queue_assigned(QueuesEnum.MOD_CAPPROT, count=25) data = { "cases": [str(case.id) for case in cases], "advice": { @@ -94,9 +122,9 @@ def test_user_bulk_approves_cases(api_client, mod_officer_headers, mod_bulk_appr def test_user_bulk_approves_fails_for_unsupported_users( - api_client, fcdo_officer_headers, fcdo_bulk_approval_url, multiple_cases_ogd_queue + api_client, fcdo_officer_headers, fcdo_bulk_approval_url, get_cases_with_ogd_queue_assigned ): - cases = multiple_cases_ogd_queue(QueuesEnum.FCDO) + cases = get_cases_with_ogd_queue_assigned(QueuesEnum.FCDO) data = { "case_ids": [str(case.id) for case in cases], "advice": { @@ -126,3 +154,44 @@ def test_user_bulk_approves_fails_for_unsupported_users( Audit.objects.filter(target_object_id=case.id, verb=AuditType.CREATE_BULK_APPROVAL_RECOMMENDATION).exists() is False ) + + +@pytest.mark.parametrize( + "team_id, queue_id,next_queue_id", + ( + (TeamIdEnum.DESNZ_CHEMICAL, QueuesEnum.DESNZ_CHEMICAL, QueuesEnum.LU_POST_CIRC), + (TeamIdEnum.DESNZ_NUCLEAR, QueuesEnum.DESNZ_NUCLEAR, QueuesEnum.DESNZ_NUCLEAR_COUNTERSIGNING), + (TeamIdEnum.FCDO, QueuesEnum.FCDO, QueuesEnum.FCDO_COUNTER_SIGNING), + (TeamIdEnum.FCDO, QueuesEnum.FCDO_COUNTER_SIGNING, QueuesEnum.LU_POST_CIRC), + (TeamIdEnum.MOD_CAPPROT, QueuesEnum.MOD_CAPPROT, QueuesEnum.MOD_ECJU_REVIEW_AND_COMBINE), + (TeamIdEnum.MOD_DI, QueuesEnum.MOD_DI_DIRECT, QueuesEnum.LU_POST_CIRC), + (TeamIdEnum.MOD_DI, QueuesEnum.MOD_DI_INDIRECT, QueuesEnum.MOD_ECJU_REVIEW_AND_COMBINE), + (TeamIdEnum.MOD_DSR, QueuesEnum.MOD_DSR, QueuesEnum.MOD_ECJU_REVIEW_AND_COMBINE), + (TeamIdEnum.MOD_DSTL, QueuesEnum.MOD_DSTL, QueuesEnum.MOD_ECJU_REVIEW_AND_COMBINE), + (TeamIdEnum.NCSC, QueuesEnum.NCSC, QueuesEnum.LU_POST_CIRC), + ), +) +def test_case_move_forward( + get_cases_with_ogd_queue_assigned, + team_id, + queue_id, + next_queue_id, +): + + case = get_cases_with_ogd_queue_assigned(queue_id, count=1)[0] + gov_user = GovUserFactory() + gov_user.team = Team.objects.get(id=team_id) + gov_user.save() + + queue = Queue.objects.get(id=queue_id) + CaseAssignmentFactory(case=case, queue=queue, user=gov_user) + + case.move_case_forward(queue, gov_user) + + assert CaseAssignment.objects.filter(case=case, queue=queue, user=gov_user).exists() is False + assert queue_id not in [str(queue.id) for queue in case.queues.all()] + assert next_queue_id in [str(queue.id) for queue in case.queues.all()] + audit_event = Audit.objects.filter(target_object_id=case.id, verb=AuditType.UNASSIGNED_QUEUES).first() + assert audit_event.payload == { + "queues": [queue.name], + } From 0f58087a4706339410c7e87b3ce06b7195d7eb1e Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Fri, 10 Jan 2025 16:54:23 +0000 Subject: [PATCH 23/32] Allow bulk approve on NCSC queue as well Add unit test for bulk approval permission --- api/core/permissions.py | 17 ++++++++-------- api/core/tests/test_permissions.py | 31 +++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/api/core/permissions.py b/api/core/permissions.py index f4caf6d447..de00ce78e3 100644 --- a/api/core/permissions.py +++ b/api/core/permissions.py @@ -8,13 +8,14 @@ from lite_routing.routing_rules_internal.enums import QueuesEnum -BULK_APPROVE_ALLOWED_QUEUES = [ - QueuesEnum.MOD_CAPPROT, - QueuesEnum.MOD_DI_DIRECT, - QueuesEnum.MOD_DI_INDIRECT, - QueuesEnum.MOD_DSR, - QueuesEnum.MOD_DSTL, -] +BULK_APPROVE_ALLOWED_QUEUES = { + "MOD_CAPPROT": QueuesEnum.MOD_CAPPROT, + "MOD_DI_DIRECT": QueuesEnum.MOD_DI_DIRECT, + "MOD_DI_INDIRECT": QueuesEnum.MOD_DI_INDIRECT, + "MOD_DSR": QueuesEnum.MOD_DSR, + "MOD_DSTL": QueuesEnum.MOD_DSTL, + "NCSC": QueuesEnum.NCSC, +} def assert_user_has_permission(user, permission, organisation: Organisation = None): @@ -67,4 +68,4 @@ def has_permission(self, request, view): class CanCaseworkerBulkApprove(permissions.BasePermission): def has_permission(self, request, view): queue_pk = view.kwargs["pk"] - return str(queue_pk) in BULK_APPROVE_ALLOWED_QUEUES + return str(queue_pk) in BULK_APPROVE_ALLOWED_QUEUES.values() diff --git a/api/core/tests/test_permissions.py b/api/core/tests/test_permissions.py index e4a5e7bf16..15f2451bc2 100644 --- a/api/core/tests/test_permissions.py +++ b/api/core/tests/test_permissions.py @@ -1,10 +1,16 @@ from unittest import mock from parameterized import parameterized -from api.core.permissions import CaseInCaseworkerOperableStatus +from django.urls import reverse +from rest_framework.request import Request +from rest_framework.test import APIRequestFactory + +from api.core.permissions import BULK_APPROVE_ALLOWED_QUEUES, CanCaseworkerBulkApprove, CaseInCaseworkerOperableStatus from api.applications.tests.factories import StandardApplicationFactory +from api.queues.caseworker.views.bulk_approval import BulkApprovalCreateView from api.staticdata.statuses.enums import CaseStatusEnum from api.staticdata.statuses.models import CaseStatus +from lite_routing.routing_rules_internal.enums import QueuesEnum, TeamIdEnum from test_helpers.clients import DataTestClient @@ -28,3 +34,26 @@ def test_has_permission_caseworker_inoperable(self, status): mock_view.get_case.return_value = application.get_case() permission_obj = CaseInCaseworkerOperableStatus() assert permission_obj.has_permission(None, mock_view) is False + + +class TestCanCaseworkerBulkApprove(DataTestClient): + + @parameterized.expand( + [ + (BULK_APPROVE_ALLOWED_QUEUES["MOD_CAPPROT"], True), + (BULK_APPROVE_ALLOWED_QUEUES["MOD_DI_DIRECT"], True), + (BULK_APPROVE_ALLOWED_QUEUES["MOD_DI_INDIRECT"], True), + (BULK_APPROVE_ALLOWED_QUEUES["MOD_DSR"], True), + (BULK_APPROVE_ALLOWED_QUEUES["MOD_DSTL"], True), + (BULK_APPROVE_ALLOWED_QUEUES["NCSC"], True), + (QueuesEnum.FCDO, False), + (QueuesEnum.FCDO_COUNTER_SIGNING, False), + (QueuesEnum.DESNZ_CHEMICAL, False), + (QueuesEnum.DESNZ_NUCLEAR, False), + ] + ) + def test_has_permission_caseworker_bulk_approve(self, queue_id, expected): + view = BulkApprovalCreateView() + view.kwargs = {"pk": queue_id} + permission_obj = CanCaseworkerBulkApprove() + assert permission_obj.has_permission(None, view) is expected From c27eb8654597d2efdadc7605c856c0b114c75c5b Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Sun, 12 Jan 2025 22:47:03 +0000 Subject: [PATCH 24/32] Update bulk approval audit event content --- api/audit_trail/formatters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/audit_trail/formatters.py b/api/audit_trail/formatters.py index ce4fabd23d..6c726e6c4c 100644 --- a/api/audit_trail/formatters.py +++ b/api/audit_trail/formatters.py @@ -354,4 +354,4 @@ def amendment_created(**payload): def create_bulk_approval_recommendation(**payload): - return f"added bulk approval recommendation on {payload['queue']} queue." + return f"added a recommendation using the Approve button in the queue." From 7cd2215eec6683ede23d53bf15e2b60343082aee Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Mon, 13 Jan 2025 10:36:07 +0000 Subject: [PATCH 25/32] Add more bulk approval unit tests Update tests such that we cover queues of all teams that has bulk approval support and queues that don't support. --- .../caseworker/tests/test_bulk_approval.py | 90 +++++++++++++++---- 1 file changed, 72 insertions(+), 18 deletions(-) diff --git a/api/queues/caseworker/tests/test_bulk_approval.py b/api/queues/caseworker/tests/test_bulk_approval.py index 79d3d3ae38..40056a3c28 100644 --- a/api/queues/caseworker/tests/test_bulk_approval.py +++ b/api/queues/caseworker/tests/test_bulk_approval.py @@ -15,6 +15,7 @@ from api.staticdata.statuses.enums import CaseStatusEnum from api.staticdata.statuses.models import CaseStatus from api.teams.models import Team +from api.users.libraries.user_to_token import user_to_token from api.users.tests.factories import GovUserFactory from lite_routing.routing_rules_internal.enums import QueuesEnum, TeamIdEnum @@ -32,6 +33,26 @@ def mod_bulk_approval_url(): return reverse("caseworker_queues:bulk_approval", kwargs={"pk": QueuesEnum.MOD_CAPPROT}) +@pytest.fixture() +def team_case_advisor(): + def _team_case_advisor(team_id): + gov_user = GovUserFactory() + gov_user.team = Team.objects.get(id=team_id) + gov_user.save() + return gov_user + + return _team_case_advisor + + +@pytest.fixture() +def team_case_advisor_headers(team_case_advisor): + def _team_case_advisor_headers(team_id): + case_advisor = team_case_advisor(team_id) + return {"HTTP_GOV_USER_TOKEN": user_to_token(case_advisor.baseuser_ptr)} + + return _team_case_advisor_headers + + @pytest.fixture def get_cases_with_ogd_queue_assigned(organisation, submit_application): @@ -47,7 +68,7 @@ def _get_cases_with_ogd_queue_assigned(queue_id, count=5): ] cases = [submit_application(application) for application in applications] - cle = ControlListEntry.objects.get(rating="PL9002b") + cle = ControlListEntry.objects.get(rating="ML2a") gov_user = GovUserFactory() gov_user.team = Team.objects.get(id=TeamIdEnum.LICENSING_UNIT) gov_user.save() @@ -56,6 +77,7 @@ def _get_cases_with_ogd_queue_assigned(queue_id, count=5): for application in applications: for good_on_application in application.goods.all(): good_on_application.is_good_controlled = True + good_on_application.save() good_on_application.control_list_entries.add(*[cle]) good_on_application.good.control_list_entries.add(*[cle]) @@ -66,12 +88,13 @@ def _get_cases_with_ogd_queue_assigned(queue_id, count=5): case.refresh_from_db() # Circulate to OGDs + queue = Queue.objects.get(id=QueuesEnum.LU_PRE_CIRC) case.move_case_forward(queue, gov_user) # Reset queue with the specified OGD queue only - queue = Queue.objects.get(id=queue_id) case.refresh_from_db() - case.queues.set([queue]) + target_queue = Queue.objects.get(id=queue_id) + case.queues.set([target_queue]) return cases @@ -83,10 +106,26 @@ def case_subjects(case): return list(application.goods.all()) + list(application.parties.all()) +@pytest.mark.parametrize( + "team_id, queue_id, next_queue_id", + ( + (TeamIdEnum.MOD_CAPPROT, QueuesEnum.MOD_CAPPROT, QueuesEnum.MOD_ECJU_REVIEW_AND_COMBINE), + (TeamIdEnum.MOD_DI, QueuesEnum.MOD_DI_DIRECT, QueuesEnum.LU_POST_CIRC), + (TeamIdEnum.MOD_DI, QueuesEnum.MOD_DI_INDIRECT, QueuesEnum.MOD_ECJU_REVIEW_AND_COMBINE), + (TeamIdEnum.MOD_DSR, QueuesEnum.MOD_DSR, QueuesEnum.MOD_ECJU_REVIEW_AND_COMBINE), + (TeamIdEnum.MOD_DSTL, QueuesEnum.MOD_DSTL, QueuesEnum.MOD_ECJU_REVIEW_AND_COMBINE), + (TeamIdEnum.NCSC, QueuesEnum.NCSC, QueuesEnum.LU_POST_CIRC), + ), +) def test_user_bulk_approves_cases( - api_client, mod_officer_headers, mod_bulk_approval_url, get_cases_with_ogd_queue_assigned + api_client, + team_case_advisor_headers, + get_cases_with_ogd_queue_assigned, + team_id, + queue_id, + next_queue_id, ): - cases = get_cases_with_ogd_queue_assigned(QueuesEnum.MOD_CAPPROT, count=25) + cases = get_cases_with_ogd_queue_assigned(queue_id, count=25) data = { "cases": [str(case.id) for case in cases], "advice": { @@ -95,36 +134,49 @@ def test_user_bulk_approves_cases( "note": "", "footnote_required": False, "footnote": "", - "team": TeamIdEnum.MOD_CAPPROT, + "team": team_id, }, } - response = api_client.post(mod_bulk_approval_url, data=data, **mod_officer_headers) + url = reverse("caseworker_queues:bulk_approval", kwargs={"pk": queue_id}) + headers = team_case_advisor_headers(team_id) + response = api_client.post(url, data=data, **headers) assert response.status_code == 201 for case in cases: + case.refresh_from_db() assert case.advice.filter( level=AdviceLevel.USER, type=AdviceType.APPROVE, - team_id=TeamIdEnum.MOD_CAPPROT, + team_id=team_id, ).count() == len(case_subjects(case)) - assert QueuesEnum.MOD_CAPPROT not in [str(queue.id) for queue in case.queues.all()] - assert QueuesEnum.MOD_ECJU_REVIEW_AND_COMBINE in [str(queue.id) for queue in case.queues.all()] + assert queue_id not in [str(queue.id) for queue in case.queues.all()] + assert next_queue_id in [str(queue.id) for queue in case.queues.all()] audit_event = Audit.objects.get(target_object_id=case.id, verb=AuditType.CREATE_BULK_APPROVAL_RECOMMENDATION) assert audit_event.payload == { "case_references": [case.reference_code for case in cases], "decision": AdviceType.APPROVE, "level": AdviceLevel.USER, - "queue": Queue.objects.get(id=QueuesEnum.MOD_CAPPROT).name, - "team_id": TeamIdEnum.MOD_CAPPROT, + "queue": Queue.objects.get(id=queue_id).name, + "team_id": team_id, "count": len(cases), } +@pytest.mark.parametrize( + "team_id, queue_id", + ( + (TeamIdEnum.FCDO, QueuesEnum.FCDO), + (TeamIdEnum.DESNZ_CHEMICAL, QueuesEnum.DESNZ_CHEMICAL), + (TeamIdEnum.DESNZ_NUCLEAR, QueuesEnum.DESNZ_NUCLEAR), + (TeamIdEnum.DESNZ_RUSSIA_SANCTIONS, QueuesEnum.DESNZ_RUSSIA_SANCTIONS), + (TeamIdEnum.MOD_ECJU, QueuesEnum.MOD_ECJU_REVIEW_AND_COMBINE), + ), +) def test_user_bulk_approves_fails_for_unsupported_users( - api_client, fcdo_officer_headers, fcdo_bulk_approval_url, get_cases_with_ogd_queue_assigned + api_client, team_case_advisor_headers, get_cases_with_ogd_queue_assigned, team_id, queue_id ): - cases = get_cases_with_ogd_queue_assigned(QueuesEnum.FCDO) + cases = get_cases_with_ogd_queue_assigned(queue_id) data = { "case_ids": [str(case.id) for case in cases], "advice": { @@ -133,10 +185,12 @@ def test_user_bulk_approves_fails_for_unsupported_users( "note": "", "footnote_required": False, "footnote": "", - "team": TeamIdEnum.FCDO, + "team": team_id, }, } - response = api_client.post(fcdo_bulk_approval_url, data=data, **fcdo_officer_headers) + url = reverse("caseworker_queues:bulk_approval", kwargs={"pk": queue_id}) + headers = team_case_advisor_headers(team_id) + response = api_client.post(url, data=data, **headers) assert response.status_code == 403 for case in cases: @@ -144,12 +198,12 @@ def test_user_bulk_approves_fails_for_unsupported_users( case.advice.filter( level=AdviceLevel.USER, type=AdviceType.APPROVE, - team_id=TeamIdEnum.FCDO, + team_id=team_id, ).count() == 0 ) - assert QueuesEnum.FCDO in [str(queue.id) for queue in case.queues.all()] + assert queue_id in [str(queue.id) for queue in case.queues.all()] assert ( Audit.objects.filter(target_object_id=case.id, verb=AuditType.CREATE_BULK_APPROVAL_RECOMMENDATION).exists() is False From 3036b2e5f9dd1be246e240bd5d4483b1365722dd Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Mon, 13 Jan 2025 10:40:37 +0000 Subject: [PATCH 26/32] Fix role already exists error --- api/queues/caseworker/tests/conftest.py | 5 +- api/queues/tests/conftest.py | 196 ------------------------ 2 files changed, 4 insertions(+), 197 deletions(-) delete mode 100644 api/queues/tests/conftest.py diff --git a/api/queues/caseworker/tests/conftest.py b/api/queues/caseworker/tests/conftest.py index 21160b4c14..10626b4c3a 100644 --- a/api/queues/caseworker/tests/conftest.py +++ b/api/queues/caseworker/tests/conftest.py @@ -9,7 +9,7 @@ from api.parties.tests.factories import PartyDocumentFactory from api.teams.models import Team from api.users.libraries.user_to_token import user_to_token -from api.users.models import BaseUser, Permission +from api.users.models import BaseUser, Permission, Role from api.users.enums import SystemUser, UserType from api.users.tests.factories import ( BaseUserFactory, @@ -77,6 +77,9 @@ def gov_headers(gov_user): @pytest.fixture() def gov_user(): gov_user = GovUserFactory() + if Role.objects.filter(id=Roles.INTERNAL_DEFAULT_ROLE_ID, type=UserType.INTERNAL.value).exists(): + return gov_user + gov_user.role = RoleFactory( id=Roles.INTERNAL_DEFAULT_ROLE_ID, type=UserType.INTERNAL.value, name=Roles.INTERNAL_DEFAULT_ROLE_NAME ) diff --git a/api/queues/tests/conftest.py b/api/queues/tests/conftest.py deleted file mode 100644 index 65be58dc0e..0000000000 --- a/api/queues/tests/conftest.py +++ /dev/null @@ -1,196 +0,0 @@ -import pytest - -from django.urls import reverse -from rest_framework.test import APIClient - -from api.applications.tests.factories import DraftStandardApplicationFactory -from api.core.constants import ExporterPermissions, GovPermissions, Roles -from api.organisations.tests.factories import OrganisationFactory -from api.parties.tests.factories import PartyDocumentFactory -from api.teams.models import Team -from api.users.libraries.user_to_token import user_to_token -from api.users.models import BaseUser, Permission -from api.users.enums import SystemUser, UserType -from api.users.tests.factories import ( - BaseUserFactory, - ExporterUserFactory, - GovUserFactory, - RoleFactory, - UserOrganisationRelationshipFactory, -) - -from lite_routing.routing_rules_internal.enums import TeamIdEnum - -pytestmark = pytest.mark.django_db - - -@pytest.fixture(autouse=True) -def setup(gov_user): - pass - - -@pytest.fixture() -def exporter_user(): - return ExporterUserFactory() - - -@pytest.fixture() -def exporter_user_permissions(): - for permission in ExporterPermissions: - Permission.objects.get_or_create(id=permission.name, name=permission.value, type=UserType.EXPORTER.value) - - -@pytest.fixture() -def organisation(exporter_user_permissions, exporter_user): - organisation = OrganisationFactory() - - UserOrganisationRelationshipFactory( - organisation=organisation, - role__permissions=[ExporterPermissions.SUBMIT_LICENCE_APPLICATION.name], - user=exporter_user, - ) - - return organisation - - -@pytest.fixture() -def exporter_headers(exporter_user, organisation): - return { - "HTTP_EXPORTER_USER_TOKEN": user_to_token(exporter_user.baseuser_ptr), - "HTTP_ORGANISATION_ID": str(organisation.id), - } - - -@pytest.fixture(autouse=True) -def system_user(): - if BaseUser.objects.filter(id=SystemUser.id).exists(): - return BaseUser.objects.get(id=SystemUser.id) - else: - return BaseUserFactory(id=SystemUser.id) - - -@pytest.fixture() -def gov_headers(gov_user): - return {"HTTP_GOV_USER_TOKEN": user_to_token(gov_user.baseuser_ptr)} - - -@pytest.fixture() -def gov_user(): - return GovUserFactory() - - -@pytest.fixture() -def lu_case_officer(gov_user_permissions): - gov_user = GovUserFactory() - gov_user.role = RoleFactory(name="Case officer", type=UserType.INTERNAL) - gov_user.role.permissions.set( - [ - GovPermissions.MANAGE_LICENCE_FINAL_ADVICE.name, - GovPermissions.MANAGE_LICENCE_DURATION.name, - GovPermissions.REOPEN_CLOSED_CASES.name, - ] - ) - gov_user.save() - return gov_user - - -@pytest.fixture() -def lu_case_officer_headers(lu_case_officer): - return {"HTTP_GOV_USER_TOKEN": user_to_token(lu_case_officer.baseuser_ptr)} - - -@pytest.fixture() -def fcdo_officer(): - gov_user = GovUserFactory() - gov_user.team = Team.objects.get(name="FCDO") - gov_user.save() - return gov_user - - -@pytest.fixture() -def fcdo_officer_headers(fcdo_officer): - return {"HTTP_GOV_USER_TOKEN": user_to_token(fcdo_officer.baseuser_ptr)} - - -@pytest.fixture() -def fcdo_countersigner(gov_user_permissions): - gov_user = GovUserFactory() - gov_user.team = Team.objects.get(name="FCDO") - gov_user.role = RoleFactory(name="FCDO Countersigner", type=UserType.INTERNAL) - gov_user.role.permissions.set( - [ - GovPermissions.MANAGE_TEAM_ADVICE.name, - ] - ) - gov_user.save() - return gov_user - - -@pytest.fixture() -def fcdo_countersigner_headers(fcdo_countersigner): - return {"HTTP_GOV_USER_TOKEN": user_to_token(fcdo_countersigner.baseuser_ptr)} - - -@pytest.fixture() -def mod_officer(): - gov_user = GovUserFactory() - gov_user.team = Team.objects.get(id=TeamIdEnum.MOD_CAPPROT) - gov_user.save() - return gov_user - - -@pytest.fixture() -def mod_officer_headers(mod_officer): - return {"HTTP_GOV_USER_TOKEN": user_to_token(mod_officer.baseuser_ptr)} - - -@pytest.fixture() -def gov_user_permissions(): - for permission in GovPermissions: - Permission.objects.get_or_create(id=permission.name, name=permission.value, type=UserType.INTERNAL.value) - - -@pytest.fixture() -def api_client(): - return APIClient() - - -@pytest.fixture -def draft_standard_application(organisation): - application = DraftStandardApplicationFactory(organisation=organisation) - PartyDocumentFactory( - party=application.end_user.party, - s3_key="end-user-undertaking", - safe=True, - ) - return application - - -@pytest.fixture -def submit_application(api_client, exporter_headers, mocker): - def _submit_application(draft_application): - mocker.patch("api.documents.libraries.s3_operations.upload_bytes_file", return_value=None) - response = api_client.put( - reverse( - "applications:application_submit", - kwargs={ - "pk": draft_application.pk, - }, - ), - data={ - "submit_declaration": True, - "agreed_to_declaration_text": "i agree", - }, - **exporter_headers, - ) - assert response.status_code == 200, response.json()["errors"] - - draft_application.refresh_from_db() - return draft_application - - return _submit_application - - -@pytest.fixture -def standard_case(draft_standard_application, submit_application): - return submit_application(draft_standard_application) From f5db0c55fc2d7046d140b226d66e35ca684b608f Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Mon, 13 Jan 2025 11:02:42 +0000 Subject: [PATCH 27/32] Remove unused fixtures --- api/audit_trail/formatters.py | 2 +- api/core/tests/test_permissions.py | 5 +---- api/queues/caseworker/tests/test_bulk_approval.py | 10 ---------- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/api/audit_trail/formatters.py b/api/audit_trail/formatters.py index 6c726e6c4c..5c5f91aebb 100644 --- a/api/audit_trail/formatters.py +++ b/api/audit_trail/formatters.py @@ -354,4 +354,4 @@ def amendment_created(**payload): def create_bulk_approval_recommendation(**payload): - return f"added a recommendation using the Approve button in the queue." + return "added a recommendation using the Approve button in the queue." diff --git a/api/core/tests/test_permissions.py b/api/core/tests/test_permissions.py index 15f2451bc2..ec2f2b5e92 100644 --- a/api/core/tests/test_permissions.py +++ b/api/core/tests/test_permissions.py @@ -1,16 +1,13 @@ from unittest import mock from parameterized import parameterized -from django.urls import reverse -from rest_framework.request import Request -from rest_framework.test import APIRequestFactory from api.core.permissions import BULK_APPROVE_ALLOWED_QUEUES, CanCaseworkerBulkApprove, CaseInCaseworkerOperableStatus from api.applications.tests.factories import StandardApplicationFactory from api.queues.caseworker.views.bulk_approval import BulkApprovalCreateView from api.staticdata.statuses.enums import CaseStatusEnum from api.staticdata.statuses.models import CaseStatus -from lite_routing.routing_rules_internal.enums import QueuesEnum, TeamIdEnum +from lite_routing.routing_rules_internal.enums import QueuesEnum from test_helpers.clients import DataTestClient diff --git a/api/queues/caseworker/tests/test_bulk_approval.py b/api/queues/caseworker/tests/test_bulk_approval.py index 40056a3c28..e4d43fc499 100644 --- a/api/queues/caseworker/tests/test_bulk_approval.py +++ b/api/queues/caseworker/tests/test_bulk_approval.py @@ -23,16 +23,6 @@ pytestmark = pytest.mark.django_db -@pytest.fixture -def fcdo_bulk_approval_url(): - return reverse("caseworker_queues:bulk_approval", kwargs={"pk": QueuesEnum.FCDO}) - - -@pytest.fixture -def mod_bulk_approval_url(): - return reverse("caseworker_queues:bulk_approval", kwargs={"pk": QueuesEnum.MOD_CAPPROT}) - - @pytest.fixture() def team_case_advisor(): def _team_case_advisor(team_id): From 6e231350f4b0ef9f52daef0c5ee082522c2d4729 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Mon, 13 Jan 2025 12:04:26 +0000 Subject: [PATCH 28/32] Fix order of results in the assert --- api/licences/tests/test_managers.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/api/licences/tests/test_managers.py b/api/licences/tests/test_managers.py index a84a5b38f5..4cdc51a1c0 100644 --- a/api/licences/tests/test_managers.py +++ b/api/licences/tests/test_managers.py @@ -9,10 +9,12 @@ class LicenceTests(DataTestClient): def test_manager_filter_non_draft_licences(self): application = StandardApplicationFactory() case = application.case_ptr - draft_licence = StandardLicenceFactory(status=LicenceStatus.DRAFT, case=case) + StandardLicenceFactory(status=LicenceStatus.DRAFT, case=case) issued_licence = StandardLicenceFactory(status=LicenceStatus.ISSUED, case=case) cancelled_licence = StandardLicenceFactory(status=LicenceStatus.CANCELLED, case=case) - assert list(Licence.objects.filter_non_draft_licences(application=application)) == [ - issued_licence, - cancelled_licence, - ] + assert sorted(list(Licence.objects.filter_non_draft_licences(application=application))) == sorted( + [ + issued_licence, + cancelled_licence, + ] + ) From 47624d4399c93ca1531c720c36b02ae93f04936b Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Mon, 13 Jan 2025 12:40:23 +0000 Subject: [PATCH 29/32] Fix test by sorting the results based on reference code --- api/licences/tests/test_managers.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/api/licences/tests/test_managers.py b/api/licences/tests/test_managers.py index 4cdc51a1c0..6d4b807164 100644 --- a/api/licences/tests/test_managers.py +++ b/api/licences/tests/test_managers.py @@ -12,9 +12,7 @@ def test_manager_filter_non_draft_licences(self): StandardLicenceFactory(status=LicenceStatus.DRAFT, case=case) issued_licence = StandardLicenceFactory(status=LicenceStatus.ISSUED, case=case) cancelled_licence = StandardLicenceFactory(status=LicenceStatus.CANCELLED, case=case) - assert sorted(list(Licence.objects.filter_non_draft_licences(application=application))) == sorted( - [ - issued_licence, - cancelled_licence, - ] - ) + assert list(Licence.objects.filter_non_draft_licences(application=application).order_by("reference_code")) == [ + issued_licence, + cancelled_licence, + ] From d1be85961b8f9196144e6d66d7eabde820039def Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Mon, 13 Jan 2025 13:16:39 +0000 Subject: [PATCH 30/32] Fix coverage issue --- api/queues/caseworker/tests/test_bulk_approval.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/queues/caseworker/tests/test_bulk_approval.py b/api/queues/caseworker/tests/test_bulk_approval.py index e4d43fc499..3355dda7f3 100644 --- a/api/queues/caseworker/tests/test_bulk_approval.py +++ b/api/queues/caseworker/tests/test_bulk_approval.py @@ -6,6 +6,7 @@ from api.applications.tests.factories import DraftStandardApplicationFactory from api.audit_trail.enums import AuditType from api.audit_trail.models import Audit +from api.audit_trail.serializers import AuditSerializer from api.cases.enums import AdviceLevel, AdviceType from api.cases.models import CaseAssignment from api.cases.tests.factories import CaseAssignmentFactory @@ -151,6 +152,8 @@ def test_user_bulk_approves_cases( "team_id": team_id, "count": len(cases), } + audit_text = AuditSerializer(audit_event).data["text"] + assert audit_text == "added a recommendation using the Approve button in the queue." @pytest.mark.parametrize( From cb98464611cd702d90173c70068c951086e39700 Mon Sep 17 00:00:00 2001 From: Gurdeep Atwal Date: Fri, 10 Jan 2025 11:17:12 +0000 Subject: [PATCH 31/32] fix search --- api/cases/managers.py | 5 ++-- api/cases/tests/test_case_search_advanced.py | 25 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/api/cases/managers.py b/api/cases/managers.py index fb87617898..5de2112c00 100644 --- a/api/cases/managers.py +++ b/api/cases/managers.py @@ -169,9 +169,9 @@ def with_submitted_range(self, submitted_from, submitted_to): def with_finalised_range(self, finalised_from, finalised_to): qs = self.filter(status__status=CaseStatusEnum.FINALISED) if finalised_from: - qs = qs.filter(advice__level=AdviceLevel.FINAL, advice__created_at__date__gte=finalised_from) + qs = qs.filter(licence_decisions__created_at__date__gte=finalised_from) if finalised_to: - qs = qs.filter(advice__level=AdviceLevel.FINAL, advice__created_at__date__lte=finalised_to) + qs = qs.filter(licence_decisions__created_at__date__lte=finalised_to) return qs def with_party_name(self, party_name): @@ -320,6 +320,7 @@ def search( # noqa "case_assignments__user__baseuser_ptr", "case_assignments__user__team", "case_assignments__queue", + "licence_decisions", "queues", "queues__team", "baseapplication__licences", diff --git a/api/cases/tests/test_case_search_advanced.py b/api/cases/tests/test_case_search_advanced.py index 2b007a9b02..50d5aac69e 100644 --- a/api/cases/tests/test_case_search_advanced.py +++ b/api/cases/tests/test_case_search_advanced.py @@ -12,7 +12,7 @@ ) from api.cases.enums import AdviceType from api.cases.models import Case -from api.cases.tests.factories import TeamAdviceFactory, FinalAdviceFactory +from api.cases.tests.factories import LicenceDecisionFactory, TeamAdviceFactory, FinalAdviceFactory from api.flags.tests.factories import FlagFactory from api.goods.tests.factories import GoodFactory from api.parties.tests.factories import PartyFactory @@ -289,18 +289,17 @@ def test_filter_by_finalised_date_range(self): application_1 = StandardApplicationFactory() application_1.status = get_case_status_by_status(CaseStatusEnum.FINALISED) application_1.save() - good = GoodFactory(organisation=application_1.organisation) - FinalAdviceFactory( - user=self.gov_user, team=self.team, case=application_1, good=good, type=AdviceType.APPROVE, created_at=day_2 - ) + LicenceDecisionFactory(case=application_1, created_at=day_2) application_2 = StandardApplicationFactory() application_2.status = get_case_status_by_status(CaseStatusEnum.FINALISED) application_2.save() - good = GoodFactory(organisation=application_2.organisation) - FinalAdviceFactory( - user=self.gov_user, team=self.team, case=application_2, good=good, type=AdviceType.APPROVE, created_at=day_4 - ) + LicenceDecisionFactory(case=application_2, created_at=day_4) + + application_3 = StandardApplicationFactory() + application_3.status = get_case_status_by_status(CaseStatusEnum.FINALISED) + application_3.save() + LicenceDecisionFactory(case=application_3, created_at=day_5) qs_1 = Case.objects.search(finalised_from=day_1, finalised_to=day_3) qs_2 = Case.objects.search(finalised_from=day_3, finalised_to=day_5) @@ -310,11 +309,11 @@ def test_filter_by_finalised_date_range(self): qs_6 = Case.objects.search(finalised_from=day_5) self.assertEqual(qs_1.count(), 1) - self.assertEqual(qs_2.count(), 1) - self.assertEqual(qs_3.count(), 2) - self.assertEqual(qs_4.count(), 2) + self.assertEqual(qs_2.count(), 2) + self.assertEqual(qs_3.count(), 3) + self.assertEqual(qs_4.count(), 3) self.assertEqual(qs_5.count(), 0) - self.assertEqual(qs_6.count(), 0) + self.assertEqual(qs_6.count(), 1) self.assertEqual(qs_1.first().pk, application_1.pk) self.assertEqual(qs_2.first().pk, application_2.pk) From 57d19cd557799d5b936b78f97e4238dc69680b42 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Thu, 16 Jan 2025 14:56:21 +0000 Subject: [PATCH 32/32] Allow good creation to be skipped when creating a test application --- test_helpers/clients.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test_helpers/clients.py b/test_helpers/clients.py index 31b0a6859d..17e39d4df5 100644 --- a/test_helpers/clients.py +++ b/test_helpers/clients.py @@ -732,6 +732,7 @@ def create_standard_application_case( user=None, num_products=1, reuse_good=False, + add_a_good=True, ): """ Creates a complete standard application case @@ -745,6 +746,7 @@ def create_standard_application_case( user=user, num_products=num_products, reuse_good=reuse_good, + add_a_good=add_a_good, ) return self.submit_application(draft, self.exporter_user)