diff --git a/api/data_workspace/tests/__init__.py b/api/applications/caseworker/__init__.py similarity index 100% rename from api/data_workspace/tests/__init__.py rename to api/applications/caseworker/__init__.py diff --git a/api/applications/caseworker/permissions.py b/api/applications/caseworker/permissions.py new file mode 100644 index 0000000000..aabdb30ac7 --- /dev/null +++ b/api/applications/caseworker/permissions.py @@ -0,0 +1,27 @@ +from rest_framework import permissions + +from api.core.constants import GovPermissions +from api.staticdata.statuses.enums import CaseStatusEnum +from lite_routing.routing_rules_internal.enums import TeamIdEnum + + +# TODO: Review where django-rules can help simplify this +class CaseStatusCaseworkerChangeable(permissions.BasePermission): + def has_object_permission(self, request, view, application): + new_status = request.data.get("status") + original_status = application.status.status + user = request.user.govuser + + if new_status == CaseStatusEnum.FINALISED: + lu_user = str(user.team.id) == TeamIdEnum.LICENSING_UNIT + if lu_user and user.has_permission(GovPermissions.MANAGE_LICENCE_FINAL_ADVICE): + return True + return False + + if new_status == CaseStatusEnum.APPLICANT_EDITING: + return False + + if CaseStatusEnum.is_terminal(original_status) and not user.has_permission(GovPermissions.REOPEN_CLOSED_CASES): + return False + + return True diff --git a/api/applications/caseworker/serializers.py b/api/applications/caseworker/serializers.py new file mode 100644 index 0000000000..842619f587 --- /dev/null +++ b/api/applications/caseworker/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from api.staticdata.statuses.enums import CaseStatusEnum + + +class ApplicationChangeStatusSerializer(serializers.Serializer): + + status = serializers.ChoiceField(choices=CaseStatusEnum.all()) + note = serializers.CharField(required=False, allow_blank=True, allow_null=True, max_length=2000) diff --git a/api/applications/caseworker/tests/__init__.py b/api/applications/caseworker/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/applications/caseworker/tests/test_permissions.py b/api/applications/caseworker/tests/test_permissions.py new file mode 100644 index 0000000000..de8f9725d8 --- /dev/null +++ b/api/applications/caseworker/tests/test_permissions.py @@ -0,0 +1,93 @@ +from unittest import mock +from parameterized import parameterized + +from api.applications.caseworker.permissions import CaseStatusCaseworkerChangeable +from api.applications.tests.factories import StandardApplicationFactory +from api.core.constants import GovPermissions +from api.staticdata.statuses.enums import CaseStatusEnum +from api.staticdata.statuses.models import CaseStatus +from api.teams.models import Team +from api.teams.enums import TeamIdEnum +from api.users.models import Role + + +from test_helpers.clients import DataTestClient + +permitted_statuses = list( + set(CaseStatusEnum.all()) - set(CaseStatusEnum.terminal_statuses()) - set([CaseStatusEnum.APPLICANT_EDITING]) +) + + +class TestChangeStatusCaseworkerChangeable(DataTestClient): + + def setUp(self): + super().setUp() + self.application = StandardApplicationFactory(status=CaseStatus.objects.get(status=CaseStatusEnum.SUBMITTED)) + self.permission_obj = CaseStatusCaseworkerChangeable() + + @parameterized.expand(permitted_statuses) + def test_has_object_permission_permitted(self, case_status): + mock_request = mock.Mock() + mock_request.data = {"status": case_status} + mock_request.user = self.gov_user.baseuser_ptr + assert self.permission_obj.has_object_permission(mock_request, None, self.application) is True + + @parameterized.expand(CaseStatusEnum.terminal_statuses()) + def test_has_object_permission_original_status_terminal_no_user_permission(self, original_status): + self.application.status = CaseStatus.objects.get(status=original_status) + self.application.save() + + role = Role.objects.create(name="test") + role.permissions.set([]) + self.gov_user.role = role + self.gov_user.save() + + mock_request = mock.Mock() + mock_request.user = self.gov_user.baseuser_ptr + mock_request.data = {"status": CaseStatusEnum.OGD_ADVICE} + assert self.permission_obj.has_object_permission(mock_request, None, self.application) is False + + @parameterized.expand(CaseStatusEnum.terminal_statuses()) + def test_has_object_permission_original_status_terminal_user_permitted(self, original_status): + self.application.status = CaseStatus.objects.get(status=original_status) + self.application.save() + + role = Role.objects.create(name="test") + role.permissions.set([GovPermissions.REOPEN_CLOSED_CASES.name]) + self.gov_user.role = role + self.gov_user.save() + + mock_request = mock.Mock() + mock_request.user = self.gov_user.baseuser_ptr + mock_request.data = {"status": CaseStatusEnum.OGD_ADVICE} + assert self.permission_obj.has_object_permission(mock_request, None, self.application) is True + + def test_has_object_permission_new_status_applicant_editing(self): + mock_request = mock.Mock() + mock_request.user = self.gov_user.baseuser_ptr + mock_request.data = {"status": CaseStatusEnum.APPLICANT_EDITING} + assert self.permission_obj.has_object_permission(mock_request, None, self.application) is False + + def test_has_object_permission_new_status_finalised_user_permitted(self): + self.gov_user.team = Team.objects.get(id=TeamIdEnum.LICENSING_UNIT) + role = Role.objects.create(name="test") + role.permissions.set([GovPermissions.MANAGE_LICENCE_FINAL_ADVICE.name]) + self.gov_user.role = role + self.gov_user.save() + + mock_request = mock.Mock() + mock_request.user = self.gov_user.baseuser_ptr + mock_request.data = {"status": CaseStatusEnum.FINALISED} + assert self.permission_obj.has_object_permission(mock_request, None, self.application) is True + + def test_has_object_permission_new_status_finalised_user_not_permitted(self): + self.gov_user.team = Team.objects.get(id=TeamIdEnum.LICENSING_UNIT) + role = Role.objects.create(name="test") + role.permissions.set([]) + self.gov_user.role = role + self.gov_user.save() + + mock_request = mock.Mock() + mock_request.user = self.gov_user.baseuser_ptr + mock_request.data = {"status": CaseStatusEnum.FINALISED} + assert self.permission_obj.has_object_permission(mock_request, None, self.application) is False diff --git a/api/applications/caseworker/urls.py b/api/applications/caseworker/urls.py new file mode 100644 index 0000000000..a69181dc7f --- /dev/null +++ b/api/applications/caseworker/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from api.applications.caseworker.views import applications + +app_name = "caseworker_applications" + +urlpatterns = [ + path("/status/", applications.ApplicationChangeStatus.as_view(), name="change_status"), +] diff --git a/api/applications/caseworker/views/__init__.py b/api/applications/caseworker/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/applications/caseworker/views/applications.py b/api/applications/caseworker/views/applications.py new file mode 100644 index 0000000000..8d9d6ba419 --- /dev/null +++ b/api/applications/caseworker/views/applications.py @@ -0,0 +1,51 @@ +from django.core.exceptions import ObjectDoesNotExist +from django.http import Http404, JsonResponse +from django.db import transaction +from rest_framework.generics import GenericAPIView +from rest_framework import status + +from api.applications.caseworker.permissions import CaseStatusCaseworkerChangeable +from api.applications.caseworker.serializers import ApplicationChangeStatusSerializer +from api.applications.helpers import get_application_view_serializer +from api.applications.libraries.get_applications import get_application +from api.core.exceptions import NotFoundError +from api.core.authentication import GovAuthentication +from api.core.permissions import CaseInCaseworkerOperableStatus +from api.staticdata.statuses.libraries.get_case_status import get_case_status_by_status + + +class ApplicationChangeStatus(GenericAPIView): + authentication_classes = (GovAuthentication,) + permission_classes = [ + CaseInCaseworkerOperableStatus, + CaseStatusCaseworkerChangeable, + ] + serializer_class = ApplicationChangeStatusSerializer + + def setup(self, request, *args, **kwargs): + super().setup(request, *args, **kwargs) + try: + self.application = get_application(self.kwargs["pk"]) + except (ObjectDoesNotExist, NotFoundError): + raise Http404() + + def get_object(self): + self.check_object_permissions(self.request, self.application) + return self.application + + def get_case(self): + return self.application + + @transaction.atomic + def post(self, request, pk): + application = self.get_object() + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.data + application.change_status(request.user, get_case_status_by_status(data["status"]), data["note"]) + + response_data = get_application_view_serializer(application)( + application, context={"user_type": request.user.type} + ).data + + return JsonResponse(data=response_data, status=status.HTTP_200_OK) diff --git a/api/applications/caseworker/views/tests/__init__.py b/api/applications/caseworker/views/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/applications/caseworker/views/tests/test_applications.py b/api/applications/caseworker/views/tests/test_applications.py new file mode 100644 index 0000000000..7acd0698e2 --- /dev/null +++ b/api/applications/caseworker/views/tests/test_applications.py @@ -0,0 +1,86 @@ +from parameterized import parameterized + +from django.urls import reverse +from rest_framework import status + +from api.audit_trail.models import Audit +from api.audit_trail.enums import AuditType +from api.applications.tests.factories import StandardApplicationFactory +from api.staticdata.statuses.enums import CaseStatusEnum +from api.staticdata.statuses.models import CaseStatus + +from test_helpers.clients import DataTestClient + + +class TestChangeStatus(DataTestClient): + + def setUp(self): + super().setUp() + self.application = StandardApplicationFactory(organisation=self.organisation) + self.url = reverse( + "caseworker_applications:change_status", + kwargs={ + "pk": str(self.application.pk), + }, + ) + + @parameterized.expand( + list(set(CaseStatusEnum.caseworker_operable_statuses()) - set(CaseStatusEnum.terminal_statuses())) + ) + def test_change_status_success(self, case_status): + self.application.status = CaseStatus.objects.get(status=case_status) + self.application.save() + response = self.client.post(self.url, **self.gov_headers, data={"status": CaseStatusEnum.SUBMITTED}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + application_id = response.json().get("id") + self.assertEqual(application_id, str(self.application.id)) + self.application.refresh_from_db() + self.assertEqual(self.application.status.status, CaseStatusEnum.SUBMITTED) + + def test_change_status_success_with_note(self): + self.application.status = CaseStatus.objects.get(status=CaseStatusEnum.SUBMITTED) + self.application.save() + response = self.client.post( + self.url, **self.gov_headers, data={"status": CaseStatusEnum.INITIAL_CHECKS, "note": "some reason"} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + application_id = response.json().get("id") + self.assertEqual(application_id, str(self.application.id)) + self.application.refresh_from_db() + self.assertEqual(self.application.status.status, CaseStatusEnum.INITIAL_CHECKS) + audit_entry = Audit.objects.get(verb=AuditType.UPDATED_STATUS) + assert audit_entry.payload == { + "additional_text": "some reason", + "status": { + "new": CaseStatusEnum.INITIAL_CHECKS, + "old": CaseStatusEnum.SUBMITTED, + }, + } + + def test_change_status_not_permitted_status(self): + self.application.status = CaseStatus.objects.get(status=CaseStatusEnum.SUBMITTED) + self.application.save() + response = self.client.post(self.url, **self.gov_headers, data={"status": CaseStatusEnum.APPLICANT_EDITING}) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.application.refresh_from_db() + self.assertEqual(self.application.status.status, CaseStatusEnum.SUBMITTED) + + def test_change_status_not_caseworker_operable(self): + self.application.status = CaseStatus.objects.get(status=CaseStatusEnum.SUPERSEDED_BY_EXPORTER_EDIT) + self.application.save() + response = self.client.post(self.url, **self.gov_headers, data={"status": CaseStatusEnum.OGD_ADVICE}) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.application.refresh_from_db() + self.assertEqual(self.application.status.status, CaseStatusEnum.SUPERSEDED_BY_EXPORTER_EDIT) + + def test_change_status_application_not_found(self): + self.application.delete() + response = self.client.post(self.url, **self.gov_headers, data={"status": CaseStatusEnum.APPLICANT_EDITING}) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_change_status_exporter_not_permitted(self): + self.application.organisation = self.exporter_user.organisation + self.application.save() + + response = self.client.post(self.url, **self.exporter_headers, data={"status": CaseStatusEnum.SUBMITTED}) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/api/applications/exporter/__init__.py b/api/applications/exporter/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/applications/exporter/permissions.py b/api/applications/exporter/permissions.py new file mode 100644 index 0000000000..ccbdf2528b --- /dev/null +++ b/api/applications/exporter/permissions.py @@ -0,0 +1,21 @@ +from rest_framework import permissions + +from api.staticdata.statuses.enums import CaseStatusEnum + + +# TODO: Review where django-rules can help simplify this +class CaseStatusExporterChangeable(permissions.BasePermission): + def has_object_permission(self, request, view, application): + new_status = request.data.get("status") + original_status = application.status.status + + if new_status == CaseStatusEnum.WITHDRAWN and not CaseStatusEnum.is_terminal(original_status): + return True + + if new_status == CaseStatusEnum.SURRENDERED and original_status == CaseStatusEnum.FINALISED: + return True + + if new_status == CaseStatusEnum.APPLICANT_EDITING and CaseStatusEnum.can_invoke_major_edit(original_status): + return True + + return False diff --git a/api/applications/exporter/serializers.py b/api/applications/exporter/serializers.py new file mode 100644 index 0000000000..842619f587 --- /dev/null +++ b/api/applications/exporter/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from api.staticdata.statuses.enums import CaseStatusEnum + + +class ApplicationChangeStatusSerializer(serializers.Serializer): + + status = serializers.ChoiceField(choices=CaseStatusEnum.all()) + note = serializers.CharField(required=False, allow_blank=True, allow_null=True, max_length=2000) diff --git a/api/applications/exporter/tests/__init__.py b/api/applications/exporter/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/applications/exporter/tests/test_permissions.py b/api/applications/exporter/tests/test_permissions.py new file mode 100644 index 0000000000..1bfdda7aec --- /dev/null +++ b/api/applications/exporter/tests/test_permissions.py @@ -0,0 +1,95 @@ +from unittest import mock +from parameterized import parameterized + +from api.applications.exporter.permissions import CaseStatusExporterChangeable +from api.applications.tests.factories import StandardApplicationFactory +from api.staticdata.statuses.enums import CaseStatusEnum +from api.staticdata.statuses.models import CaseStatus + + +from test_helpers.clients import DataTestClient + +non_terminal_statuses = list(set(CaseStatusEnum.all()) - set(CaseStatusEnum.terminal_statuses())) +non_finalised_statuses = list(set(CaseStatusEnum.all()) - set([CaseStatusEnum.FINALISED])) +non_major_editable_statuses = list(set(CaseStatusEnum.all()) - set(CaseStatusEnum.can_invoke_major_edit_statuses())) +non_exporter_eligible_statuses = list( + set(CaseStatusEnum.all()) + - set([CaseStatusEnum.WITHDRAWN, CaseStatusEnum.SURRENDERED, CaseStatusEnum.APPLICANT_EDITING]) +) + + +class TestChangeStatusExporterChangeable(DataTestClient): + + def setUp(self): + super().setUp() + self.application = StandardApplicationFactory(status=CaseStatus.objects.get(status=CaseStatusEnum.SUBMITTED)) + self.permission_obj = CaseStatusExporterChangeable() + + @parameterized.expand(non_terminal_statuses) + def test_has_object_permission_set_withdrawn_permitted(self, original_status): + self.application.status = CaseStatus.objects.get(status=original_status) + self.application.save() + + mock_request = mock.Mock() + mock_request.data = {"status": CaseStatusEnum.WITHDRAWN} + mock_request.user = self.exporter_user.baseuser_ptr + assert self.permission_obj.has_object_permission(mock_request, None, self.application) is True + + @parameterized.expand(CaseStatusEnum.terminal_statuses()) + def test_has_object_permission_set_withdrawn_not_permitted(self, original_status): + self.application.status = CaseStatus.objects.get(status=original_status) + self.application.save() + + mock_request = mock.Mock() + mock_request.data = {"status": CaseStatusEnum.WITHDRAWN} + mock_request.user = self.exporter_user.baseuser_ptr + assert self.permission_obj.has_object_permission(mock_request, None, self.application) is False + + def test_has_object_permission_set_surrendered_permitted(self): + self.application.status = CaseStatus.objects.get(status=CaseStatusEnum.FINALISED) + self.application.save() + + mock_request = mock.Mock() + mock_request.data = {"status": CaseStatusEnum.SURRENDERED} + mock_request.user = self.exporter_user.baseuser_ptr + assert self.permission_obj.has_object_permission(mock_request, None, self.application) is True + + @parameterized.expand(non_finalised_statuses) + def test_has_object_permission_set_surrendered_not_permitted(self, original_status): + self.application.status = CaseStatus.objects.get(status=original_status) + self.application.save() + + mock_request = mock.Mock() + mock_request.data = {"status": CaseStatusEnum.SURRENDERED} + mock_request.user = self.exporter_user.baseuser_ptr + assert self.permission_obj.has_object_permission(mock_request, None, self.application) is False + + @parameterized.expand(CaseStatusEnum.can_invoke_major_edit_statuses()) + def test_has_object_permission_set_applicant_editing_permitted(self, original_status): + self.application.status = CaseStatus.objects.get(status=original_status) + self.application.save() + + mock_request = mock.Mock() + mock_request.data = {"status": CaseStatusEnum.APPLICANT_EDITING} + mock_request.user = self.exporter_user.baseuser_ptr + assert self.permission_obj.has_object_permission(mock_request, None, self.application) is True + + @parameterized.expand(non_major_editable_statuses) + def test_has_object_permission_set_applicant_editing_not_permitted(self, original_status): + self.application.status = CaseStatus.objects.get(status=original_status) + self.application.save() + + mock_request = mock.Mock() + mock_request.data = {"status": CaseStatusEnum.APPLICANT_EDITING} + mock_request.user = self.exporter_user.baseuser_ptr + assert self.permission_obj.has_object_permission(mock_request, None, self.application) is False + + @parameterized.expand(non_exporter_eligible_statuses) + def test_has_object_permission_non_exporter_status_not_permitted(self, new_status): + self.application.status = CaseStatus.objects.get(status=CaseStatusEnum.SUBMITTED) + self.application.save() + + mock_request = mock.Mock() + mock_request.data = {"status": new_status} + mock_request.user = self.exporter_user.baseuser_ptr + assert self.permission_obj.has_object_permission(mock_request, None, self.application) is False diff --git a/api/applications/exporter/urls.py b/api/applications/exporter/urls.py new file mode 100644 index 0000000000..c3e38e174c --- /dev/null +++ b/api/applications/exporter/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from api.applications.exporter.views import applications + +app_name = "exporter_applications" + +urlpatterns = [ + path("/status/", applications.ApplicationChangeStatus.as_view(), name="change_status"), +] diff --git a/api/applications/exporter/views/__init__.py b/api/applications/exporter/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/applications/exporter/views/applications.py b/api/applications/exporter/views/applications.py new file mode 100644 index 0000000000..a56beb1355 --- /dev/null +++ b/api/applications/exporter/views/applications.py @@ -0,0 +1,51 @@ +from django.core.exceptions import ObjectDoesNotExist +from django.http import Http404, JsonResponse +from django.db import transaction +from rest_framework.generics import GenericAPIView +from rest_framework import status + +from api.applications.exporter.permissions import CaseStatusExporterChangeable +from api.applications.exporter.serializers import ApplicationChangeStatusSerializer +from api.applications.helpers import get_application_view_serializer +from api.applications.libraries.get_applications import get_application +from api.core.authentication import ExporterAuthentication +from api.core.exceptions import NotFoundError +from api.core.permissions import IsExporterInOrganisation +from api.staticdata.statuses.libraries.get_case_status import get_case_status_by_status + + +class ApplicationChangeStatus(GenericAPIView): + authentication_classes = (ExporterAuthentication,) + permission_classes = [ + IsExporterInOrganisation, + CaseStatusExporterChangeable, + ] + serializer_class = ApplicationChangeStatusSerializer + + def setup(self, request, *args, **kwargs): + super().setup(request, *args, **kwargs) + try: + self.application = get_application(self.kwargs["pk"]) + except (ObjectDoesNotExist, NotFoundError): + raise Http404() + + def get_object(self): + self.check_object_permissions(self.request, self.application) + return self.application + + def get_organisation(self): + return self.application.organisation + + @transaction.atomic + def post(self, request, pk): + application = self.get_object() + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.data + application.change_status(request.user, get_case_status_by_status(data["status"]), data["note"]) + + response_data = get_application_view_serializer(application)( + application, context={"user_type": request.user.type} + ).data + + return JsonResponse(data=response_data, status=status.HTTP_200_OK) diff --git a/api/applications/exporter/views/tests/__init__.py b/api/applications/exporter/views/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/applications/exporter/views/tests/test_applications.py b/api/applications/exporter/views/tests/test_applications.py new file mode 100644 index 0000000000..4f11f36c30 --- /dev/null +++ b/api/applications/exporter/views/tests/test_applications.py @@ -0,0 +1,79 @@ +from parameterized import parameterized + +from django.urls import reverse +from rest_framework import status + +from api.applications.tests.factories import StandardApplicationFactory +from api.staticdata.statuses.enums import CaseStatusEnum +from api.staticdata.statuses.models import CaseStatus +from api.organisations.tests.factories import OrganisationFactory + +from test_helpers.clients import DataTestClient + + +class TestChangeStatus(DataTestClient): + + def setUp(self): + super().setUp() + self.application = StandardApplicationFactory(organisation=self.exporter_user.organisation) + self.url = reverse( + "exporter_applications:change_status", + kwargs={ + "pk": str(self.application.pk), + }, + ) + + @parameterized.expand( + [ + (CaseStatusEnum.SUBMITTED, CaseStatusEnum.WITHDRAWN), + (CaseStatusEnum.SUBMITTED, CaseStatusEnum.APPLICANT_EDITING), + (CaseStatusEnum.INITIAL_CHECKS, CaseStatusEnum.APPLICANT_EDITING), + (CaseStatusEnum.REOPENED_FOR_CHANGES, CaseStatusEnum.APPLICANT_EDITING), + ] + ) + def test_change_status_success(self, original_status, new_status): + self.application.status = CaseStatus.objects.get(status=original_status) + self.application.save() + response = self.client.post(self.url, **self.exporter_headers, data={"status": new_status}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + application_id = response.json().get("id") + self.assertEqual(application_id, str(self.application.id)) + self.application.refresh_from_db() + self.assertEqual(self.application.status.status, new_status) + + @parameterized.expand( + [ + (CaseStatusEnum.FINALISED, CaseStatusEnum.WITHDRAWN), + (CaseStatusEnum.SUBMITTED, CaseStatusEnum.SURRENDERED), + (CaseStatusEnum.OGD_ADVICE, CaseStatusEnum.APPLICANT_EDITING), + (CaseStatusEnum.FINALISED, CaseStatusEnum.APPLICANT_EDITING), + ] + ) + def test_change_status_status_change_not_permitted(self, original_status, new_status): + self.application.status = CaseStatus.objects.get(status=original_status) + self.application.save() + response = self.client.post(self.url, **self.exporter_headers, data={"status": new_status}) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.application.refresh_from_db() + self.assertEqual(self.application.status.status, original_status) + + def test_change_status_application_not_found(self): + self.application.delete() + + response = self.client.post( + self.url, **self.exporter_headers, data={"status": CaseStatusEnum.APPLICANT_EDITING} + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_change_status_application_wrong_organisation(self): + original_status = CaseStatusEnum.INITIAL_CHECKS + self.application.status = CaseStatus.objects.get(status=original_status) + self.application.organisation = OrganisationFactory() + self.application.save() + + response = self.client.post( + self.url, **self.exporter_headers, data={"status": CaseStatusEnum.APPLICANT_EDITING} + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.application.refresh_from_db() + self.assertEqual(self.application.status.status, original_status) diff --git a/api/applications/libraries/application_helpers.py b/api/applications/libraries/application_helpers.py index 501d03e59e..eb1de6f2f8 100644 --- a/api/applications/libraries/application_helpers.py +++ b/api/applications/libraries/application_helpers.py @@ -26,6 +26,7 @@ def optional_str_to_bool(optional_string: str): raise ValueError("You provided " + optional_string + ', while the allowed values are None, "true" or "false"') +# TODO: After release of LTD-5225, remove this function alongside legacy ApplicationManageStatus view def can_status_be_set_by_exporter_user(original_status: str, new_status: str) -> bool: """Check that a status can be set by an exporter user. Exporter users cannot withdraw an application that is already in a terminal state and they cannot set an application to `Applicant editing` if the @@ -45,6 +46,7 @@ def can_status_be_set_by_exporter_user(original_status: str, new_status: str) -> return True +# TODO: After release of LTD-5225, remove this function alongside legacy ApplicationManageStatus view def can_status_be_set_by_gov_user(user: GovUser, original_status: str, new_status: str, is_mod: bool) -> bool: """ Check that a status can be set by a gov user. Gov users can not set a case's status to @@ -91,6 +93,7 @@ def create_submitted_audit(user, application, old_status: str, additional_payloa ) +# TODO: After release of LTD-5225, remove this function alongside legacy ApplicationManageStatus view def check_user_can_set_status(request, application, data): """ Checks whether an user (internal/exporter) can set the requested status diff --git a/api/applications/tests/test_application_status.py b/api/applications/tests/test_application_status.py index bbbb35a235..cb73d62302 100644 --- a/api/applications/tests/test_application_status.py +++ b/api/applications/tests/test_application_status.py @@ -56,7 +56,7 @@ def test_set_application_status_on_application_not_in_users_organisation_failure self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(self.standard_application.status, get_case_status_by_status(CaseStatusEnum.SUBMITTED)) - @mock.patch("api.applications.views.applications.notify_exporter_case_opened_for_editing") + @mock.patch("api.applications.notify.notify_exporter_case_opened_for_editing") def test_exporter_set_application_status_applicant_editing_when_in_editable_status_success(self, mock_notify): self.submit_application(self.standard_application) self.standard_application.status = get_case_status_by_status(CaseStatusEnum.REOPENED_FOR_CHANGES) diff --git a/api/applications/tests/test_models.py b/api/applications/tests/test_models.py index 188de0ae16..6d9a75bd5e 100644 --- a/api/applications/tests/test_models.py +++ b/api/applications/tests/test_models.py @@ -109,7 +109,7 @@ def test_create_amendment(self): assert amendment_audit_entry.actor == exporter_user status_change_audit_entry = audit_entries[0] assert status_change_audit_entry.payload == { - "status": {"new": "Superseded by exporter edit", "old": "ogd_advice"} + "status": {"new": "superseded_by_exporter_edit", "old": "ogd_advice"} } assert status_change_audit_entry.verb == "updated_status" diff --git a/api/applications/views/applications.py b/api/applications/views/applications.py index 5568b7582c..0bf984fb48 100644 --- a/api/applications/views/applications.py +++ b/api/applications/views/applications.py @@ -51,7 +51,6 @@ PartyOnApplication, StandardApplication, ) -from api.applications.notify import notify_exporter_case_opened_for_editing from api.applications.serializers.generic_application import ( GenericApplicationListSerializer, GenericApplicationCopySerializer, @@ -86,7 +85,7 @@ from api.goods.models import FirearmGoodDetails from api.goodstype.models import GoodsType from api.licences.enums import LicenceStatus -from api.licences.helpers import get_licence_reference_code, update_licence_status +from api.licences.helpers import get_licence_reference_code from api.licences.models import Licence from api.licences.serializers.create_licence import LicenceCreateSerializer from lite_content.lite_api import strings @@ -102,7 +101,6 @@ from api.workflow.flagging_rules_automation import apply_flagging_rules_to_case from lite_routing.routing_rules_internal.routing_engine import run_routing_rules -from api.cases.libraries.finalise import remove_flags_on_finalisation, remove_flags_from_audit_trail class ApplicationList(ListCreateAPIView): @@ -380,6 +378,7 @@ def put(self, request, pk): return JsonResponse(data=data, status=status.HTTP_200_OK) +# TODO: After release of LTD-5225, remove this endpoint completely class ApplicationManageStatus(APIView): authentication_classes = (SharedAuthentication,) @@ -392,50 +391,14 @@ def put(self, request, pk): if error_response: return error_response - update_licence_status(application, data["status"]) + new_status = data["status"] + note = data.get("note", "") + user = request.user - case_status = get_case_status_by_status(data["status"]) - data["status"] = str(case_status.pk) - old_status = application.status - - serializer = get_application_update_serializer(application) - serializer = serializer(application, data=data, partial=True) - - if not serializer.is_valid(): - return JsonResponse(data={"errors": serializer.errors}, status=status.HTTP_400_BAD_REQUEST) - - application = serializer.save() - - if CaseStatusEnum.is_terminal(old_status.status) and not CaseStatusEnum.is_terminal(application.status.status): - # we reapply flagging rules if the status is reopened from a terminal state - apply_flagging_rules_to_case(application) - - audit_trail_service.create( - actor=request.user, - verb=AuditType.UPDATED_STATUS, - target=application.get_case(), - payload={ - "status": { - "new": case_status.status, - "old": old_status.status, - }, - "additional_text": data.get("note"), - }, - ) - - if old_status != application.status: - run_routing_rules(case=application, keep_status=True) - - if application.status.status == CaseStatusEnum.APPLICANT_EDITING: - notify_exporter_case_opened_for_editing(application) + application.change_status(user, get_case_status_by_status(new_status), note) data = get_application_view_serializer(application)(application, context={"user_type": request.user.type}).data - # Remove needed flags when case is Withdrawn/Closed - if case_status.status in [CaseStatusEnum.WITHDRAWN, CaseStatusEnum.CLOSED]: - remove_flags_on_finalisation(application.get_case()) - remove_flags_from_audit_trail(application.get_case()) - return JsonResponse(data={"data": data}, status=status.HTTP_200_OK) diff --git a/api/applications/views/tests/test_parties.py b/api/applications/views/tests/test_parties.py index d1b20f3e8f..909210684f 100644 --- a/api/applications/views/tests/test_parties.py +++ b/api/applications/views/tests/test_parties.py @@ -5,8 +5,10 @@ StandardApplicationFactory, PartyOnApplicationFactory, ) +from api.parties.models import Party from api.staticdata.statuses.models import CaseStatus from test_helpers.clients import DataTestClient +from reversion.models import Version class TestApplicationPartyView(DataTestClient): @@ -27,11 +29,29 @@ def setUp(self): ) def test_draft_application_can_update_party_detail(self): + party = Party.objects.get(id=self.party_on_application.party.pk) + versions = Version.objects.get_for_object(party) + self.assertEqual(versions.count(), 0) + self.application.status = CaseStatus.objects.get(status="draft") self.application.save() response = self.client.put(self.url, **self.exporter_headers, data=self.data) self.assertEqual(response.status_code, status.HTTP_200_OK) + party.refresh_from_db() + versions = Version.objects.get_for_object(party) + self.assertEqual(versions.count(), 1) + self.assertEqual(versions.first().field_dict["name"], "End user") + + response = self.client.put(self.url, **self.exporter_headers, data={"name": "Second Update"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + party.refresh_from_db() + versions = Version.objects.get_for_object(party) + self.assertEqual(versions.count(), 2) + self.assertEqual(versions.first().field_dict["name"], "Second Update") + self.assertEqual(versions.last().field_dict["name"], "End user") + def test_editing_application_can_update_party_detail(self): self.application.status = CaseStatus.objects.get(status="applicant_editing") self.application.save() diff --git a/api/cases/managers.py b/api/cases/managers.py index 40bd3b21c7..2e9b3bae34 100644 --- a/api/cases/managers.py +++ b/api/cases/managers.py @@ -82,6 +82,9 @@ def has_status(self, status): def has_sub_status(self, sub_status): return self.filter(sub_status__name=sub_status) + def has_licence_status(self, licence_status): + return self.filter(baseapplication__licences__status=licence_status) + def is_type(self, case_type): return self.filter(case_type=case_type) @@ -259,6 +262,7 @@ def search( # noqa user=None, status=None, sub_status=None, + licence_status=None, case_type=None, assigned_user=None, case_officer=None, @@ -320,6 +324,7 @@ def search( # noqa "case_assignments__queue", "queues", "queues__team", + "baseapplication__licences", Prefetch( "baseapplication__parties", to_attr="end_user_parties", @@ -351,6 +356,9 @@ def search( # noqa if sub_status: case_qs = case_qs.has_sub_status(sub_status=sub_status) + if licence_status: + case_qs = case_qs.has_licence_status(licence_status=licence_status) + if case_type: case_type = CaseTypeEnum.reference_to_id(case_type) case_qs = case_qs.is_type(case_type=case_type) diff --git a/api/cases/models.py b/api/cases/models.py index e72dcede35..1735d392bd 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -194,8 +194,9 @@ def change_status(self, user, status: CaseStatus, note: Optional[str] = ""): Sets the status for the case, runs validation on various parameters, creates audit entries and also runs flagging and automation rules """ + from api.applications.notify import notify_exporter_case_opened_for_editing from api.audit_trail import service as audit_trail_service - from api.workflow.flagging_rules_automation import apply_flagging_rules_to_case + from api.cases.libraries.finalise import remove_flags_on_finalisation, remove_flags_from_audit_trail from api.licences.helpers import update_licence_status from lite_routing.routing_rules_internal.routing_engine import run_routing_rules @@ -207,15 +208,12 @@ def change_status(self, user, status: CaseStatus, note: Optional[str] = ""): # Update licence status if applicable case status change update_licence_status(self, status.status) - if CaseStatusEnum.is_terminal(old_status) and not CaseStatusEnum.is_terminal(self.status.status): - apply_flagging_rules_to_case(self) - audit_trail_service.create( actor=user, verb=AuditType.UPDATED_STATUS, - target=self, + target=self.get_case(), payload={ - "status": {"new": CaseStatusEnum.get_text(self.status.status), "old": old_status}, + "status": {"new": self.status.status, "old": old_status}, "additional_text": note, }, ) @@ -223,6 +221,14 @@ def change_status(self, user, status: CaseStatus, note: Optional[str] = ""): if old_status != self.status.status: run_routing_rules(case=self, keep_status=True) + if status.status == CaseStatusEnum.APPLICANT_EDITING: + notify_exporter_case_opened_for_editing(self) + + # Remove needed flags when case is Withdrawn/Closed + if status.status in [CaseStatusEnum.WITHDRAWN, CaseStatusEnum.CLOSED]: + remove_flags_on_finalisation(self) + remove_flags_from_audit_trail(self) + def parameter_set(self): """ This function looks at the case determines the flags, casetype, and countries of that case, diff --git a/api/cases/signals.py b/api/cases/signals.py index 4da08c2f6e..05cf39228d 100644 --- a/api/cases/signals.py +++ b/api/cases/signals.py @@ -8,8 +8,10 @@ from api.workflow.flagging_rules_automation import apply_flagging_rules_to_case -@receiver(pre_save, sender=Case) +@receiver(pre_save) def case_pre_save_handler(sender, instance, raw=False, **kwargs): + if not isinstance(instance, Case): + return try: previous_record = Case.objects.get(pk=instance.id) instance._previous_status = previous_record.status @@ -17,16 +19,20 @@ def case_pre_save_handler(sender, instance, raw=False, **kwargs): pass -@receiver(post_save, sender=Case) +@receiver(post_save) def case_post_save_handler(sender, instance, raw=False, **kwargs): if raw: return + if not isinstance(instance, Case): + return + + case = instance.get_case() status_changed = instance._previous_status and instance._previous_status != instance.status status_draft = instance.status == get_case_status_by_status(CaseStatusEnum.DRAFT) new_status_terminal = instance.status.is_terminal if status_changed and not status_draft and not new_status_terminal: - apply_flagging_rules_to_case(instance) + apply_flagging_rules_to_case(case) _check_for_countersign_rejection(instance) diff --git a/api/cases/tests/test_case_search.py b/api/cases/tests/test_case_search.py index 44ddf9b907..b0596915b7 100644 --- a/api/cases/tests/test_case_search.py +++ b/api/cases/tests/test_case_search.py @@ -50,6 +50,8 @@ ) from lite_routing.routing_rules_internal.enums import FlagsEnum +from api.licences.enums import LicenceStatus +from api.licences.tests.factories import StandardLicenceFactory class FilterAndSortTests(DataTestClient): @@ -1227,3 +1229,27 @@ def test_get_cases_filter_by_sub_status(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response_data), 1) self.assertEqual(response_data[0]["id"], str(self.case.id)) + + @parameterized.expand( + [ + [LicenceStatus.ISSUED, "issued", 1], + [LicenceStatus.SUSPENDED, "suspended", 1], + [LicenceStatus.REVOKED, "revoked", 1], + [LicenceStatus.ISSUED, "statustext", 0], + ] + ) + def test_get_cases_filter_by_licence_status(self, licence_status, licence_status_search, expected_case_count): + self._create_data() + self.application = StandardApplicationFactory() + self.case_2 = Case.objects.get(id=self.application.id) + self.case_2.submitted_at = django_utils.timezone.now() + self.case_2.licences.add(StandardLicenceFactory(case=self.case_2, status=licence_status)) + self.case_2.save() + + url = f'{reverse("cases:search")}?licence_status={licence_status_search}' + + response = self.client.get(url, **self.gov_headers) + response_data = response.json()["results"]["cases"] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response_data), expected_case_count) diff --git a/api/cases/tests/test_create_advice.py b/api/cases/tests/test_create_advice.py index fff551354f..45cef95866 100644 --- a/api/cases/tests/test_create_advice.py +++ b/api/cases/tests/test_create_advice.py @@ -18,6 +18,7 @@ from api.core.constants import GovPermissions, Roles from api.flags.models import Flag from api.staticdata.denial_reasons.models import DenialReason +from api.staticdata.statuses.models import CaseStatus from api.staticdata.statuses.enums import CaseStatusEnum from api.staticdata.statuses.libraries.get_case_status import get_case_status_by_status from api.teams.enums import TeamIdEnum @@ -744,9 +745,8 @@ def setUp(self): def test_countersign_advice_terminal_status_failure(self): """Ensure we cannot countersign a case that is in one of the terminal state""" case_url = reverse("cases:case", kwargs={"pk": self.case.id}) - data = {"status": CaseStatusEnum.WITHDRAWN, "note": "test"} - response = self.client.patch(case_url, data, **self.gov_headers) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.case.status = CaseStatus.objects.get(status=CaseStatusEnum.WITHDRAWN) + self.case.save() response = self.client.put(self.url, **self.gov_headers, data=[]) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -823,9 +823,8 @@ def setUp(self): def test_countersign_advice_with_decision_terminal_status_failure(self): """Ensure we cannot countersign a case that is in one of the terminal state""" case_url = reverse("cases:case", kwargs={"pk": self.case.id}) - data = {"status": CaseStatusEnum.WITHDRAWN, "note": "test"} - response = self.client.patch(case_url, data, **self.gov_headers) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.case.status = CaseStatus.objects.get(status=CaseStatusEnum.WITHDRAWN) + self.case.save() response = self.client.post(self.url, **self.gov_headers, data=[]) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/api/cases/tests/test_models.py b/api/cases/tests/test_models.py index b0acf32ca8..3e112bdefb 100644 --- a/api/cases/tests/test_models.py +++ b/api/cases/tests/test_models.py @@ -1,9 +1,11 @@ +from unittest import mock from uuid import UUID from api.audit_trail.enums import AuditType from api.audit_trail.models import Audit from api.audit_trail.serializers import AuditSerializer from parameterized import parameterized +from api.applications.tests.factories import StandardApplicationFactory from api.cases.models import BadSubStatus from api.cases.tests.factories import CaseFactory from api.staticdata.statuses.enums import CaseStatusEnum, CaseSubStatusIdEnum @@ -99,3 +101,72 @@ def test_case_save_reset_sub_status_system_audit(self): ) audit_text = AuditSerializer(audit).data["text"] self.assertEqual(audit_text, "updated the status to Finalised") + + def test_change_status_same_status(self): + status = CaseStatus.objects.get(status="submitted") + app = StandardApplicationFactory( + status=status, + ) + case = app.get_case() + case.change_status(self.gov_user, status=status) + case.refresh_from_db() + assert case.status == status + + @mock.patch("api.licences.helpers.update_licence_status") + @mock.patch("lite_routing.routing_rules_internal.routing_engine.run_routing_rules") + def test_change_status_new_status(self, mock_run_routing_rules, mock_update_licence_status): + original_status = CaseStatus.objects.get(status="submitted") + new_status = CaseStatus.objects.get(status="ogd_advice") + app = StandardApplicationFactory( + status=original_status, + ) + case = app.get_case() + case.change_status(self.gov_user, status=new_status, note="some note") + case.refresh_from_db() + assert case.status == new_status + audit_entry = Audit.objects.first() + assert audit_entry.verb == AuditType.UPDATED_STATUS + assert audit_entry.target == case + assert audit_entry.payload == { + "status": {"new": new_status.status, "old": original_status.status}, + "additional_text": "some note", + } + assert audit_entry.actor == self.gov_user + mock_update_licence_status.assert_called_with(case, new_status.status) + mock_run_routing_rules.assert_called_with(case=case, keep_status=True) + + @mock.patch("api.applications.notify.notify_exporter_case_opened_for_editing") + def test_change_status_to_applicant_editing(self, mock_notify_exporter_case_opened_for_editing): + original_status = CaseStatus.objects.get(status="submitted") + new_status = CaseStatus.objects.get(status="applicant_editing") + app = StandardApplicationFactory( + status=original_status, + ) + case = app.get_case() + case.change_status(self.gov_user, status=new_status, note="some note") + case.refresh_from_db() + assert case.status == new_status + mock_notify_exporter_case_opened_for_editing.assert_called_with(case) + + @parameterized.expand( + [ + (CaseStatusEnum.WITHDRAWN,), + (CaseStatusEnum.CLOSED,), + ] + ) + @mock.patch("api.cases.libraries.finalise.remove_flags_on_finalisation") + @mock.patch("api.cases.libraries.finalise.remove_flags_from_audit_trail") + def test_change_status_to_closed( + self, case_status, mock_remove_flags_from_audit_trail, mock_remove_flags_on_finalisation + ): + original_status = CaseStatus.objects.get(status="submitted") + new_status = CaseStatus.objects.get(status=case_status) + app = StandardApplicationFactory( + status=original_status, + ) + case = app.get_case() + case.change_status(self.gov_user, status=new_status, note="some note") + case.refresh_from_db() + assert case.status == new_status + mock_remove_flags_from_audit_trail.assert_called_with(case) + mock_remove_flags_on_finalisation.assert_called_with(case) diff --git a/api/cases/tests/test_signals.py b/api/cases/tests/test_signals.py index 2cd47e532d..e045fb556b 100644 --- a/api/cases/tests/test_signals.py +++ b/api/cases/tests/test_signals.py @@ -1,7 +1,7 @@ from unittest import mock -from django.test import override_settings +from api.applications.tests.factories import StandardApplicationFactory from api.cases.models import Case from api.cases.signals import case_post_save_handler from api.cases.tests.factories import CaseFactory @@ -63,6 +63,16 @@ def test_case_post_save_handler_signal_fires(self, mocked_flagging_func): case.save() assert mocked_flagging_func.called + @mock.patch("api.cases.signals.apply_flagging_rules_to_case") + def test_case_post_save_handler_standard_application_signal_fires(self, mocked_flagging_func): + submitted = CaseStatus.objects.get(status="submitted") + # We don't expect the initial save to call flagging rules (since the case is new) + assert not mocked_flagging_func.called + application = StandardApplicationFactory(status=submitted) + application.status = CaseStatus.objects.get(status="initial_checks") + application.save() + assert mocked_flagging_func.called + """ Test if workflow kicked off in first save, not in second and kicked off in third save """ diff --git a/api/cases/tests/test_status.py b/api/cases/tests/test_status.py deleted file mode 100644 index ac48b2fac2..0000000000 --- a/api/cases/tests/test_status.py +++ /dev/null @@ -1,197 +0,0 @@ -from django.urls import reverse -from faker import Faker -from parameterized import parameterized -from rest_framework import status - -from api.applications.tests.factories import StandardApplicationFactory -from api.audit_trail.enums import AuditType -from api.audit_trail.models import Audit -from api.cases.models import CaseAssignment -from api.licences.enums import LicenceStatus -from api.licences.tests.factories import StandardLicenceFactory -from api.staticdata.statuses.enums import CaseStatusEnum -from api.staticdata.statuses.libraries.get_case_status import get_case_status_by_status -from test_helpers.clients import DataTestClient - -faker = Faker() - - -class ChangeStatusTests(DataTestClient): - def setUp(self): - super().setUp() - self.case = StandardApplicationFactory() - self.url = reverse("cases:case", kwargs={"pk": self.case.id}) - - def test_optional_note(self): - """ - When changing status, allow for optional notes to be added - """ - - data = {"status": CaseStatusEnum.WITHDRAWN, "note": faker.word()} - - response = self.client.patch(self.url, data, **self.gov_headers) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(Audit.objects.get(verb=AuditType.UPDATED_STATUS).payload["additional_text"], data["note"]) - - @parameterized.expand( - [ - ( - CaseStatusEnum.SUSPENDED, - LicenceStatus.SUSPENDED, - ), - ( - CaseStatusEnum.SURRENDERED, - LicenceStatus.SURRENDERED, - ), - ( - CaseStatusEnum.REVOKED, - LicenceStatus.REVOKED, - ), - ] - ) - def test_certain_case_statuses_changes_licence_status(self, case_status, licence_status): - licence = StandardLicenceFactory(case=self.case, status=LicenceStatus.ISSUED) - - data = {"status": case_status} - response = self.client.patch(self.url, data=data, **self.gov_headers) - - self.case.refresh_from_db() - licence.refresh_from_db() - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(self.case.status.status, case_status) - self.assertEqual(licence.status, licence_status) - - def test_change_status_no_user_permission(self): - data = {"status": CaseStatusEnum.FINALISED} - response = self.client.patch(self.url, data=data, **self.gov_headers) - - self.case.refresh_from_db() - - # This should be a 401, but legacy... - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(self.case.status.status, "submitted") - - def test_change_status_new_status_not_allowed(self): - data = {"status": CaseStatusEnum.APPLICANT_EDITING} - response = self.client.patch(self.url, data=data, **self.gov_headers) - - self.case.refresh_from_db() - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(self.case.status.status, "submitted") - - # TODO: More tests covering different paths for status change view - - -class EndUserAdvisoryUpdate(DataTestClient): - def setUp(self): - super().setUp() - self.end_user_advisory = self.create_end_user_advisory_case( - "end_user_advisory", "my reasons", organisation=self.organisation - ) - self.url = reverse( - "cases:case", - kwargs={"pk": self.end_user_advisory.id}, - ) - - self.end_user_advisory.case_officer = self.gov_user - self.end_user_advisory.save() - self.end_user_advisory.queues.set([self.queue]) - CaseAssignment.objects.create(case=self.end_user_advisory, queue=self.queue, user=self.gov_user) - - def test_update_end_user_advisory_status_to_withdrawn_success(self): - """ - When a case is set to a the withdrawn status, its assigned users, case officer and queues should be removed - """ - data = {"status": CaseStatusEnum.WITHDRAWN} - - response = self.client.patch(self.url, data, **self.gov_headers) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.end_user_advisory.refresh_from_db() - self.assertEqual(self.end_user_advisory.status.status, CaseStatusEnum.WITHDRAWN) - self.assertEqual(self.end_user_advisory.queues.count(), 0) - self.assertEqual(self.end_user_advisory.case_officer, None) - self.assertEqual(CaseAssignment.objects.filter(case=self.end_user_advisory).count(), 0) - - -class EndUserAdvisoryStatus(DataTestClient): - def setUp(self): - super().setUp() - self.query = self.create_end_user_advisory("A note", "Unsure about something", self.organisation) - self.query.status = get_case_status_by_status(CaseStatusEnum.CLOSED) - self.query.save() - - self.url = reverse("cases:case", kwargs={"pk": self.query.id}) - - def test_gov_set_status_when_no_permission_to_reopen_closed_cases_failure(self): - data = {"status": CaseStatusEnum.SUBMITTED} - - response = self.client.patch(self.url, data=data, **self.gov_headers) - self.query.refresh_from_db() - - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(self.query.status.status, CaseStatusEnum.CLOSED) - - def test_gov_set_status_when_they_have_permission_to_reopen_closed_cases_success(self): - data = {"status": CaseStatusEnum.SUBMITTED} - - # Give gov user super used role, to include reopen closed cases permission - self.gov_user.role = self.super_user_role - self.gov_user.save() - - response = self.client.patch(self.url, data=data, **self.gov_headers) - self.query.refresh_from_db() - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(self.query.status.status, CaseStatusEnum.SUBMITTED) - - -class GoodsQueryManageStatusTests(DataTestClient): - @parameterized.expand([["create_clc_query"], ["create_pv_grading_query"]]) - def test_set_query_status_to_withdrawn_removes_case_from_queues_users_and_updates_status_success(self, cls_func): - """ - When a case is set to a terminal status, its assigned users, case officer and queues should be removed - """ - query = getattr(self, cls_func)("This is a widget", self.organisation) - query.case_officer = self.gov_user - query.save() - query.queues.set([self.queue]) - CaseAssignment.objects.create(case=query, queue=self.queue, user=self.gov_user) - url = reverse("cases:case", kwargs={"pk": query.pk}) - data = {"status": "withdrawn"} - - response = self.client.patch(url, data, **self.gov_headers) - query.refresh_from_db() - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(query.status.status, CaseStatusEnum.WITHDRAWN) - self.assertEqual(query.queues.count(), 0) - self.assertEqual(query.case_officer, None) - self.assertEqual(CaseAssignment.objects.filter(case=query).count(), 0) - - def test_case_routing_automation_status_change(self): - query = self.create_goods_query("This is a widget", self.organisation, "reason", "reason") - query.queues.set([self.queue]) - - routing_queue = self.create_queue("new queue", self.team) - self.create_routing_rule( - self.team.id, - routing_queue.id, - 3, - status_id=get_case_status_by_status(CaseStatusEnum.PV).id, - additional_rules=[], - ) - self.assertNotEqual(query.status.status, CaseStatusEnum.PV) - - url = reverse("cases:case", kwargs={"pk": query.pk}) - data = {"status": CaseStatusEnum.PV} - - response = self.client.patch(url, data, **self.gov_headers) - query.refresh_from_db() - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(query.status.status, CaseStatusEnum.PV) - self.assertEqual(query.queues.count(), 1) - self.assertEqual(query.queues.first().id, routing_queue.id) diff --git a/api/cases/views/views.py b/api/cases/views/views.py index fa29e9f151..1d4bdbb2ae 100644 --- a/api/cases/views/views.py +++ b/api/cases/views/views.py @@ -6,7 +6,7 @@ from django.contrib.contenttypes.models import ContentType from rest_framework import status -from rest_framework.exceptions import ParseError, ValidationError +from rest_framework.exceptions import ParseError from rest_framework.generics import ListCreateAPIView, UpdateAPIView, ListAPIView, RetrieveAPIView from rest_framework.views import APIView @@ -18,7 +18,6 @@ CountryWithFlagsSerializer, CountersignDecisionAdviceSerializer, ) -from api.applications.libraries.application_helpers import can_status_be_set_by_gov_user from api.audit_trail import service as audit_trail_service from api.audit_trail.enums import AuditType from api.cases import notify @@ -182,19 +181,6 @@ def get(self, request, pk): data["licences"] = get_case_licences(case) return JsonResponse(data={"case": data}, status=status.HTTP_200_OK) - def patch(self, request, pk): - """ - Change case status - """ - case = get_case(pk) - new_status = get_case_status_by_status(request.data.get("status")) - - if not can_status_be_set_by_gov_user(request.user.govuser, case.status.status, new_status.status, is_mod=False): - raise ValidationError({"status": ["Status cannot be set by user"]}) - - case.change_status(request.user, new_status, request.data.get("note")) - return JsonResponse(data={}, status=status.HTTP_200_OK) - class CaseDetailBasic(RetrieveAPIView): authentication_classes = (GovAuthentication,) diff --git a/api/conf/caseworker_urls.py b/api/conf/caseworker_urls.py new file mode 100644 index 0000000000..0c99449988 --- /dev/null +++ b/api/conf/caseworker_urls.py @@ -0,0 +1,5 @@ +from django.urls import path, include + +urlpatterns = [ + path("applications/", include("api.applications.caseworker.urls")), +] diff --git a/api/conf/exporter_urls.py b/api/conf/exporter_urls.py new file mode 100644 index 0000000000..d14a5d0b5e --- /dev/null +++ b/api/conf/exporter_urls.py @@ -0,0 +1,5 @@ +from django.urls import path, include + +urlpatterns = [ + path("applications/", include("api.applications.exporter.urls")), +] diff --git a/api/conf/urls.py b/api/conf/urls.py index 15f450dc31..97e3536725 100644 --- a/api/conf/urls.py +++ b/api/conf/urls.py @@ -36,6 +36,8 @@ path("bookmarks/", include("api.bookmarks.urls")), path("appeals/", include("api.appeals.urls")), path("survey/", include("api.survey.urls")), + path("caseworker/", include("api.conf.caseworker_urls")), + path("exporter/", include("api.conf.exporter_urls")), ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) handler500 = "rest_framework.exceptions.server_error" diff --git a/api/core/permissions.py b/api/core/permissions.py index 6383dcb534..b8fd764d7a 100644 --- a/api/core/permissions.py +++ b/api/core/permissions.py @@ -29,3 +29,8 @@ def check_user_has_permission(user, permission, organisation: Organisation = Non class IsExporterInOrganisation(permissions.BasePermission): def has_permission(self, request, view): return get_request_user_organisation(request) == view.get_organisation() + + +class CaseInCaseworkerOperableStatus(permissions.BasePermission): + def has_permission(self, request, view): + return view.get_case().status.is_caseworker_operable diff --git a/api/core/tests/test_permissions.py b/api/core/tests/test_permissions.py new file mode 100644 index 0000000000..e4a5e7bf16 --- /dev/null +++ b/api/core/tests/test_permissions.py @@ -0,0 +1,30 @@ +from unittest import mock +from parameterized import parameterized + +from api.core.permissions import CaseInCaseworkerOperableStatus +from api.applications.tests.factories import StandardApplicationFactory +from api.staticdata.statuses.enums import CaseStatusEnum +from api.staticdata.statuses.models import CaseStatus + +from test_helpers.clients import DataTestClient + + +class TestCaseInCaseworkerOperableStatus(DataTestClient): + + @parameterized.expand(CaseStatusEnum.caseworker_operable_statuses()) + def test_has_permission_caseworker_operable(self, status): + status_record = CaseStatus.objects.get(status=status) + application = StandardApplicationFactory(status=status_record) + mock_view = mock.MagicMock() + mock_view.get_case.return_value = application.get_case() + permission_obj = CaseInCaseworkerOperableStatus() + assert permission_obj.has_permission(None, mock_view) is True + + @parameterized.expand(CaseStatusEnum.caseworker_inoperable_statuses()) + def test_has_permission_caseworker_inoperable(self, status): + status_record = CaseStatus.objects.get(status=status) + application = StandardApplicationFactory(status=status_record) + mock_view = mock.MagicMock() + mock_view.get_case.return_value = application.get_case() + permission_obj = CaseInCaseworkerOperableStatus() + assert permission_obj.has_permission(None, mock_view) is False diff --git a/api/data_workspace/urls.py b/api/data_workspace/urls.py index fcb86a8257..58bbcadd8e 100644 --- a/api/data_workspace/urls.py +++ b/api/data_workspace/urls.py @@ -1,96 +1,15 @@ -from django.urls import path, include -from rest_framework.routers import DefaultRouter - -from api.data_workspace import ( - application_views, - audit_views, - case_views, - good_views, - license_views, - views, - staticdata_views, - external_data_views, - users_views, - advice_views, - address_views, - organisations_views, +from django.urls import ( + include, + path, ) -app_name = "data_workspace" +from api.data_workspace.v0.urls import router_v0 +from api.data_workspace.v1.urls import router_v1 -router_v0 = DefaultRouter() -router_v0.register("licences", license_views.LicencesListDW, basename="dw-licences-only") -router_v0.register("ogl", license_views.OpenGeneralLicenceListDW, basename="dw-ogl-only") -router_v1 = DefaultRouter() -router_v1.register( - "standard-applications", application_views.StandardApplicationListView, basename="dw-standard-applications" -) -router_v1.register( - "good-on-applications", application_views.GoodOnApplicationListView, basename="dw-good-on-applications" -) -router_v1.register( - "good-on-application-control-list-entries", - application_views.GoodOnApplicationControlListEntriesListView, - basename="dw-good-on-applications-control-list-entries", -) -router_v1.register( - "good-on-application-regime-entries", - application_views.GoodOnApplicationRegimeEntriesListView, - basename="dw-good-on-applications-regime-entries", -) -router_v1.register( - "party-on-applications", application_views.PartyOnApplicationListView, basename="dw-party-on-applications" -) -router_v1.register( - "denial-match-on-applications", - application_views.DenialMatchOnApplicationListView, - basename="dw-denial-match-on-applications", -) -router_v1.register( - "control-list-entries", staticdata_views.ControlListEntriesListView, basename="dw-control-list-entries" -) -router_v1.register("countries", staticdata_views.CountriesListView, basename="dw-countries") -router_v1.register("case-statuses", staticdata_views.CaseStatusListView, basename="dw-case-statuses") -router_v1.register("regimes", staticdata_views.RegimesListView, basename="dw-regimes") -router_v1.register("regime-subsections", staticdata_views.RegimeSubsectionsListView, basename="dw-regime-subsections") -router_v1.register("regime-entries", staticdata_views.RegimeEntriesListView, basename="dw-regime-entries") -router_v1.register("goods", good_views.GoodListView, basename="dw-goods") -router_v1.register( - "good-control-list-entries", good_views.GoodControlListEntryListView, basename="dw-good-control-list-entries" -) -router_v1.register("licences", license_views.LicencesList, basename="dw-licences") -router_v1.register("good-on-licences", license_views.GoodOnLicenceList, basename="dw-good-on-licences") -router_v1.register("organisations", views.OrganisationListView, basename="dw-organisations") -router_v1.register("parties", views.PartyListView, basename="dw-parties") -router_v1.register("queues", views.QueueListView, basename="dw-queues") -router_v1.register("teams", views.TeamListView, basename="dw-teams") -router_v1.register("departments", views.DepartmentListView, basename="dw-departments") -router_v1.register("case-assignment", case_views.CaseAssignmentList, basename="dw-case-assignment") -router_v1.register("case-assignment-slas", case_views.CaseAssignmentSLAList, basename="dw-case-assignment-sla") -router_v1.register("case-types", case_views.CaseTypeList, basename="dw-case-type") -router_v1.register("case-queues", case_views.CaseQueueList, basename="dw-case-queue") -router_v1.register("case-department-slas", case_views.CaseDepartmentList, basename="dw-case-department-sla") -router_v1.register("ecju-queries", case_views.EcjuQueryList, basename="dw-ecju-query") -router_v1.register( - "external-data-denials", external_data_views.ExternalDataDenialView, basename="dw-external-data-denial" -) -router_v1.register("users-base-users", users_views.BaseUserListView, basename="dw-users-base-users") -router_v1.register("users-gov-users", users_views.GovUserListView, basename="dw-users-gov-users") -router_v1.register("audit-move-case", audit_views.AuditMoveCaseListView, basename="dw-audit-move-case") -router_v1.register("advice", advice_views.AdviceListView, basename="dw-advice") -router_v1.register( - "advice-denial-reasons", advice_views.AdviceDenialReasonListView, basename="dw-advice-denial-reasons" -) -router_v1.register( - "audit-updated-status", audit_views.AuditUpdatedCaseStatusListView, basename="dw-audit-updated-status" -) -router_v1.register( - "audit-licence-updated-status", - audit_views.AuditUpdatedLicenceStatusListView, - basename="dw-audit-licence-updated-status", -) -router_v1.register("survey-response", views.SurveyResponseListView, basename="dw-survey-reponse") -router_v1.register("address", address_views.AddressView, basename="dw-address") -router_v1.register("site", organisations_views.SiteView, basename="dw-site") -urlpatterns = [path("v0/", include(router_v0.urls)), path("v1/", include(router_v1.urls))] +app_name = "data_workspace" + +urlpatterns = [ + path("v0/", include((router_v0.urls, "data_workspace_v0"), namespace="v0")), + path("v1/", include((router_v1.urls, "data_workspace_v1"), namespace="v1")), +] diff --git a/api/data_workspace/v0/__init__.py b/api/data_workspace/v0/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/data_workspace/license_views.py b/api/data_workspace/v0/licence_views.py similarity index 73% rename from api/data_workspace/license_views.py rename to api/data_workspace/v0/licence_views.py index 4cb8c17785..41eac27795 100644 --- a/api/data_workspace/license_views.py +++ b/api/data_workspace/v0/licence_views.py @@ -4,7 +4,6 @@ from api.cases.enums import CaseTypeEnum from api.core.authentication import DataWorkspaceOnlyAuthentication -from api.data_workspace.serializers import LicenceWithoutGoodsSerializer from api.open_general_licences.models import OpenGeneralLicence from api.open_general_licences.serializers import OpenGeneralLicenceSerializer from api.licences import models, enums @@ -41,17 +40,3 @@ class OpenGeneralLicenceListDW(viewsets.ReadOnlyModelViewSet): .select_related("case_type") .prefetch_related("countries", "control_list_entries") ) - - -class GoodOnLicenceList(viewsets.ReadOnlyModelViewSet): - authentication_classes = (DataWorkspaceOnlyAuthentication,) - serializer_class = serializers.GoodOnLicenceReportsViewSerializer - pagination_class = LimitOffsetPagination - queryset = models.GoodOnLicence.objects.all() - - -class LicencesList(viewsets.ReadOnlyModelViewSet): - authentication_classes = (DataWorkspaceOnlyAuthentication,) - serializer_class = LicenceWithoutGoodsSerializer - pagination_class = LimitOffsetPagination - queryset = models.Licence.objects.all() diff --git a/api/data_workspace/v0/tests/__init__.py b/api/data_workspace/v0/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/data_workspace/v0/tests/test_licence_views.py b/api/data_workspace/v0/tests/test_licence_views.py new file mode 100644 index 0000000000..c3ea182a35 --- /dev/null +++ b/api/data_workspace/v0/tests/test_licence_views.py @@ -0,0 +1,46 @@ +import mohawk + +from django.conf import settings +from django.test import override_settings +from django.urls import reverse +from rest_framework import status +from urllib import parse + +from api.core.requests import get_hawk_sender +from test_helpers.clients import DataTestClient + + +class DataWorkspaceTests(DataTestClient): + def setUp(self): + super().setUp() + test_host = "http://testserver" + self.licences = parse.urljoin(test_host, reverse("data_workspace:v0:dw-licences-only-list")) + self.ogl_list = parse.urljoin(test_host, reverse("data_workspace:v0:dw-ogl-only-list")) + + @override_settings(HAWK_AUTHENTICATION_ENABLED=True) + def test_dw_view_licences(self): + sender = get_hawk_sender("GET", self.licences, None, settings.HAWK_LITE_DATA_WORKSPACE_CREDENTIALS) + self.client.credentials(HTTP_HAWK_AUTHENTICATION=sender.request_header, CONTENT_TYPE="application/json") + response = self.client.get(self.licences) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + @override_settings(HAWK_AUTHENTICATION_ENABLED=True) + def test_dw_view_licences_fail_incorrect_hawk_key(self): + sender = get_hawk_sender("GET", self.licences, None, "internal-frontend") + self.client.credentials(HTTP_HAWK_AUTHENTICATION=sender.request_header, CONTENT_TYPE="application/json") + with self.assertRaises(mohawk.exc.HawkFail): + self.client.get(self.licences) + + @override_settings(HAWK_AUTHENTICATION_ENABLED=True) + def test_dw_view_ogl_types(self): + sender = get_hawk_sender("GET", self.ogl_list, None, settings.HAWK_LITE_DATA_WORKSPACE_CREDENTIALS) + self.client.credentials(HTTP_HAWK_AUTHENTICATION=sender.request_header, CONTENT_TYPE="application/json") + response = self.client.get(self.ogl_list) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + @override_settings(HAWK_AUTHENTICATION_ENABLED=True) + def test_dw_view_ogl_fail_incorrect_hawk_key(self): + sender = get_hawk_sender("GET", self.ogl_list, None, "internal-frontend") + self.client.credentials(HTTP_HAWK_AUTHENTICATION=sender.request_header, CONTENT_TYPE="application/json") + with self.assertRaises(mohawk.exc.HawkFail): + self.client.get(self.ogl_list) diff --git a/api/data_workspace/v0/urls.py b/api/data_workspace/v0/urls.py new file mode 100644 index 0000000000..4a46f86951 --- /dev/null +++ b/api/data_workspace/v0/urls.py @@ -0,0 +1,16 @@ +from rest_framework.routers import DefaultRouter + +from api.data_workspace.v0 import licence_views + + +router_v0 = DefaultRouter() +router_v0.register( + "licences", + licence_views.LicencesListDW, + basename="dw-licences-only", +) +router_v0.register( + "ogl", + licence_views.OpenGeneralLicenceListDW, + basename="dw-ogl-only", +) diff --git a/api/data_workspace/v1/__init__.py b/api/data_workspace/v1/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/data_workspace/address_views.py b/api/data_workspace/v1/address_views.py similarity index 100% rename from api/data_workspace/address_views.py rename to api/data_workspace/v1/address_views.py diff --git a/api/data_workspace/advice_views.py b/api/data_workspace/v1/advice_views.py similarity index 91% rename from api/data_workspace/advice_views.py rename to api/data_workspace/v1/advice_views.py index e47db980c1..980cd439ef 100644 --- a/api/data_workspace/advice_views.py +++ b/api/data_workspace/v1/advice_views.py @@ -4,7 +4,7 @@ from api.applications.serializers.advice import Advice from api.cases.serializers import AdviceSerializer from api.core.authentication import DataWorkspaceOnlyAuthentication -from api.data_workspace.serializers import AdviceDenialReasonSerializer +from api.data_workspace.v1.serializers import AdviceDenialReasonSerializer class AdviceListView(viewsets.ReadOnlyModelViewSet): diff --git a/api/data_workspace/application_views.py b/api/data_workspace/v1/application_views.py similarity index 100% rename from api/data_workspace/application_views.py rename to api/data_workspace/v1/application_views.py diff --git a/api/data_workspace/audit_views.py b/api/data_workspace/v1/audit_views.py similarity index 98% rename from api/data_workspace/audit_views.py rename to api/data_workspace/v1/audit_views.py index 857d00bab7..1dbba3e21e 100644 --- a/api/data_workspace/audit_views.py +++ b/api/data_workspace/v1/audit_views.py @@ -7,7 +7,7 @@ from api.audit_trail.models import Audit from api.cases.models import Case from api.core.authentication import DataWorkspaceOnlyAuthentication -from api.data_workspace.serializers import ( +from api.data_workspace.v1.serializers import ( AuditMoveCaseSerializer, AuditUpdatedCaseStatusSerializer, AuditUpdatedLicenceStatusSerializer, diff --git a/api/data_workspace/case_views.py b/api/data_workspace/v1/case_views.py similarity index 97% rename from api/data_workspace/case_views.py rename to api/data_workspace/v1/case_views.py index ea356497af..9e4d962bbd 100644 --- a/api/data_workspace/case_views.py +++ b/api/data_workspace/v1/case_views.py @@ -8,7 +8,7 @@ CaseTypeSerializer, CaseQueueSerializer, ) -from api.data_workspace.serializers import ( +from api.data_workspace.v1.serializers import ( EcjuQuerySerializer, CaseAssignmentSerializer, DepartmentSLASerializer, diff --git a/api/data_workspace/external_data_views.py b/api/data_workspace/v1/external_data_views.py similarity index 100% rename from api/data_workspace/external_data_views.py rename to api/data_workspace/v1/external_data_views.py diff --git a/api/data_workspace/good_views.py b/api/data_workspace/v1/good_views.py similarity index 100% rename from api/data_workspace/good_views.py rename to api/data_workspace/v1/good_views.py diff --git a/api/data_workspace/v1/licence_views.py b/api/data_workspace/v1/licence_views.py new file mode 100644 index 0000000000..8d2c5d235d --- /dev/null +++ b/api/data_workspace/v1/licence_views.py @@ -0,0 +1,21 @@ +from rest_framework import viewsets +from rest_framework.pagination import LimitOffsetPagination + +from api.core.authentication import DataWorkspaceOnlyAuthentication +from api.data_workspace.v1.serializers import LicenceWithoutGoodsSerializer +from api.licences import models +from api.licences.serializers import view_licence as serializers + + +class GoodOnLicenceList(viewsets.ReadOnlyModelViewSet): + authentication_classes = (DataWorkspaceOnlyAuthentication,) + serializer_class = serializers.GoodOnLicenceReportsViewSerializer + pagination_class = LimitOffsetPagination + queryset = models.GoodOnLicence.objects.all() + + +class LicencesList(viewsets.ReadOnlyModelViewSet): + authentication_classes = (DataWorkspaceOnlyAuthentication,) + serializer_class = LicenceWithoutGoodsSerializer + pagination_class = LimitOffsetPagination + queryset = models.Licence.objects.all() diff --git a/api/data_workspace/organisations_views.py b/api/data_workspace/v1/organisations_views.py similarity index 100% rename from api/data_workspace/organisations_views.py rename to api/data_workspace/v1/organisations_views.py diff --git a/api/data_workspace/serializers.py b/api/data_workspace/v1/serializers.py similarity index 100% rename from api/data_workspace/serializers.py rename to api/data_workspace/v1/serializers.py diff --git a/api/data_workspace/staticdata_views.py b/api/data_workspace/v1/staticdata_views.py similarity index 100% rename from api/data_workspace/staticdata_views.py rename to api/data_workspace/v1/staticdata_views.py diff --git a/api/data_workspace/v1/tests/__init__.py b/api/data_workspace/v1/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/data_workspace/tests/test_address_views.py b/api/data_workspace/v1/tests/test_address_views.py similarity index 90% rename from api/data_workspace/tests/test_address_views.py rename to api/data_workspace/v1/tests/test_address_views.py index 1bc1282146..65de6a66f0 100644 --- a/api/data_workspace/tests/test_address_views.py +++ b/api/data_workspace/v1/tests/test_address_views.py @@ -5,7 +5,7 @@ class AddressDataWorkspaceTests(DataTestClient): def test_addresses(self): - url = reverse("data_workspace:dw-address-list") + url = reverse("data_workspace:v1:dw-address-list") expected_fields = {"id", "address_line_1", "address_line_2", "city", "region", "postcode", "country"} response = self.client.get(url) diff --git a/api/data_workspace/tests/test_advice_views.py b/api/data_workspace/v1/tests/test_advice_views.py similarity index 91% rename from api/data_workspace/tests/test_advice_views.py rename to api/data_workspace/v1/tests/test_advice_views.py index e4f46fda18..ad50614e2b 100644 --- a/api/data_workspace/tests/test_advice_views.py +++ b/api/data_workspace/v1/tests/test_advice_views.py @@ -1,6 +1,6 @@ from django.urls import reverse -from api.cases.enums import AdviceType, AdviceLevel +from api.cases.enums import AdviceType from api.cases.tests.factories import FinalAdviceFactory from test_helpers.clients import DataTestClient @@ -12,7 +12,7 @@ def setUp(self): FinalAdviceFactory(user=self.gov_user, case=self.standard_application, type=AdviceType.APPROVE) def test_advice(self): - url = reverse("data_workspace:dw-advice-list") + url = reverse("data_workspace:v1:dw-advice-list") response = self.client.get(url) payload = response.json() last_result = payload["results"][-1] @@ -56,7 +56,7 @@ def setUp(self): FinalAdviceFactory(user=self.gov_user, case=self.standard_application, type=AdviceType.REFUSE) def test_advice_denial_reason(self): - url = reverse("data_workspace:dw-advice-denial-reasons-list") + url = reverse("data_workspace:v1:dw-advice-denial-reasons-list") response = self.client.get(url) payload = response.json() last_result = payload["results"][-1] diff --git a/api/data_workspace/tests/test_application_views.py b/api/data_workspace/v1/tests/test_applications_views.py similarity index 93% rename from api/data_workspace/tests/test_application_views.py rename to api/data_workspace/v1/tests/test_applications_views.py index 0f46b72825..5cad8e44d2 100644 --- a/api/data_workspace/tests/test_application_views.py +++ b/api/data_workspace/v1/tests/test_applications_views.py @@ -17,14 +17,18 @@ class DataWorkspaceApplicationViewTests(DataTestClient): def setUp(self): super().setUp() test_host = "http://testserver" - self.standard_applications = parse.urljoin(test_host, reverse("data_workspace:dw-standard-applications-list")) - self.good_on_applications = parse.urljoin(test_host, reverse("data_workspace:dw-good-on-applications-list")) + self.standard_applications = parse.urljoin( + test_host, reverse("data_workspace:v1:dw-standard-applications-list") + ) + self.good_on_applications = parse.urljoin(test_host, reverse("data_workspace:v1:dw-good-on-applications-list")) self.good_on_applications_clc_entries = parse.urljoin( - test_host, reverse("data_workspace:dw-good-on-applications-control-list-entries-list") + test_host, reverse("data_workspace:v1:dw-good-on-applications-control-list-entries-list") + ) + self.party_on_applications = parse.urljoin( + test_host, reverse("data_workspace:v1:dw-party-on-applications-list") ) - self.party_on_applications = parse.urljoin(test_host, reverse("data_workspace:dw-party-on-applications-list")) self.denial_on_applications = parse.urljoin( - test_host, reverse("data_workspace:dw-denial-match-on-applications-list") + test_host, reverse("data_workspace:v1:dw-denial-match-on-applications-list") ) def test_dw_standard_application_views(self): diff --git a/api/data_workspace/tests/test_audit_views.py b/api/data_workspace/v1/tests/test_audit_views.py similarity index 95% rename from api/data_workspace/tests/test_audit_views.py rename to api/data_workspace/v1/tests/test_audit_views.py index 40cd9520db..49259190e8 100644 --- a/api/data_workspace/tests/test_audit_views.py +++ b/api/data_workspace/v1/tests/test_audit_views.py @@ -13,7 +13,7 @@ class DataWorkspaceAuditMoveCaseTests(DataTestClient): def setUp(self): super().setUp() - self.url = reverse("data_workspace:dw-audit-move-case-list") + self.url = reverse("data_workspace:v1:dw-audit-move-case-list") case = self.create_standard_application_case(self.organisation, "Test Application") # Audit events are only created for non-draft cases case.status = get_case_status_by_status(CaseStatusEnum.OPEN) @@ -62,7 +62,7 @@ def test_payload_multiple_queues(self): class DataWorkspaceAuditUpdatedCaseStatusTests(DataTestClient): def setUp(self): super().setUp() - self.url = reverse("data_workspace:dw-audit-updated-status-list") + self.url = reverse("data_workspace:v1:dw-audit-updated-status-list") case = self.create_standard_application_case(self.organisation, "Test Application") # Audit events are only created for non-draft cases case.status = get_case_status_by_status(CaseStatusEnum.OPEN) @@ -95,7 +95,7 @@ def test_audit_updated_status(self): class DataWorkspaceAuditUpdatedLicenceStatusTests(DataTestClient): def setUp(self): super().setUp() - self.url = reverse("data_workspace:dw-audit-licence-updated-status-list") + self.url = reverse("data_workspace:v1:dw-audit-licence-updated-status-list") case = self.create_standard_application_case(self.organisation, "Test Application") licence = StandardLicenceFactory(case=case, status=LicenceStatus.ISSUED) diff --git a/api/data_workspace/tests/test_case_views.py b/api/data_workspace/v1/tests/test_case_views.py similarity index 88% rename from api/data_workspace/tests/test_case_views.py rename to api/data_workspace/v1/tests/test_case_views.py index 49fbf69540..7b41595dc9 100644 --- a/api/data_workspace/tests/test_case_views.py +++ b/api/data_workspace/v1/tests/test_case_views.py @@ -16,16 +16,11 @@ def setUp(self): CaseAssignmentSLA.objects.create(sla_days=4, queue=self.queue, case=self.case) # Create CaseAssignment - user = GovUserFactory( - baseuser_ptr__email="john@dov.uk", - baseuser_ptr__first_name="John", - baseuser_ptr__last_name="Conam", - team=self.team, - ) + user = GovUserFactory(team=self.team) CaseAssignment.objects.create(queue=self.queue, case=self.case, user=user) def test_case_assignment(self): - url = reverse("data_workspace:dw-case-assignment-list") + url = reverse("data_workspace:v1:dw-case-assignment-list") expected_fields = {"user", "case", "id", "queue", "created_at", "updated_at"} response = self.client.options(url) @@ -35,7 +30,7 @@ def test_case_assignment(self): self.assertEqual(set(actions_get.keys()), expected_fields) def test_case_assignment_slas(self): - url = reverse("data_workspace:dw-case-assignment-sla-list") + url = reverse("data_workspace:v1:dw-case-assignment-sla-list") expected_fields = ("id", "sla_days", "queue", "case") response = self.client.get(url) @@ -50,7 +45,7 @@ def test_case_assignment_slas(self): self.assertEqual(tuple(options.keys()), expected_fields) def test_case_types(self): - url = reverse("data_workspace:dw-case-type-list") + url = reverse("data_workspace:v1:dw-case-type-list") expected_fields = ("id", "reference", "type", "sub_type") response = self.client.get(url) @@ -65,7 +60,7 @@ def test_case_types(self): self.assertEqual(tuple(options.keys()), expected_fields) def test_case_queues(self): - url = reverse("data_workspace:dw-case-queue-list") + url = reverse("data_workspace:v1:dw-case-queue-list") expected_fields = ("id", "created_at", "updated_at", "case", "queue") response = self.client.get(url) @@ -81,7 +76,7 @@ def test_case_queues(self): def test_ecju_queries(self): EcjuQueryFactory() - url = reverse("data_workspace:dw-ecju-query-list") + url = reverse("data_workspace:v1:dw-ecju-query-list") expected_fields = { "raised_by_user", "id", diff --git a/api/data_workspace/tests/test_external_data_views.py b/api/data_workspace/v1/tests/test_external_data_views.py similarity index 95% rename from api/data_workspace/tests/test_external_data_views.py rename to api/data_workspace/v1/tests/test_external_data_views.py index 3032211a25..0c819d32a3 100644 --- a/api/data_workspace/tests/test_external_data_views.py +++ b/api/data_workspace/v1/tests/test_external_data_views.py @@ -9,7 +9,7 @@ class DataWorkspaceExternalDataViewTests(DataTestClient): def setUp(self): super().setUp() test_host = "http://testserver" - self.denial_external_data = parse.urljoin(test_host, reverse("data_workspace:dw-external-data-denial-list")) + self.denial_external_data = parse.urljoin(test_host, reverse("data_workspace:v1:dw-external-data-denial-list")) def test_denial_view(self): response = self.client.options(self.denial_external_data) diff --git a/api/data_workspace/tests/test_good_views.py b/api/data_workspace/v1/tests/test_good_views.py similarity index 95% rename from api/data_workspace/tests/test_good_views.py rename to api/data_workspace/v1/tests/test_good_views.py index cb4771dbb4..353d413ae1 100644 --- a/api/data_workspace/tests/test_good_views.py +++ b/api/data_workspace/v1/tests/test_good_views.py @@ -15,7 +15,7 @@ def setUp(self): @override_settings(HAWK_AUTHENTICATION_ENABLED=False) def test_goods(self): - url = reverse("data_workspace:dw-goods-list") + url = reverse("data_workspace:v1:dw-goods-list") expected_fields = set( [ "id", @@ -68,7 +68,7 @@ def test_goods(self): def test_good_control_list_entry(self): clc_entry = ControlListEntry.objects.first() GoodControlListEntry.objects.create(good=self.good, controllistentry=clc_entry) - url = reverse("data_workspace:dw-good-control-list-entries-list") + url = reverse("data_workspace:v1:dw-good-control-list-entries-list") expected_fields = ("id", "good", "controllistentry") response = self.client.get(url) diff --git a/api/data_workspace/tests/test_license_views.py b/api/data_workspace/v1/tests/test_licence_views.py similarity index 53% rename from api/data_workspace/tests/test_license_views.py rename to api/data_workspace/v1/tests/test_licence_views.py index 0111078fa0..facc22025c 100644 --- a/api/data_workspace/tests/test_license_views.py +++ b/api/data_workspace/v1/tests/test_licence_views.py @@ -1,12 +1,6 @@ -import mohawk - -from django.conf import settings -from django.test import override_settings from django.urls import reverse from rest_framework import status -from urllib import parse -from api.core.requests import get_hawk_sender from api.applications.tests.factories import GoodOnApplicationFactory from api.cases.tests.factories import FinalAdviceFactory from api.cases.enums import AdviceType @@ -18,9 +12,6 @@ class DataWorkspaceTests(DataTestClient): def setUp(self): super().setUp() - test_host = "http://testserver" - self.licences = parse.urljoin(test_host, reverse("data_workspace:dw-licences-only-list")) - self.ogl_list = parse.urljoin(test_host, reverse("data_workspace:dw-ogl-only-list")) # Set up fixtures for testing. case = self.create_standard_application_case(self.organisation) good = GoodFactory( @@ -28,9 +19,7 @@ def setUp(self): is_good_controlled=True, control_list_entries=["ML21"], ) - good_advice = FinalAdviceFactory( - user=self.gov_user, team=self.team, case=case, good=good, type=AdviceType.APPROVE - ) + FinalAdviceFactory(user=self.gov_user, team=self.team, case=case, good=good, type=AdviceType.APPROVE) GoodOnLicenceFactory( good=GoodOnApplicationFactory(application=case, good=good), licence=StandardLicenceFactory(case=case), @@ -38,36 +27,8 @@ def setUp(self): value=1, ) - @override_settings(HAWK_AUTHENTICATION_ENABLED=True) - def test_dw_view_licences(self): - sender = get_hawk_sender("GET", self.licences, None, settings.HAWK_LITE_DATA_WORKSPACE_CREDENTIALS) - self.client.credentials(HTTP_HAWK_AUTHENTICATION=sender.request_header, CONTENT_TYPE="application/json") - response = self.client.get(self.licences) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - @override_settings(HAWK_AUTHENTICATION_ENABLED=True) - def test_dw_view_licences_fail_incorrect_hawk_key(self): - sender = get_hawk_sender("GET", self.licences, None, "internal-frontend") - self.client.credentials(HTTP_HAWK_AUTHENTICATION=sender.request_header, CONTENT_TYPE="application/json") - with self.assertRaises(mohawk.exc.HawkFail): - self.client.get(self.licences) - - @override_settings(HAWK_AUTHENTICATION_ENABLED=True) - def test_dw_view_ogl_types(self): - sender = get_hawk_sender("GET", self.ogl_list, None, settings.HAWK_LITE_DATA_WORKSPACE_CREDENTIALS) - self.client.credentials(HTTP_HAWK_AUTHENTICATION=sender.request_header, CONTENT_TYPE="application/json") - response = self.client.get(self.ogl_list) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - @override_settings(HAWK_AUTHENTICATION_ENABLED=True) - def test_dw_view_ogl_fail_incorrect_hawk_key(self): - sender = get_hawk_sender("GET", self.ogl_list, None, "internal-frontend") - self.client.credentials(HTTP_HAWK_AUTHENTICATION=sender.request_header, CONTENT_TYPE="application/json") - with self.assertRaises(mohawk.exc.HawkFail): - self.client.get(self.ogl_list) - def test_good_on_licenses(self): - url = reverse("data_workspace:dw-good-on-licences-list") + url = reverse("data_workspace:v1:dw-good-on-licences-list") expected_fields = ( "good_on_application_id", "usage", @@ -99,7 +60,7 @@ def test_good_on_licenses(self): self.assertEqual(tuple(options.keys()), expected_fields) def test_licenses(self): - url = reverse("data_workspace:dw-licences-list") + url = reverse("data_workspace:v1:dw-licences-list") expected_fields = ("id", "reference_code", "status", "application", "goods") response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/api/data_workspace/tests/test_organisations_views.py b/api/data_workspace/v1/tests/test_organisation_views.py similarity index 92% rename from api/data_workspace/tests/test_organisations_views.py rename to api/data_workspace/v1/tests/test_organisation_views.py index c081b88bc4..18b174f15f 100644 --- a/api/data_workspace/tests/test_organisations_views.py +++ b/api/data_workspace/v1/tests/test_organisation_views.py @@ -6,7 +6,7 @@ class OrganisationsDataWorkspaceTests(DataTestClient): def test_site(self): - url = reverse("data_workspace:dw-site-list") + url = reverse("data_workspace:v1:dw-site-list") expected_fields = { "id", "name", diff --git a/api/data_workspace/tests/test_serializers.py b/api/data_workspace/v1/tests/test_serializers.py similarity index 98% rename from api/data_workspace/tests/test_serializers.py rename to api/data_workspace/v1/tests/test_serializers.py index 22e72160cd..b43fa763fd 100644 --- a/api/data_workspace/tests/test_serializers.py +++ b/api/data_workspace/v1/tests/test_serializers.py @@ -1,6 +1,6 @@ from api.audit_trail.models import AuditType from api.audit_trail.tests.factories import AuditFactory -from api.data_workspace.serializers import ( +from api.data_workspace.v1.serializers import ( AuditMoveCaseSerializer, CaseAssignmentSerializer, EcjuQuerySerializer, diff --git a/api/data_workspace/tests/test_staticdata_views.py b/api/data_workspace/v1/tests/test_staticdata_views.py similarity index 83% rename from api/data_workspace/tests/test_staticdata_views.py rename to api/data_workspace/v1/tests/test_staticdata_views.py index ebd512eab4..241efa0208 100644 --- a/api/data_workspace/tests/test_staticdata_views.py +++ b/api/data_workspace/v1/tests/test_staticdata_views.py @@ -6,7 +6,7 @@ class DataWorkspaceTests(DataTestClient): def test_control_list_entries(self): - url = reverse("data_workspace:dw-control-list-entries-list") + url = reverse("data_workspace:v1:dw-control-list-entries-list") expected_fields = ("id", "rating", "text", "category", "controlled", "parent") response = self.client.get(url) @@ -21,7 +21,7 @@ def test_control_list_entries(self): self.assertEqual(tuple(options.keys()), expected_fields) def test_countries(self): - url = reverse("data_workspace:dw-countries-list") + url = reverse("data_workspace:v1:dw-countries-list") expected_fields = ("id", "name", "type", "is_eu", "report_name") response = self.client.get(url) @@ -36,8 +36,19 @@ def test_countries(self): self.assertEqual(tuple(options.keys()), expected_fields) def test_case_statuses(self): - url = reverse("data_workspace:dw-case-statuses-list") - expected_fields = ("id", "key", "value", "status", "priority") + url = reverse("data_workspace:v1:dw-case-statuses-list") + expected_fields = ( + "id", + "key", + "value", + "status", + "priority", + "is_terminal", + "is_read_only", + "is_major_editable", + "can_invoke_major_editable", + "is_caseworker_operable", + ) response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -51,7 +62,7 @@ def test_case_statuses(self): self.assertEqual(tuple(options.keys()), expected_fields) def test_regimes(self): - url = reverse("data_workspace:dw-regimes-list") + url = reverse("data_workspace:v1:dw-regimes-list") expected_fields = ("id", "name") response = self.client.get(url) @@ -66,7 +77,7 @@ def test_regimes(self): self.assertEqual(tuple(options.keys()), expected_fields) def test_regime_subsections(self): - url = reverse("data_workspace:dw-regime-subsections-list") + url = reverse("data_workspace:v1:dw-regime-subsections-list") expected_fields = ("id", "name", "regime") response = self.client.get(url) @@ -81,7 +92,7 @@ def test_regime_subsections(self): self.assertEqual(tuple(options.keys()), expected_fields) def test_regime_entries(self): - url = reverse("data_workspace:dw-regime-entries-list") + url = reverse("data_workspace:v1:dw-regime-entries-list") expected_fields = ("id", "name", "shortened_name", "subsection") response = self.client.get(url) diff --git a/api/data_workspace/tests/test_users_views.py b/api/data_workspace/v1/tests/test_users_views.py similarity index 94% rename from api/data_workspace/tests/test_users_views.py rename to api/data_workspace/v1/tests/test_users_views.py index 2e32eadf29..b508f1c22a 100644 --- a/api/data_workspace/tests/test_users_views.py +++ b/api/data_workspace/v1/tests/test_users_views.py @@ -9,8 +9,8 @@ class DataWorkspaceApplicationViewTests(DataTestClient): def setUp(self): super().setUp() test_host = "http://testserver" - self.users_base_users = parse.urljoin(test_host, reverse("data_workspace:dw-users-base-users-list")) - self.users_gov_users = parse.urljoin(test_host, reverse("data_workspace:dw-users-gov-users-list")) + self.users_base_users = parse.urljoin(test_host, reverse("data_workspace:v1:dw-users-base-users-list")) + self.users_gov_users = parse.urljoin(test_host, reverse("data_workspace:v1:dw-users-gov-users-list")) def test_dw_users_base_users(self): response = self.client.options(self.users_base_users) diff --git a/api/data_workspace/tests/test_views.py b/api/data_workspace/v1/tests/test_views.py similarity index 93% rename from api/data_workspace/tests/test_views.py rename to api/data_workspace/v1/tests/test_views.py index 0470c5f9b1..65a52cc307 100644 --- a/api/data_workspace/tests/test_views.py +++ b/api/data_workspace/v1/tests/test_views.py @@ -26,7 +26,7 @@ def setUp(self): ) def test_organisations(self): - url = reverse("data_workspace:dw-organisations-list") + url = reverse("data_workspace:v1:dw-organisations-list") expected_fields = ( "id", "primary_site", @@ -56,7 +56,7 @@ def test_organisations(self): self.assertEqual(tuple(options.keys()), expected_fields) def test_parties(self): - url = reverse("data_workspace:dw-parties-list") + url = reverse("data_workspace:v1:dw-parties-list") expected_fields = ( "id", "created_at", @@ -98,7 +98,7 @@ def test_parties(self): self.assertEqual(tuple(options.keys()), expected_fields) def test_queues(self): - url = reverse("data_workspace:dw-queues-list") + url = reverse("data_workspace:v1:dw-queues-list") expected_fields = ("id", "name", "team", "countersigning_queue") response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -112,7 +112,7 @@ def test_queues(self): self.assertEqual(tuple(options.keys()), expected_fields) def test_teams(self): - url = reverse("data_workspace:dw-teams-list") + url = reverse("data_workspace:v1:dw-teams-list") expected_fields = ("id", "name", "alias", "part_of_ecju", "is_ogd") response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -127,7 +127,7 @@ def test_teams(self): def test_departments(self): team = TeamFactory() - url = reverse("data_workspace:dw-departments-list") + url = reverse("data_workspace:v1:dw-departments-list") response = self.client.get(url) payload = response.json() @@ -143,7 +143,7 @@ def test_departments(self): def test_case_department_slas(self): department_sla = DepartmentSLAFactory() - url = reverse("data_workspace:dw-case-department-sla-list") + url = reverse("data_workspace:v1:dw-case-department-sla-list") response = self.client.get(url) payload = response.json() last_result = payload["results"][-1] @@ -158,7 +158,7 @@ def test_case_department_slas(self): assert last_result["department"] == str(department_sla.department.id) def test_survey_response(self): - url = reverse("data_workspace:dw-survey-reponse-list") + url = reverse("data_workspace:v1:dw-survey-reponse-list") response = self.client.get(url) payload = response.json() diff --git a/api/data_workspace/v1/urls.py b/api/data_workspace/v1/urls.py new file mode 100644 index 0000000000..50102c85d7 --- /dev/null +++ b/api/data_workspace/v1/urls.py @@ -0,0 +1,89 @@ +from rest_framework.routers import DefaultRouter + +from api.data_workspace.v1 import ( + address_views, + advice_views, + application_views, + audit_views, + case_views, + external_data_views, + good_views, + licence_views, + organisations_views, + staticdata_views, + users_views, + views, +) + + +router_v1 = DefaultRouter() +router_v1.register( + "standard-applications", application_views.StandardApplicationListView, basename="dw-standard-applications" +) +router_v1.register( + "good-on-applications", application_views.GoodOnApplicationListView, basename="dw-good-on-applications" +) +router_v1.register( + "good-on-application-control-list-entries", + application_views.GoodOnApplicationControlListEntriesListView, + basename="dw-good-on-applications-control-list-entries", +) +router_v1.register( + "good-on-application-regime-entries", + application_views.GoodOnApplicationRegimeEntriesListView, + basename="dw-good-on-applications-regime-entries", +) +router_v1.register( + "party-on-applications", application_views.PartyOnApplicationListView, basename="dw-party-on-applications" +) +router_v1.register( + "denial-match-on-applications", + application_views.DenialMatchOnApplicationListView, + basename="dw-denial-match-on-applications", +) +router_v1.register( + "control-list-entries", staticdata_views.ControlListEntriesListView, basename="dw-control-list-entries" +) +router_v1.register("countries", staticdata_views.CountriesListView, basename="dw-countries") +router_v1.register("case-statuses", staticdata_views.CaseStatusListView, basename="dw-case-statuses") +router_v1.register("regimes", staticdata_views.RegimesListView, basename="dw-regimes") +router_v1.register("regime-subsections", staticdata_views.RegimeSubsectionsListView, basename="dw-regime-subsections") +router_v1.register("regime-entries", staticdata_views.RegimeEntriesListView, basename="dw-regime-entries") +router_v1.register("goods", good_views.GoodListView, basename="dw-goods") +router_v1.register( + "good-control-list-entries", good_views.GoodControlListEntryListView, basename="dw-good-control-list-entries" +) +router_v1.register("licences", licence_views.LicencesList, basename="dw-licences") +router_v1.register("good-on-licences", licence_views.GoodOnLicenceList, basename="dw-good-on-licences") +router_v1.register("organisations", views.OrganisationListView, basename="dw-organisations") +router_v1.register("parties", views.PartyListView, basename="dw-parties") +router_v1.register("queues", views.QueueListView, basename="dw-queues") +router_v1.register("teams", views.TeamListView, basename="dw-teams") +router_v1.register("departments", views.DepartmentListView, basename="dw-departments") +router_v1.register("case-assignment", case_views.CaseAssignmentList, basename="dw-case-assignment") +router_v1.register("case-assignment-slas", case_views.CaseAssignmentSLAList, basename="dw-case-assignment-sla") +router_v1.register("case-types", case_views.CaseTypeList, basename="dw-case-type") +router_v1.register("case-queues", case_views.CaseQueueList, basename="dw-case-queue") +router_v1.register("case-department-slas", case_views.CaseDepartmentList, basename="dw-case-department-sla") +router_v1.register("ecju-queries", case_views.EcjuQueryList, basename="dw-ecju-query") +router_v1.register( + "external-data-denials", external_data_views.ExternalDataDenialView, basename="dw-external-data-denial" +) +router_v1.register("users-base-users", users_views.BaseUserListView, basename="dw-users-base-users") +router_v1.register("users-gov-users", users_views.GovUserListView, basename="dw-users-gov-users") +router_v1.register("audit-move-case", audit_views.AuditMoveCaseListView, basename="dw-audit-move-case") +router_v1.register("advice", advice_views.AdviceListView, basename="dw-advice") +router_v1.register( + "advice-denial-reasons", advice_views.AdviceDenialReasonListView, basename="dw-advice-denial-reasons" +) +router_v1.register( + "audit-updated-status", audit_views.AuditUpdatedCaseStatusListView, basename="dw-audit-updated-status" +) +router_v1.register( + "audit-licence-updated-status", + audit_views.AuditUpdatedLicenceStatusListView, + basename="dw-audit-licence-updated-status", +) +router_v1.register("survey-response", views.SurveyResponseListView, basename="dw-survey-reponse") +router_v1.register("address", address_views.AddressView, basename="dw-address") +router_v1.register("site", organisations_views.SiteView, basename="dw-site") diff --git a/api/data_workspace/users_views.py b/api/data_workspace/v1/users_views.py similarity index 100% rename from api/data_workspace/users_views.py rename to api/data_workspace/v1/users_views.py diff --git a/api/data_workspace/views.py b/api/data_workspace/v1/views.py similarity index 96% rename from api/data_workspace/views.py rename to api/data_workspace/v1/views.py index 8541a84500..65a7ff30a3 100644 --- a/api/data_workspace/views.py +++ b/api/data_workspace/v1/views.py @@ -10,7 +10,7 @@ from api.queues.serializers import QueueListSerializer from api.teams.models import Team, Department from api.teams.serializers import TeamReadOnlySerializer -from api.data_workspace.serializers import DepartmentSerializer, SurveyResponseSerializer +from api.data_workspace.v1.serializers import DepartmentSerializer, SurveyResponseSerializer from api.survey.models import SurveyResponse diff --git a/api/parties/models.py b/api/parties/models.py index 97bd2f1718..7167443ba4 100644 --- a/api/parties/models.py +++ b/api/parties/models.py @@ -10,6 +10,7 @@ from api.organisations.models import Organisation from api.parties.enums import PartyType, SubType, PartyRole, PartyDocumentType from api.staticdata.countries.models import Country +import reversion class PartyManager(models.Manager): @@ -45,6 +46,7 @@ def copy_detail(self, pk): return values +@reversion.register() class Party(TimestampableModel, Clonable): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) name = models.TextField(default="", blank=True) diff --git a/api/parties/tests/test_models.py b/api/parties/tests/test_models.py index 4fda8124da..63345e0b00 100644 --- a/api/parties/tests/test_models.py +++ b/api/parties/tests/test_models.py @@ -6,13 +6,17 @@ from api.parties.tests.factories import PartyFactory, PartyDocumentFactory from api.staticdata.countries.models import Country +from reversion.models import Version +from reversion import revisions as reversion + from test_helpers.clients import DataTestClient class TestParty(DataTestClient): - def test_clone(self): - original_party = PartyFactory( + def setUp(self): + super().setUp() + self.original_party = PartyFactory( name="some party", address="123 fake st", country=Country.objects.get(id="FR"), @@ -37,22 +41,26 @@ def test_clone(self): email="some.email@example.net", # /PS-IGNORE details="some details", ) - original_party.flags.add(Flag.objects.first()) - original_party_safe_document = PartyDocumentFactory(party=original_party, s3_key="some safe key", safe=True) + self.original_party.flags.add(Flag.objects.first()) + + def test_clone(self): + original_party_safe_document = PartyDocumentFactory( + party=self.original_party, s3_key="some safe key", safe=True + ) original_party_unsafe_document = PartyDocumentFactory( - party=original_party, s3_key="some unsafe key", safe=False + party=self.original_party, s3_key="some unsafe key", safe=False ) - cloned_party = original_party.clone() - assert original_party.id != cloned_party.id + cloned_party = self.original_party.clone() + assert self.original_party.id != cloned_party.id assert model_to_dict(cloned_party) == { "name": "some party", "address": "123 fake st", - "country": original_party.country.id, + "country": self.original_party.country.id, "website": "https://www.example.com/foo", "signatory_name_euu": "some signatory name", "type": "end_user", - "organisation": original_party.organisation.id, + "organisation": self.original_party.organisation.id, "role": "intermediate_consignee", "role_other": "some other role", "sub_type": "government", @@ -65,7 +73,7 @@ def test_clone(self): "ec3_missing_reason": "some reason", "clearance_level": "uk_official", "descriptors": "some descriptors", - "copy_of": original_party.id, + "copy_of": self.original_party.id, "phone_number": "12345", "email": "some.email@example.net", # /PS-IGNORE "details": "some details", @@ -79,6 +87,43 @@ def test_clone(self): "some safe key" ] + def test_reversion_creation(self): + versions = Version.objects.get_for_object(self.original_party) + self.assertEqual(versions.count(), 0) + + with reversion.create_revision(): + self.original_party.name = "Updated Name" + self.original_party.save() + reversion.set_comment("Updated name to 'Updated Name'") + + versions = Version.objects.get_for_object(self.original_party) + self.assertEqual(versions.count(), 1) + + version = versions.first() + self.assertEqual(version.revision.comment, "Updated name to 'Updated Name'") + + version_data = version.field_dict + self.assertEqual(version_data["name"], "Updated Name") + + def test_revert_to_previous_version(self): + with reversion.create_revision(): + self.original_party.name = "Updated Name" + self.original_party.save() + reversion.set_comment("Updated name to 'Updated Name'") + + with reversion.create_revision(): + self.original_party.name = "Final Name" + self.original_party.save() + reversion.set_comment("Updated name to 'Final Name'") + + versions = Version.objects.get_for_object(self.original_party) + self.assertEqual(versions.count(), 2) + + versions.last().revision.revert() + + self.original_party.refresh_from_db() + self.assertEqual(self.original_party.name, "Updated Name") + class TestPartyDocument(DataTestClient): diff --git a/api/staticdata/statuses/enums.py b/api/staticdata/statuses/enums.py index eef126f70e..4fb7f5af95 100644 --- a/api/staticdata/statuses/enums.py +++ b/api/staticdata/statuses/enums.py @@ -60,6 +60,43 @@ class CaseStatusEnum: SUPERSEDED_BY_EXPORTER_EDIT, ] + # Cases with these statuses can be operated upon by caseworkers + _caseworker_operable_statuses = [ + APPEAL_FINAL_REVIEW, + APPEAL_REVIEW, + APPLICANT_EDITING, + CHANGE_INTIAL_REVIEW, + CHANGE_UNDER_FINAL_REVIEW, + CHANGE_UNDER_REVIEW, + CLC, + OPEN, + UNDER_INTERNAL_REVIEW, + RETURN_TO_INSPECTOR, + AWAITING_EXPORTER_RESPONSE, + CLOSED, + DEREGISTERED, + FINALISED, + INITIAL_CHECKS, + PV, + REGISTERED, + REOPENED_FOR_CHANGES, + REOPENED_DUE_TO_ORG_CHANGES, + RESUBMITTED, + REVOKED, + OGD_ADVICE, + SUBMITTED, + SURRENDERED, + SUSPENDED, + UNDER_APPEAL, + UNDER_ECJU_REVIEW, + UNDER_FINAL_REVIEW, + UNDER_REVIEW, + WITHDRAWN, + OGD_CONSOLIDATION, + FINAL_REVIEW_COUNTERSIGN, + FINAL_REVIEW_SECOND_COUNTERSIGN, + ] + goods_query_statuses = [CLC, PV] clc_statuses = [SUBMITTED, CLOSED, WITHDRAWN] @@ -192,6 +229,10 @@ def is_terminal(cls, status): def is_system_status(cls, status): return status in cls._system_status + @classmethod + def is_caseworker_operable(cls, status): + return status in cls._caseworker_operable_statuses + @classmethod def read_only_statuses(cls): return list(set(cls.all()) - set(cls._writeable_statuses)) @@ -200,6 +241,14 @@ def read_only_statuses(cls): def major_editable_statuses(cls): return cls._major_editable_statuses + @classmethod + def caseworker_operable_statuses(cls): + return cls._caseworker_operable_statuses + + @classmethod + def caseworker_inoperable_statuses(cls): + return list(set(CaseStatusEnum.all()) - set(cls._caseworker_operable_statuses)) + @classmethod def is_major_editable_status(cls, status): return status in cls._major_editable_statuses diff --git a/api/staticdata/statuses/models.py b/api/staticdata/statuses/models.py index c6a950274d..0567d34095 100644 --- a/api/staticdata/statuses/models.py +++ b/api/staticdata/statuses/models.py @@ -49,6 +49,10 @@ def is_major_editable(self): def can_invoke_major_editable(self): return CaseStatusEnum.can_invoke_major_edit(self.status) + @property + def is_caseworker_operable(self): + return CaseStatusEnum.is_caseworker_operable(self.status) + def natural_key(self): return (self.status,) diff --git a/api/staticdata/statuses/serializers.py b/api/staticdata/statuses/serializers.py index 8d854bd163..a8fffb9fb3 100644 --- a/api/staticdata/statuses/serializers.py +++ b/api/staticdata/statuses/serializers.py @@ -25,6 +25,11 @@ class Meta: "value", "status", "priority", + "is_terminal", + "is_read_only", + "is_major_editable", + "can_invoke_major_editable", + "is_caseworker_operable", ) diff --git a/api/staticdata/statuses/tests/test_enums.py b/api/staticdata/statuses/tests/test_enums.py new file mode 100644 index 0000000000..d0478a5896 --- /dev/null +++ b/api/staticdata/statuses/tests/test_enums.py @@ -0,0 +1,14 @@ +from parameterized import parameterized + +from api.staticdata.statuses.enums import CaseStatusEnum + + +class TestCaseStatusEnum: + + @parameterized.expand(CaseStatusEnum.caseworker_operable_statuses()) + def test_is_caseworker_operable_operable_status_status_operable(self, status): + assert CaseStatusEnum.is_caseworker_operable(status) is True + + @parameterized.expand(CaseStatusEnum.caseworker_inoperable_statuses()) + def test_is_caseworker_operable_operable_status_status_inoperable(self, status): + assert CaseStatusEnum.is_caseworker_operable(status) is False diff --git a/api/staticdata/statuses/tests/test_models.py b/api/staticdata/statuses/tests/test_models.py new file mode 100644 index 0000000000..416a5bb123 --- /dev/null +++ b/api/staticdata/statuses/tests/test_models.py @@ -0,0 +1,19 @@ +from parameterized import parameterized + +from api.staticdata.statuses.enums import CaseStatusEnum +from api.staticdata.statuses.models import CaseStatus + +from test_helpers.clients import DataTestClient + + +class TestCaseStatus(DataTestClient): + + @parameterized.expand(CaseStatusEnum.caseworker_operable_statuses()) + def test_is_caseworker_operable_operable_status_status_operable(self, status): + status_record = CaseStatus.objects.get(status=status) + assert status_record.is_caseworker_operable is True + + @parameterized.expand(CaseStatusEnum.caseworker_inoperable_statuses()) + def test_is_caseworker_operable_operable_status_status_inoperable(self, status): + status_record = CaseStatus.objects.get(status=status) + assert status_record.is_caseworker_operable is False diff --git a/lite_routing b/lite_routing index 892aeb57f1..5ee81c6b25 160000 --- a/lite_routing +++ b/lite_routing @@ -1 +1 @@ -Subproject commit 892aeb57f1da7a41d72b6f70f68504e318f6701f +Subproject commit 5ee81c6b253a8ce58ecb774b984bc27adb515f34