diff --git a/caseworker/advice/constants.py b/caseworker/advice/constants.py index dc13a41352..6db1bae099 100644 --- a/caseworker/advice/constants.py +++ b/caseworker/advice/constants.py @@ -1,3 +1,45 @@ +# Teams +DESNZ_CHEMICAL = "56273dd4-4634-4ad7-a782-e480f85a85a9" # /PS-IGNORE +DESNZ_NUCLEAR = "88164d59-8724-4596-811b-40b60b5cf892" # /PS-IGNORE +FCDO_TEAM = "67b9a4a3-6f3d-4511-8a19-23ccff221a74" # /PS-IGNORE +LICENSING_UNIT_TEAM = "58e77e47-42c8-499f-a58d-94f94541f8c6" # /PS-IGNORE +MOD_CAPPROT_TEAM = "a06aec31-47d7-443b-860d-66ab0c6d7cfd" # /PS-IGNORE +NCSC_TEAM = "2b5492ad-0ef5-4a2c-bba2-b208fbe80956" # /PS-IGNORE +ENFORCEMENT_UNIT = "48b4fe54-7d4f-4c03-b143-f8d1dcef9f5b" # /PS-IGNORE +MOD_ECJU = "b7640925-2577-4c24-8081-b85bd635b62a" # /PS-IGNORE +MOD_DI_TEAM = "2e5fab3c-4599-432e-9540-74ccfafb18ee" # /PS-IGNORE +MOD_DSR_TEAM = "4c62ce4a-18f8-4ada-8d18-4b53a565250f" # /PS-IGNORE +MOD_DSTL_TEAM = "809eba0f-f197-4f0f-949b-9af309a844fb" # /PS-IGNORE + +# Queues +DESNZ_NUCLEAR_CASES_TO_REVIEW = "f26cd433-b23c-4bb0-95d3-3def83f7fd19" # /PS-IGNORE +DESNZ_NUCLEAR_COUNTERSIGNING = "91213b45-f69f-492d-9d61-84e3a27cceb3" # /PS-IGNORE +DESNZ_CHEMICAL_CASES_TO_REVIEW = "58e79b78-8817-40d0-afb3-fda57978a502" # /PS-IGNORE +DESNZ_RUSSIA_SANCTIONS = "3ac48607-8102-49d9-bf49-55ef7b3cecef" # /PS-IGNORE +FCDO_CASES_TO_REVIEW_QUEUE = "f458094c-1fed-4222-ac70-ff5fa20ff649" # /PS-IGNORE +FCDO_COUNTERSIGNING_QUEUE = "5e772575-9ae4-4a16-b55b-7e1476d810c4" # /PS-IGNORE +FCDO_CPACC_CASES_TO_REVIEW_QUEUE = "a7ac8131-8eac-4dab-9eb1-aa2e9d0c0d42" # /PS-IGNORE +LU_POST_CIRC_FINALISE_QUEUE = "f0e7c2fa-100f-42ad-b740-bb072393e664" # /PS-IGNORE +LU_LICENSING_MANAGER_QUEUE = "9f5a2a93-03ed-4416-a8f8-8b728e5ea9d0" # /PS-IGNORE +LU_SR_LICENSING_MANAGER_QUEUE = "7b643901-565a-4ec8-8a7a-de34bc541a0e" # /PS-IGNORE +MOD_CAPPROT_CASES_TO_REVIEW = "93d1bc19-979d-4ba3-a57c-b0ce253c6237" # /PS-IGNORE +MOD_DI_DIRECT_CASES_TO_REVIEW = "c93f1e56-c577-4910-a01c-434e47ac6c9c" # /PS-IGNORE +MOD_DI_INDIRECT_CASES_TO_REVIEW = "0dd6c6f0-8f8b-4c03-b68f-0d8b04225369" # /PS-IGNORE +MOD_DSR_CASES_TO_REVIEW = "a84d6556-782e-4002-abe2-8bc1e5c2b162" # /PS-IGNORE +MOD_DSTL_CASES_TO_REVIEW = "1a5f47ee-ef5e-456b-914c-4fa629b4559c" # /PS-IGNORE +MOD_ECJU_REVIEW_AND_COMBINE = "432a8587-fc0e-4d34-9b50-92ad6d45bb16" # /PS-IGNORE +NCSC_CASES_TO_REVIEW = "bbfc426b-a1af-4a4c-a97b-ae1557de4210" # /PS-IGNORE + + +BULK_APPROVE_ALLOWED_QUEUES = ( + MOD_CAPPROT_CASES_TO_REVIEW, + MOD_DI_DIRECT_CASES_TO_REVIEW, + MOD_DI_INDIRECT_CASES_TO_REVIEW, + MOD_DSR_CASES_TO_REVIEW, + MOD_DSTL_CASES_TO_REVIEW, + NCSC_CASES_TO_REVIEW, +) + DECISION_TYPE_VERB_MAPPING = { "Approve": "approved", "Proviso": "approved", diff --git a/caseworker/advice/services.py b/caseworker/advice/services.py index 7a661c7eeb..e89479fea0 100644 --- a/caseworker/advice/services.py +++ b/caseworker/advice/services.py @@ -569,6 +569,11 @@ def unassessed_trigger_list_goods(case): ] +def post_bulk_approval_recommendation(request, queue_id, data): + response = client.post(request, f"/caseworker/queues/{queue_id}/bulk-approval/", data) + return response.json(), response.status_code + + def get_advice_tab_context(case, caseworker, queue_id): """Get contextual information for the advice tab such as the tab's URL and button visibility, based off the case, the current user and current user's queue. diff --git a/caseworker/advice/views/bulk_approval.py b/caseworker/advice/views/bulk_approval.py new file mode 100644 index 0000000000..615dae1d72 --- /dev/null +++ b/caseworker/advice/views/bulk_approval.py @@ -0,0 +1,73 @@ +import rules + +from http import HTTPStatus + +from django.contrib import messages +from django.contrib.messages.views import SuccessMessageMixin +from django.http import Http404, HttpResponseRedirect +from django.urls import reverse +from django.views import View + + +from caseworker.advice.services import post_bulk_approval_recommendation +from caseworker.users.services import get_gov_user + +from core.auth.views import LoginRequiredMixin +from core.decorators import expect_status + + +class BulkApprovalView(LoginRequiredMixin, SuccessMessageMixin, View): + """ + Submit approval recommendation for the selected cases + """ + + def dispatch(self, *args, **kwargs): + + if not rules.test_rule("can_user_bulk_approve_cases", self.request, self.kwargs["pk"]): + raise Http404() + return super().dispatch(*args, **kwargs) + + @property + def caseworker_id(self): + return str(self.request.session["lite_api_user_id"]) + + @property + def caseworker(self): + data, _ = get_gov_user(self.request, self.caseworker_id) + return data["user"] + + def get_success_url(self): + return reverse("queues:cases", kwargs={"queue_pk": self.kwargs["pk"]}) + + @expect_status( + HTTPStatus.CREATED, + "Error submitting bulk approval recommendation", + "Unexpected error submitting bulk approval recommendation", + ) + def submit_bulk_approval_recommendation(self, queue_id, payload): + return post_bulk_approval_recommendation(self.request, queue_id, payload) + + def post(self, request, *args, **kwargs): + queue_id = self.kwargs["pk"] + cases = self.request.POST.getlist("cases", []) + payload = { + "cases": cases, + "advice": { + "text": "No concerns: Approved using bulk approval", + "proviso": "", + "note": "", + "footnote_required": False, + "footnote": "", + "team": str(self.caseworker["team"]["id"]), + }, + } + + self.submit_bulk_approval_recommendation(queue_id, payload) + + num_cases = len(cases) + success_message = f"Successfully approved {num_cases} cases" + if num_cases == 1: + success_message = "Successfully approved 1 case" + messages.success(self.request, success_message) + + return HttpResponseRedirect(self.get_success_url()) diff --git a/caseworker/queues/rules.py b/caseworker/queues/rules.py new file mode 100644 index 0000000000..0de3d25342 --- /dev/null +++ b/caseworker/queues/rules.py @@ -0,0 +1,11 @@ +import rules + +from caseworker.advice.constants import BULK_APPROVE_ALLOWED_QUEUES + + +@rules.predicate +def can_user_bulk_approve_cases(request, queue_id): + return str(queue_id) in BULK_APPROVE_ALLOWED_QUEUES + + +rules.add_rule("can_user_bulk_approve_cases", can_user_bulk_approve_cases) diff --git a/caseworker/queues/urls.py b/caseworker/queues/urls.py index f0463b9a51..eb95d7e1d1 100644 --- a/caseworker/queues/urls.py +++ b/caseworker/queues/urls.py @@ -1,5 +1,6 @@ from django.urls import path +from caseworker.advice.views.bulk_approval import BulkApprovalView from caseworker.queues.views import case_assignments, cases, enforcement, queues app_name = "queues" @@ -36,4 +37,5 @@ path( "/enforcement-xml-import/", enforcement.EnforcementXMLImport.as_view(), name="enforcement_xml_import" ), + path("/bulk-approve/", BulkApprovalView.as_view(), name="bulk_approval"), ] diff --git a/caseworker/templates/queues/cases.html b/caseworker/templates/queues/cases.html index 3ecfbb8f54..cd235b3696 100644 --- a/caseworker/templates/queues/cases.html +++ b/caseworker/templates/queues/cases.html @@ -1,7 +1,7 @@ {% extends 'layouts/base.html' %} {% load svg static %} -{% load crispy_forms_tags %} +{% load crispy_forms_tags rules %} {% block back_link %}{% endblock %} @@ -15,7 +15,21 @@ {% if not queue.is_system_queue %} + {% test_rule 'can_user_bulk_approve_cases' request queue.id as can_user_bulk_approve_cases %}
+ {% if can_user_bulk_approve_cases %} +
+ +
+ {% endif %}
@@ -66,6 +80,7 @@
+ {% csrf_token %} {% if not data.results.cases %} {% if tab_data.my_cases.is_selected %} {% include "includes/notice.html" with text='cases.CasesListPage.NO_CASES_ALLOCATED' %} diff --git a/conf/caseworker.py b/conf/caseworker.py index 5816548d62..d4200d4323 100644 --- a/conf/caseworker.py +++ b/conf/caseworker.py @@ -22,6 +22,7 @@ "caseworker.teams", "caseworker.cases", "caseworker.activities", + "caseworker.queues", ] MIDDLEWARE += [ diff --git a/unit_tests/caseworker/advice/views/test_bulk_approval.py b/unit_tests/caseworker/advice/views/test_bulk_approval.py new file mode 100644 index 0000000000..4a94c9407f --- /dev/null +++ b/unit_tests/caseworker/advice/views/test_bulk_approval.py @@ -0,0 +1,212 @@ +import pytest +import re + +from bs4 import BeautifulSoup +from pytest_django.asserts import assertTemplateUsed +from urllib import parse +from uuid import uuid4 + +from django.contrib.messages import get_messages +from django.urls import reverse + +from caseworker.advice.constants import ( + DESNZ_CHEMICAL, + DESNZ_NUCLEAR, + FCDO_TEAM, + MOD_CAPPROT_TEAM, + MOD_DI_TEAM, + MOD_DSR_TEAM, + MOD_DSTL_TEAM, + MOD_ECJU, + NCSC_TEAM, + DESNZ_CHEMICAL_CASES_TO_REVIEW, + DESNZ_NUCLEAR_CASES_TO_REVIEW, + FCDO_CASES_TO_REVIEW_QUEUE, + MOD_CAPPROT_CASES_TO_REVIEW, + MOD_DI_DIRECT_CASES_TO_REVIEW, + MOD_DI_INDIRECT_CASES_TO_REVIEW, + MOD_DSR_CASES_TO_REVIEW, + MOD_DSTL_CASES_TO_REVIEW, + MOD_ECJU_REVIEW_AND_COMBINE, + NCSC_CASES_TO_REVIEW, +) +from core import client + + +@pytest.fixture +def mock_get_queue_cases(requests_mock): + def _mock_get_queue_cases(queue_id): + url = client._build_absolute_uri(f"/queues/{queue_id}/") + return requests_mock.get(url=url, json={}) + + return _mock_get_queue_cases + + +@pytest.fixture +def mock_get_queue_detail(requests_mock): + def _mock_get_queue_detail(queue_id): + url = client._build_absolute_uri(f"/queues/{queue_id}/") + return requests_mock.get(url=url, json={"id": queue_id}) + + return _mock_get_queue_detail + + +@pytest.fixture +def mock_get_queue_search_data(requests_mock): + def _mock_get_queue_search_data(queue_id): + query_params = {"queue_id": queue_id, "page": 1, "selected_tab": "all_cases", "hidden": False} + url = client._build_absolute_uri(f"/cases/") + url = f"{url}?{parse.urlencode(query_params, doseq=True)}" + return requests_mock.get(url=url, json={"results": {"cases": [], "filters": {"gov_users": []}}}) + + return _mock_get_queue_search_data + + +@pytest.fixture +def OGD_team_user(gov_uk_user_id): + def _OGD_team_user(team_id): + return { + "email": "ogd.team@example.com", + "first_name": "OGD Team", + "id": gov_uk_user_id, + "last_name": "User", + "role_name": "Super User", + "status": "Active", + "team": { + "id": team_id, + "name": "OGD Team", + "alias": "OGD_TEAM", + "part_of_ecju": False, + "is_ogd": True, + }, + } + + return _OGD_team_user + + +@pytest.mark.parametrize( + "team_id, queue_id, case_count", + ( + (MOD_CAPPROT_TEAM, MOD_CAPPROT_CASES_TO_REVIEW, 10), + (MOD_DI_TEAM, MOD_DI_DIRECT_CASES_TO_REVIEW, 5), + (MOD_DI_TEAM, MOD_DI_INDIRECT_CASES_TO_REVIEW, 5), + (MOD_DSR_TEAM, MOD_DSR_CASES_TO_REVIEW, 5), + (MOD_DSTL_TEAM, MOD_DSTL_CASES_TO_REVIEW, 5), + (NCSC_TEAM, NCSC_CASES_TO_REVIEW, 5), + (NCSC_TEAM, NCSC_CASES_TO_REVIEW, 1), + ), +) +def test_user_bulk_approval_success( + authorized_client, + requests_mock, + mock_gov_user, + OGD_team_user, + team_id, + queue_id, + case_count, +): + # setup mock requests + # There are multiple fixtures that require parameterizing hence these cannot + # be parameterized outside of this test + url = client._build_absolute_uri(f"/caseworker/queues/{queue_id}/bulk-approval/") + requests_mock.post(url=url, json={}, status_code=201) + + ogd_advisor = OGD_team_user(team_id) + mock_gov_user["user"]["team"] = ogd_advisor["team"] + + url = client._build_absolute_uri("/gov-users/") + requests_mock.get(url=f"{url}me/", json=mock_gov_user) + requests_mock.get(url=re.compile(f"{url}{mock_gov_user['user']['id']}/"), json=mock_gov_user) + + data = { + "cases": [str(uuid4()) for _ in range(case_count)], + "advice": { + "text": "No concerns: Approved using bulk approval", + "proviso": "", + "note": "", + "footnote_required": False, + "footnote": "", + "team": team_id, + }, + } + url = reverse("queues:bulk_approval", kwargs={"pk": queue_id}) + response = authorized_client.post(url, data=data) + assert response.status_code == 302 + + bulk_approval_request = requests_mock.request_history.pop() + assert bulk_approval_request.method == "POST" + assert bulk_approval_request.json() == data + + messages = list(get_messages(response.wsgi_request)) + expected = f"Successfully approved {case_count} cases" + if case_count == 1: + expected = "Successfully approved 1 case" + assert len(messages) == 1 + assert str(messages[0]) == expected + assert messages[0].tags == "success" + + +@pytest.mark.parametrize( + "team_id, queue_id", + ( + # team_id is not required in this test but included for completenes + (FCDO_TEAM, FCDO_CASES_TO_REVIEW_QUEUE), + (DESNZ_CHEMICAL, DESNZ_CHEMICAL_CASES_TO_REVIEW), + (DESNZ_NUCLEAR, DESNZ_NUCLEAR_CASES_TO_REVIEW), + (MOD_ECJU, MOD_ECJU_REVIEW_AND_COMBINE), + ), +) +def test_user_bulk_approval_not_available_error( + authorized_client, + requests_mock, + team_id, + queue_id, +): + url = client._build_absolute_uri(f"/caseworker/queues/{queue_id}/bulk-approval/") + bulk_approval_request = requests_mock.post(url=url, json={}, status_code=201) + + url = reverse("queues:bulk_approval", kwargs={"pk": queue_id}) + response = authorized_client.post(url, data={}) + assert response.status_code == 404 + + assert bulk_approval_request.call_count == 0 + + +@pytest.mark.parametrize( + "queue_id, expected", + ( + (DESNZ_CHEMICAL_CASES_TO_REVIEW, False), + (DESNZ_NUCLEAR_CASES_TO_REVIEW, False), + (FCDO_CASES_TO_REVIEW_QUEUE, False), + (MOD_ECJU_REVIEW_AND_COMBINE, False), + (MOD_CAPPROT_CASES_TO_REVIEW, True), + (MOD_DI_DIRECT_CASES_TO_REVIEW, True), + (MOD_DI_INDIRECT_CASES_TO_REVIEW, True), + (MOD_DSR_CASES_TO_REVIEW, True), + (MOD_DSTL_CASES_TO_REVIEW, True), + (NCSC_CASES_TO_REVIEW, True), + ), +) +def test_bulk_approval_button_status_for_ogd_queue( + authorized_client, + mock_get_queue_cases, + mock_get_queue_search_data, + mock_cases_search_head, + mock_control_list_entries, + mock_regime_entries, + mock_countries, + mock_queues_list, + mock_get_queue_detail, + mock_bookmarks, + queue_id, + expected, +): + mock_get_queue_cases(queue_id) + mock_get_queue_search_data(queue_id) + mock_get_queue_detail(queue_id) + + url = reverse(f"queues:cases", kwargs={"queue_pk": queue_id}) + response = authorized_client.get(url) + assertTemplateUsed(response, "queues/cases.html") + soup = BeautifulSoup(response.content, "html.parser") + assert bool(soup.find("button", {"id": "bulk-approve-button"})) is expected diff --git a/unit_tests/caseworker/conftest.py b/unit_tests/caseworker/conftest.py index 983851a274..2d5051144a 100644 --- a/unit_tests/caseworker/conftest.py +++ b/unit_tests/caseworker/conftest.py @@ -612,6 +612,15 @@ def mock_gov_fcdo_user(requests_mock, mock_notifications, mock_case_statuses, mo requests_mock.get(url=re.compile(f"{url}{gov_uk_user_id}/"), json=mock_gov_user) +@pytest.fixture +def mock_gov_mod_capprot_user(requests_mock, mock_notifications, mock_case_statuses, mock_gov_user, MOD_team1_user): + mock_gov_user["user"]["team"] = MOD_team1_user["team"] + + url = client._build_absolute_uri("/gov-users/") + requests_mock.get(url=f"{url}me/", json=mock_gov_user) + requests_mock.get(url=re.compile(f"{url}{gov_uk_user_id}/"), json=mock_gov_user) + + @pytest.fixture def mock_gov_desnz_nuclear_user(requests_mock, mock_notifications, mock_case_statuses, mock_gov_user): mock_gov_user["user"]["team"] = { diff --git a/unit_tests/caseworker/queues/test_rules.py b/unit_tests/caseworker/queues/test_rules.py new file mode 100644 index 0000000000..74149c6aaf --- /dev/null +++ b/unit_tests/caseworker/queues/test_rules.py @@ -0,0 +1,32 @@ +import pytest + +from caseworker.queues import rules as caseworker_rules +from caseworker.advice.constants import ( + DESNZ_CHEMICAL_CASES_TO_REVIEW, + DESNZ_NUCLEAR_CASES_TO_REVIEW, + FCDO_CASES_TO_REVIEW_QUEUE, + MOD_CAPPROT_CASES_TO_REVIEW, + MOD_DI_DIRECT_CASES_TO_REVIEW, + MOD_DI_INDIRECT_CASES_TO_REVIEW, + MOD_DSR_CASES_TO_REVIEW, + MOD_DSTL_CASES_TO_REVIEW, + NCSC_CASES_TO_REVIEW, +) + + +@pytest.mark.parametrize( + "queue_id, expected_result", + ( + (DESNZ_CHEMICAL_CASES_TO_REVIEW, False), + (DESNZ_NUCLEAR_CASES_TO_REVIEW, False), + (FCDO_CASES_TO_REVIEW_QUEUE, False), + (MOD_CAPPROT_CASES_TO_REVIEW, True), + (MOD_DI_DIRECT_CASES_TO_REVIEW, True), + (MOD_DI_INDIRECT_CASES_TO_REVIEW, True), + (MOD_DSR_CASES_TO_REVIEW, True), + (MOD_DSTL_CASES_TO_REVIEW, True), + (NCSC_CASES_TO_REVIEW, True), + ), +) +def test_can_user_bulk_approve_cases(get_mock_request_user, queue_id, expected_result): + assert caseworker_rules.can_user_bulk_approve_cases(get_mock_request_user(None), queue_id) is expected_result