From ef8d7bc14d50c9f809f1d2a59750a4bde1ed13a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Riku=20Kestila=CC=88?= Date: Thu, 7 Dec 2023 15:28:42 +0200 Subject: [PATCH 1/3] feat: add callback for receiving delete success message from Ahjo --- .../api/v1/ahjo_integration_views.py | 53 ++++++++++++---- backend/benefit/applications/enums.py | 5 ++ .../tests/test_ahjo_integration.py | 61 ++++++++++++++++--- backend/benefit/helsinkibenefit/urls.py | 2 +- 4 files changed, 100 insertions(+), 21 deletions(-) diff --git a/backend/benefit/applications/api/v1/ahjo_integration_views.py b/backend/benefit/applications/api/v1/ahjo_integration_views.py index 388d4dd24f..4e14dfccb9 100644 --- a/backend/benefit/applications/api/v1/ahjo_integration_views.py +++ b/backend/benefit/applications/api/v1/ahjo_integration_views.py @@ -11,7 +11,11 @@ from rest_framework.views import APIView from applications.api.v1.serializers.ahjo_callback import AhjoCallbackSerializer -from applications.enums import AhjoCallBackStatus, AhjoStatus as AhjoStatusEnum +from applications.enums import ( + AhjoCallBackStatus, + AhjoRequestType, + AhjoStatus as AhjoStatusEnum, +) from applications.models import AhjoStatus, Application, Attachment from common.permissions import SafeListPermission from shared.audit_log import audit_logging @@ -76,7 +80,14 @@ class AhjoCallbackView(APIView): required=True, type=OpenApiTypes.UUID, location=OpenApiParameter.PATH, - ) + ), + OpenApiParameter( + name="request_id", + description="The type of request that Ahjo responded to", + required=True, + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + ), ], description="Callback endpoint for Ahjo to send updates to the application.", request=AhjoCallbackSerializer, @@ -97,21 +108,28 @@ def post(self, request, *args, **kwargs): if callback_data["message"] == AhjoCallBackStatus.SUCCESS: ahjo_request_id = callback_data["requestId"] application = get_object_or_404(Application, pk=application_id) - application.ahjo_case_guid = callback_data["caseGuid"] - application.ahjo_case_id = callback_data["caseId"] - application.save() - - AhjoStatus.objects.create( - application=application, status=AhjoStatusEnum.CASE_OPENED - ) + request_type = self.kwargs["request_type"] + + if request_type == AhjoRequestType.OPEN_CASE: + application = self._handle_open_case_callback( + application, callback_data + ) + ahjo_status = AhjoStatusEnum.CASE_OPENED + info = f"""Application ahjo_case_guid and ahjo_case_id + were updated by Ahjo request id: {ahjo_request_id}""" + elif request_type == AhjoRequestType.DELETE_APPLICATION: + self._handle_delete_callback() + ahjo_status = AhjoStatusEnum.DELETE_REQUEST_RECEIVED + info = f"""Application was marked for cancellation in Ahjo with request id: {ahjo_request_id}""" + + AhjoStatus.objects.create(application=application, status=ahjo_status) audit_logging.log( request.user, "", Operation.UPDATE, application, - additional_information=f"""Application ahjo_case_guid and ahjo_case_id were updated - by Ahjo request id: {ahjo_request_id}""", + additional_information=info, ) return Response( @@ -128,3 +146,16 @@ def post(self, request, *args, **kwargs): {"message": "Callback received but unsuccessful"}, status=status.HTTP_200_OK, ) + + def _create_status(self, application: Application, status: AhjoStatusEnum): + return AhjoStatus.objects.create(application=application, status=status) + + def _handle_open_case_callback(self, application: Application, callback_data: dict): + application.ahjo_case_guid = callback_data["caseGuid"] + application.ahjo_case_id = callback_data["caseId"] + application.save() + return application + + def _handle_delete_callback(self): + # do anything that needs to be done when Ahjo sends a delete callback + pass diff --git a/backend/benefit/applications/enums.py b/backend/benefit/applications/enums.py index 7e073f28c0..c102863f05 100644 --- a/backend/benefit/applications/enums.py +++ b/backend/benefit/applications/enums.py @@ -172,3 +172,8 @@ class ApplicationActions(models.TextChoices): class AhjoCallBackStatus(models.TextChoices): SUCCESS = "Success", _("Success") FAILURE = "Failure", _("Failure") + + +class AhjoRequestType(models.TextChoices): + OPEN_CASE = "open_case", _("Open case in Ahjo") + DELETE_APPLICATION = "delete_application", _("Delete application in Ahjo") diff --git a/backend/benefit/applications/tests/test_ahjo_integration.py b/backend/benefit/applications/tests/test_ahjo_integration.py index 2ad185c4eb..b8e67caa35 100644 --- a/backend/benefit/applications/tests/test_ahjo_integration.py +++ b/backend/benefit/applications/tests/test_ahjo_integration.py @@ -13,6 +13,7 @@ from applications.api.v1.ahjo_integration_views import AhjoAttachmentView from applications.enums import ( AhjoCallBackStatus, + AhjoRequestType, AhjoStatus, ApplicationStatus, BenefitType, @@ -365,9 +366,27 @@ def test_get_attachment_unauthorized_ip_not_allowed( assert response.status_code == 403 +@pytest.mark.parametrize( + "request_type, ahjo_status", + [ + ( + AhjoRequestType.OPEN_CASE, + AhjoStatus.CASE_OPENED, + ), + ( + AhjoRequestType.DELETE_APPLICATION, + AhjoStatus.DELETE_REQUEST_RECEIVED, + ), + ], +) @pytest.mark.django_db def test_ahjo_callback_success( - ahjo_client, ahjo_user_token, decided_application, settings + ahjo_client, + ahjo_user_token, + decided_application, + settings, + request_type, + ahjo_status, ): settings.NEXT_PUBLIC_MOCK_FLAG = True auth_headers = {"HTTP_AUTHORIZATION": "Token " + ahjo_user_token.key} @@ -377,22 +396,36 @@ def test_ahjo_callback_success( "caseId": "HEL 2023-999999", "caseGuid": f"{uuid.uuid4()}", } - url = reverse("ahjo_callback_url", kwargs={"uuid": decided_application.id}) + url = reverse( + "ahjo_callback_url", + kwargs={"request_type": request_type, "uuid": decided_application.id}, + ) response = ahjo_client.post(url, **auth_headers, data=callback_payload) decided_application.refresh_from_db() assert response.status_code == 200 assert response.data == {"message": "Callback received"} - assert decided_application.ahjo_case_id == callback_payload["caseId"] - assert str(decided_application.ahjo_case_guid) == callback_payload["caseGuid"] - assert decided_application.ahjo_status.latest().status == AhjoStatus.CASE_OPENED + if request_type == AhjoRequestType.OPEN_CASE: + assert decided_application.ahjo_case_id == callback_payload["caseId"] + assert str(decided_application.ahjo_case_guid) == callback_payload["caseGuid"] + assert decided_application.ahjo_status.latest().status == ahjo_status +@pytest.mark.parametrize( + "request_type", + [ + (AhjoRequestType.OPEN_CASE,), + (AhjoRequestType.DELETE_APPLICATION,), + ], +) def test_ahjo_callback_unauthorized_wrong_or_missing_credentials( - anonymous_client, decided_application, settings + anonymous_client, decided_application, settings, request_type ): settings.NEXT_PUBLIC_MOCK_FLAG = True - url = reverse("ahjo_callback_url", kwargs={"uuid": decided_application.id}) + url = reverse( + "ahjo_callback_url", + kwargs={"request_type": request_type, "uuid": decided_application.id}, + ) response = anonymous_client.post(url) assert response.status_code == 401 @@ -403,11 +436,21 @@ def test_ahjo_callback_unauthorized_wrong_or_missing_credentials( assert response.status_code == 401 +@pytest.mark.parametrize( + "request_type", + [ + (AhjoRequestType.OPEN_CASE,), + (AhjoRequestType.DELETE_APPLICATION,), + ], +) def test_ahjo_callback_unauthorized_ip_not_allowed( - ahjo_client, ahjo_user_token, decided_application, settings + ahjo_client, ahjo_user_token, decided_application, settings, request_type ): settings.NEXT_PUBLIC_MOCK_FLAG = False - url = reverse("ahjo_callback_url", kwargs={"uuid": decided_application.id}) + url = reverse( + "ahjo_callback_url", + kwargs={"request_type": request_type, "uuid": decided_application.id}, + ) auth_headers = {"HTTP_AUTHORIZATION": "Token " + ahjo_user_token.key} response = ahjo_client.post(url, **auth_headers) diff --git a/backend/benefit/helsinkibenefit/urls.py b/backend/benefit/helsinkibenefit/urls.py index e498eb28c2..e290d9a149 100644 --- a/backend/benefit/helsinkibenefit/urls.py +++ b/backend/benefit/helsinkibenefit/urls.py @@ -66,7 +66,7 @@ urlpatterns = [ path("admin/", admin.site.urls), path( - "v1/ahjo-integration/callback/", + "v1/ahjo-integration/callback//", AhjoCallbackView.as_view(), name="ahjo_callback_url", ), From cb679fd20240b76be9216860539e94f66d2e776e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Riku=20Kestila=CC=88?= Date: Thu, 7 Dec 2023 15:58:42 +0200 Subject: [PATCH 2/3] feat: add function that performs delete/cancel request to Ahjo --- .../applications/services/ahjo_integration.py | 94 ++++++++--- .../applications/tests/test_ahjo_requests.py | 147 +++++++++++++----- 2 files changed, 174 insertions(+), 67 deletions(-) diff --git a/backend/benefit/applications/services/ahjo_integration.py b/backend/benefit/applications/services/ahjo_integration.py index ad8debc1b7..c92c363e49 100644 --- a/backend/benefit/applications/services/ahjo_integration.py +++ b/backend/benefit/applications/services/ahjo_integration.py @@ -16,7 +16,11 @@ from django.db.models import QuerySet from django.urls import reverse -from applications.enums import AhjoStatus as AhjoStatusEnum, ApplicationStatus +from applications.enums import ( + AhjoRequestType, + AhjoStatus as AhjoStatusEnum, + ApplicationStatus, +) from applications.models import AhjoSetting, AhjoStatus, Application from applications.services.ahjo_authentication import AhjoConnector from applications.services.ahjo_payload import prepare_open_case_payload @@ -383,9 +387,14 @@ def get_token() -> str: return -def prepare_headers(access_token: str, application_uuid: uuid) -> dict: +def prepare_headers( + access_token: str, application: Application, request_type: AhjoRequestType +) -> dict: """Prepare the headers for the Ahjo request.""" - url = reverse("ahjo_callback_url", kwargs={"uuid": str(application_uuid)}) + url = reverse( + "ahjo_callback_url", + kwargs={"request_type": request_type, "uuid": str(application.id)}, + ) return { "Authorization": f"Bearer {access_token}", @@ -394,7 +403,7 @@ def prepare_headers(access_token: str, application_uuid: uuid) -> dict: } -def get_application_for_ahjo(id: uuid) -> Optional[Application]: +def get_application_for_ahjo(id: uuid.UUID) -> Optional[Application]: """Get the first accepted application.""" application = ( Application.objects.filter(pk=id, status=ApplicationStatus.ACCEPTED) @@ -411,52 +420,87 @@ def get_application_for_ahjo(id: uuid) -> Optional[Application]: return application -def create_status_for_application(application: Application): +def create_status_for_application(application: Application, status: AhjoStatusEnum): """Create a new AhjoStatus for the application.""" - AhjoStatus.objects.create( - application=application, status=AhjoStatusEnum.REQUEST_TO_OPEN_CASE_SENT - ) + AhjoStatus.objects.create(application=application, status=status) -def do_ahjo_request_with_json_payload( - url: str, headers: dict, data: dict, application: Application, timeout: int = 10 +def send_request_to_ahjo( + request_type: AhjoRequestType, + headers: dict, + application: Application, + data: dict = {}, + timeout: int = 10, ): + """Send a request to Ahjo.""" headers["Content-Type"] = "application/json" - json_data = json.dumps(data) + url_base = f"{settings.AHJO_REST_API_URL}/cases" + + if request_type == AhjoRequestType.OPEN_CASE: + method = "POST" + status = AhjoStatusEnum.REQUEST_TO_OPEN_CASE_SENT + api_url = url_base + data = json.dumps(data) + elif request_type == AhjoRequestType.DELETE_APPLICATION: + method = "DELETE" + status = AhjoStatusEnum.DELETE_REQUEST_SENT + api_url = f"{url_base}/{application.ahjo_case_id}" try: - response = requests.post( - f"{url}/cases", headers=headers, timeout=timeout, data=json_data + response = requests.request( + method, api_url, headers=headers, timeout=timeout, data=data ) response.raise_for_status() if response.ok: - create_status_for_application(application) + create_status_for_application(application, status) LOGGER.info( - f"Open case for application {application.id} Request to Ahjo was successful." + f"Request for application {application.id} to Ahjo was successful." ) except requests.exceptions.HTTPError as e: - # Handle the HTTP error - LOGGER.error(f"HTTP error occurred while sending request to Ahjo: {e}") + LOGGER.error( + f"HTTP error occurred while sending a {request_type} request to Ahjo: {e}" + ) + raise except requests.exceptions.RequestException as e: - # Handle the network error - LOGGER.error(f"Network error occurred while sending request to Ahjo: {e}") + LOGGER.error( + f"Network error occurred while sending a {request_type} request to Ahjo: {e}" + ) + raise except Exception as e: - # Handle any other error - LOGGER.error(f"Error occurred while sending request to Ahjo: {e}") + LOGGER.error( + f"Error occurred while sending request a {request_type} to Ahjo: {e}" + ) + raise -def open_case_in_ahjo(application_id: uuid): +def open_case_in_ahjo(application_id: uuid.UUID): """Open a case in Ahjo.""" try: application = get_application_for_ahjo(application_id) - ahjo_api_url = settings.AHJO_REST_API_URL ahjo_token = get_token() - headers = prepare_headers(ahjo_token.access_token, application.id) + headers = prepare_headers( + ahjo_token.access_token, application.id, AhjoRequestType.OPEN_CASE + ) data = prepare_open_case_payload(application) - do_ahjo_request_with_json_payload(ahjo_api_url, headers, data, application) + send_request_to_ahjo(AhjoRequestType.OPEN_CASE, headers, data, application) + except ObjectDoesNotExist as e: + LOGGER.error(f"Object not found: {e}") + except ImproperlyConfigured as e: + LOGGER.error(f"Improperly configured: {e}") + + +def delete_application_in_ahjo(application_id: uuid.UUID): + """Delete/cancel an application in Ahjo.""" + try: + application = get_application_for_ahjo(application_id) + ahjo_token = get_token() + headers = prepare_headers( + ahjo_token.access_token, application.id, AhjoRequestType.DELETE_APPLICATION + ) + send_request_to_ahjo(AhjoRequestType.DELETE_APPLICATION, headers, application) except ObjectDoesNotExist as e: LOGGER.error(f"Object not found: {e}") except ImproperlyConfigured as e: diff --git a/backend/benefit/applications/tests/test_ahjo_requests.py b/backend/benefit/applications/tests/test_ahjo_requests.py index 4fea054f87..a9c9801cf0 100644 --- a/backend/benefit/applications/tests/test_ahjo_requests.py +++ b/backend/benefit/applications/tests/test_ahjo_requests.py @@ -1,25 +1,40 @@ -import uuid from unittest.mock import patch +import pytest import requests import requests_mock +from django.conf import settings from django.urls import reverse -from applications.enums import AhjoStatus -from applications.services.ahjo_integration import ( - do_ahjo_request_with_json_payload, - prepare_headers, -) +from applications.enums import AhjoRequestType, AhjoStatus +from applications.services.ahjo_integration import prepare_headers, send_request_to_ahjo + +@pytest.fixture +def application_with_ahjo_case_id(decided_application): + decided_application.ahjo_case_id = "12345" + decided_application.save() + return decided_application -def test_prepare_headers(settings): + +@pytest.mark.parametrize( + "request_type", + [ + (AhjoRequestType.OPEN_CASE,), + (AhjoRequestType.DELETE_APPLICATION,), + ], +) +def test_prepare_headers(settings, request_type, decided_application): settings.API_BASE_URL = "http://test.com" access_token = "test_token" - application_uuid = uuid.uuid4() - headers = prepare_headers(access_token, application_uuid) + url = reverse( + "ahjo_callback_url", + kwargs={"request_type": request_type, "uuid": decided_application.id}, + ) + + headers = prepare_headers(access_token, decided_application, request_type) - url = reverse("ahjo_callback_url", kwargs={"uuid": str(application_uuid)}) expected_headers = { "Authorization": f"Bearer {access_token}", "Accept": "application/hal+json", @@ -29,61 +44,109 @@ def test_prepare_headers(settings): assert headers == expected_headers -def test_do_ahjo_request_with_json_payload_success( - decided_application, ahjo_open_case_payload +@pytest.mark.parametrize( + "request_type, request_url, ahjo_status", + [ + ( + AhjoRequestType.OPEN_CASE, + f"{settings.AHJO_REST_API_URL}/cases", + AhjoStatus.REQUEST_TO_OPEN_CASE_SENT, + ), + ( + AhjoRequestType.DELETE_APPLICATION, + f"{settings.AHJO_REST_API_URL}/cases/12345", + AhjoStatus.DELETE_REQUEST_SENT, + ), + ], +) +def test_send_request_to_ahjo( + request_type, + request_url, + ahjo_status, + application_with_ahjo_case_id, + ahjo_open_case_payload, ): - url = "http://test.com" headers = {"Authorization": "Bearer test"} with requests_mock.Mocker() as m: - m.post(f"{url}/cases", text="data") - do_ahjo_request_with_json_payload( - url, headers, ahjo_open_case_payload, decided_application + if request_type == AhjoRequestType.OPEN_CASE: + m.post(request_url, text="data") + elif request_type == AhjoRequestType.DELETE_APPLICATION: + m.delete(request_url) + send_request_to_ahjo( + request_type, headers, application_with_ahjo_case_id, ahjo_open_case_payload ) - decided_application.refresh_from_db() - assert ( - decided_application.ahjo_status.latest().status - == AhjoStatus.REQUEST_TO_OPEN_CASE_SENT - ) + assert m.called + + application_with_ahjo_case_id.refresh_from_db() + assert application_with_ahjo_case_id.ahjo_status.latest().status == ahjo_status @patch("applications.services.ahjo_integration.LOGGER") -def test_http_error(mock_logger, decided_application, ahjo_open_case_payload): - url = "http://mockedurl.com" +def test_http_error(mock_logger, application_with_ahjo_case_id): headers = {} - data = ahjo_open_case_payload - application = decided_application - with requests_mock.Mocker() as m: - m.post(f"{url}/cases", status_code=400) - do_ahjo_request_with_json_payload(url, headers, data, application) + with requests_mock.Mocker() as m, pytest.raises(requests.exceptions.HTTPError): + m.post(f"{settings.AHJO_REST_API_URL}/cases", status_code=400) + send_request_to_ahjo( + AhjoRequestType.OPEN_CASE, headers, application_with_ahjo_case_id + ) + assert m.called + mock_logger.error.assert_called() + + with requests_mock.Mocker() as m, pytest.raises(requests.exceptions.HTTPError): + m.delete(f"{settings.AHJO_REST_API_URL}/cases/12345", status_code=400) + send_request_to_ahjo( + AhjoRequestType.DELETE_APPLICATION, headers, application_with_ahjo_case_id + ) + + assert m.called mock_logger.error.assert_called() @patch("applications.services.ahjo_integration.LOGGER") -def test_network_error(mock_logger, decided_application, ahjo_open_case_payload): - url = "http://mockedurl.com" +def test_network_error(mock_logger, application_with_ahjo_case_id): headers = {} - data = ahjo_open_case_payload - application = decided_application + exception = requests.exceptions.ConnectionError - with requests_mock.Mocker() as m: - m.post(f"{url}/cases", exc=requests.exceptions.ConnectionError) - do_ahjo_request_with_json_payload(url, headers, data, application) + with requests_mock.Mocker() as m, pytest.raises(exception): + m.post(f"{settings.AHJO_REST_API_URL}/cases", exc=exception) + send_request_to_ahjo( + AhjoRequestType.OPEN_CASE, headers, application_with_ahjo_case_id + ) + + assert m.called + mock_logger.error.assert_called() + + with requests_mock.Mocker() as m, pytest.raises(exception): + m.delete(f"{settings.AHJO_REST_API_URL}/cases/12345", exc=exception) + send_request_to_ahjo( + AhjoRequestType.DELETE_APPLICATION, headers, application_with_ahjo_case_id + ) + assert m.called mock_logger.error.assert_called() @patch("applications.services.ahjo_integration.LOGGER") -def test_other_exception(mock_logger, decided_application, ahjo_open_case_payload): - url = "http://mockedurl.com" +def test_other_error(mock_logger, application_with_ahjo_case_id): headers = {} - data = ahjo_open_case_payload - application = decided_application - with requests_mock.Mocker() as m: - m.post(f"{url}/cases", exc=Exception("Some error")) - do_ahjo_request_with_json_payload(url, headers, data, application) + with requests_mock.Mocker() as m, pytest.raises(Exception): + m.post(f"{settings.AHJO_REST_API_URL}/cases", exc=Exception) + send_request_to_ahjo( + AhjoRequestType.OPEN_CASE, headers, application_with_ahjo_case_id + ) + + assert m.called + mock_logger.error.assert_called() + + with requests_mock.Mocker() as m, pytest.raises(Exception): + m.delete(f"{settings.AHJO_REST_API_URL}/cases/12345", exc=Exception) + send_request_to_ahjo( + AhjoRequestType.DELETE_APPLICATION, headers, application_with_ahjo_case_id + ) + assert m.called mock_logger.error.assert_called() From 82584bd14d70f9c97a16e5276161f4d75ba7955b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Riku=20Kestila=CC=88?= Date: Mon, 11 Dec 2023 11:15:10 +0200 Subject: [PATCH 3/3] fix: remove unused function --- backend/benefit/applications/api/v1/ahjo_integration_views.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/backend/benefit/applications/api/v1/ahjo_integration_views.py b/backend/benefit/applications/api/v1/ahjo_integration_views.py index 4e14dfccb9..4ae69f1d6c 100644 --- a/backend/benefit/applications/api/v1/ahjo_integration_views.py +++ b/backend/benefit/applications/api/v1/ahjo_integration_views.py @@ -147,9 +147,6 @@ def post(self, request, *args, **kwargs): status=status.HTTP_200_OK, ) - def _create_status(self, application: Application, status: AhjoStatusEnum): - return AhjoStatus.objects.create(application=application, status=status) - def _handle_open_case_callback(self, application: Application, callback_data: dict): application.ahjo_case_guid = callback_data["caseGuid"] application.ahjo_case_id = callback_data["caseId"]