From a2eacea77f232cd50a66a4fa30fa491d6088106b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Riku=20Kestila=CC=88?= Date: Tue, 30 Apr 2024 14:38:45 +0300 Subject: [PATCH] feat: callback endpoint for decision status --- .../api/v1/ahjo_integration_views.py | 44 +++++++++++++- .../api/v1/serializers/ahjo_callback.py | 7 +++ backend/benefit/applications/enums.py | 10 ++++ .../applications/services/ahjo_client.py | 40 +++++++++---- .../applications/services/ahjo_integration.py | 13 ++++ .../tests/test_ahjo_integration.py | 60 +++++++++++++++++-- .../applications/tests/test_ahjo_requests.py | 2 +- backend/benefit/helsinkibenefit/urls.py | 6 ++ 8 files changed, 163 insertions(+), 19 deletions(-) diff --git a/backend/benefit/applications/api/v1/ahjo_integration_views.py b/backend/benefit/applications/api/v1/ahjo_integration_views.py index 531a5a62fc..2a9245b9d7 100644 --- a/backend/benefit/applications/api/v1/ahjo_integration_views.py +++ b/backend/benefit/applications/api/v1/ahjo_integration_views.py @@ -12,9 +12,13 @@ from rest_framework.response import Response from rest_framework.views import APIView -from applications.api.v1.serializers.ahjo_callback import AhjoCallbackSerializer +from applications.api.v1.serializers.ahjo_callback import ( + AhjoCallbackSerializer, + AhjoDecisionCallbackSerializer, +) from applications.enums import ( AhjoCallBackStatus, + AhjoDecisionUpdateType, AhjoRequestType, AhjoStatus as AhjoStatusEnum, ApplicationBatchStatus, @@ -272,3 +276,41 @@ def _log_failure_details(self, application, callback_data): f"Ahjo reports failure with record, hash value {cb_record['hashValue']} \ and fileURI {cb_record['fileUri']}" ) + + +class AhjoDecisionCallbackView(APIView): + authentication_classes = [ + TokenAuthentication, + ] + permission_classes = [IsAuthenticated, SafeListPermission] + + def post(self, request, *args, **kwargs): + serializer = AhjoDecisionCallbackSerializer(data=request.data) + + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + callback_data = serializer.validated_data + ahjo_case_id = callback_data["caseId"] + update_type = callback_data["updatetype"] + + application = get_object_or_404(Application, ahjo_case_id=ahjo_case_id) + + if update_type == AhjoDecisionUpdateType.ADDED: + AhjoStatus.objects.create( + application=application, status=AhjoStatusEnum.SIGNED_IN_AHJO + ) + elif update_type == AhjoDecisionUpdateType.REMOVED: + AhjoStatus.objects.create( + application=application, status=AhjoStatusEnum.REMOVED_IN_AHJO + ) + + audit_logging.log( + request.user, + "", + Operation.UPDATE, + application, + additional_information=f"Decision proposal update type: {update_type} was received from Ahjo", + ) + + return Response({"message": "Callback received"}, status=status.HTTP_200_OK) diff --git a/backend/benefit/applications/api/v1/serializers/ahjo_callback.py b/backend/benefit/applications/api/v1/serializers/ahjo_callback.py index b9b2ce1bec..09116e670e 100644 --- a/backend/benefit/applications/api/v1/serializers/ahjo_callback.py +++ b/backend/benefit/applications/api/v1/serializers/ahjo_callback.py @@ -14,3 +14,10 @@ def validate_message(self, message): if message not in ["Success", "Failure"]: raise serializers.ValidationError("Invalid message value.") return message + + +class AhjoDecisionCallbackSerializer(serializers.Serializer): + updatetype = serializers.CharField(required=True) + id = serializers.CharField(required=True) + caseId = serializers.CharField(required=True) + caseGuid = serializers.UUIDField(format="hex_verbose", required=True) diff --git a/backend/benefit/applications/enums.py b/backend/benefit/applications/enums.py index a87cfc1acf..21eeb0391f 100644 --- a/backend/benefit/applications/enums.py +++ b/backend/benefit/applications/enums.py @@ -177,6 +177,9 @@ class AhjoStatus(models.TextChoices): DELETE_REQUEST_RECEIVED = "delete_request_received", _("Delete request received") NEW_RECORDS_REQUEST_SENT = "new_record_request_sent", _("New record request sent") NEW_RECORDS_RECEIVED = "new_record_received", _("New record received by Ahjo") + REMOVED_IN_AHJO = "removed", _("Decision cancelled in Ahjo") + SIGNED_IN_AHJO = "signed", _("Decision signed and completed in Ahjo") + UPDATED_IN_AHJO = "updated", _("Decision updated in Ahjo") class ApplicationActions(models.TextChoices): @@ -196,6 +199,7 @@ class AhjoRequestType(models.TextChoices): UPDATE_APPLICATION = "update_application", _("Update application in Ahjo") ADD_RECORDS = "add_records", _("Send new records to Ahjo") SEND_DECISION_PROPOSAL = "send_decision", _("Send decision to Ahjo") + SUBSCRIBE_TO_DECISIONS = "subscribe_to_decisions", _("Subscribe to decisions API") class DecisionType(models.TextChoices): @@ -243,6 +247,12 @@ class AhjoRecordTitle(models.TextChoices): SECRET_ATTACHMENT = "Päätöksen liite", _("Secret decision attachment title") +class AhjoDecisionUpdateType(models.TextChoices): + ADDED = "Added", _("Added") + REMOVED = "Removed", _("removed") + UPDATED = "Updated", _("Updated") + + # Call gettext on some of the enums so that "makemessages" command can find them when used dynamically in templates _("granted") _("granted_aged") diff --git a/backend/benefit/applications/services/ahjo_client.py b/backend/benefit/applications/services/ahjo_client.py index 0115412c32..8a295ae922 100644 --- a/backend/benefit/applications/services/ahjo_client.py +++ b/backend/benefit/applications/services/ahjo_client.py @@ -2,7 +2,7 @@ import logging import uuid from dataclasses import dataclass, field -from typing import Tuple, Union +from typing import Optional, Tuple, Union import requests from django.conf import settings @@ -19,12 +19,11 @@ @dataclass class AhjoRequest: - application: Application request_type = AhjoRequestType - url_base: str = field( - default_factory=lambda: f"{settings.AHJO_REST_API_URL}{API_CASES_BASE}" - ) + application: Optional[Application] = None + lang: str = "fi" + url_base: str = field(default_factory=lambda: settings.AHJO_REST_API_URL) def __str__(self): return ( @@ -38,7 +37,9 @@ def api_url(self) -> str: ) if not self.application.ahjo_case_id: raise MissingAhjoCaseIdError("Application does not have an Ahjo case id") - return f"{self.url_base}/{self.application.ahjo_case_id}/records" + return ( + f"{self.url_base}{API_CASES_BASE}/{self.application.ahjo_case_id}/records" + ) @dataclass @@ -54,7 +55,7 @@ def api_url(self) -> str: raise MissingHandlerIdError( f"Application {self.application.id} handler does not have an ad_username" ) - return self.url_base + return f"{self.url_base}{API_CASES_BASE}" @dataclass @@ -99,12 +100,22 @@ def api_url(self) -> str: ) if not self.application.ahjo_case_id: raise MissingAhjoCaseIdError("Application does not have an Ahjo case id") - # Remove /records from the url, as it is not needed for delete requests - url = super().api_url().replace("/records", "") + url = f"{self.url_base}{API_CASES_BASE}/{self.application.ahjo_case_id}" draftsman_id = self.application.calculation.handler.ad_username return f"{url}?draftsmanid={draftsman_id}&reason={self.reason}&apireqlang={self.lang}" +@dataclass +class AhjoSubscribeDecisionRequest(AhjoRequest): + """Request to subscribe to a decision in Ahjo.""" + + request_type = AhjoRequestType.SUBSCRIBE_TO_DECISIONS + request_method = "POST" + + def api_url(self) -> str: + return f"{self.url_base}/decisions/subscribe" + + class AhjoApiClientException(Exception): pass @@ -140,12 +151,15 @@ def prepare_ahjo_headers(self) -> dict: """Prepare the headers for the Ahjo given Ahjo request type. The headers are used to authenticate the request to Ahjo and register a callback address. """ + + kwargs_dict = {"request_type": self._request.request_type} + + if not self._request.request_type == AhjoRequestType.SUBSCRIBE_TO_DECISIONS: + kwargs_dict["uuid"] = str(self._request.application.id) + url = reverse( "ahjo_callback_url", - kwargs={ - "request_type": self._request.request_type, - "uuid": str(self._request.application.id), - }, + kwargs=kwargs_dict, ) return { diff --git a/backend/benefit/applications/services/ahjo_integration.py b/backend/benefit/applications/services/ahjo_integration.py index d862f30430..63eeff1976 100644 --- a/backend/benefit/applications/services/ahjo_integration.py +++ b/backend/benefit/applications/services/ahjo_integration.py @@ -26,6 +26,7 @@ AhjoDecisionProposalRequest, AhjoDeleteCaseRequest, AhjoOpenCaseRequest, + AhjoSubscribeDecisionRequest, AhjoUpdateRecordsRequest, ) from applications.services.ahjo_payload import ( @@ -608,3 +609,15 @@ def delete_existing_xml_attachments(application: Application): LOGGER.info( f"Deleted existing decision text attachments for application {application.id}" ) + + +def send_subscription_request_to_ahjo(ahjo_auth_token: AhjoToken): + """Send a subscription request to Ahjo.""" + try: + ahjo_request = AhjoSubscribeDecisionRequest() + ahjo_client = AhjoApiClient(ahjo_auth_token, ahjo_request) + response, response_text = ahjo_client.send_request_to_ahjo + except ObjectDoesNotExist as e: + LOGGER.error(f"Object not found: {e}") + except ImproperlyConfigured as e: + LOGGER.error(f"Improperly configured: {e}") diff --git a/backend/benefit/applications/tests/test_ahjo_integration.py b/backend/benefit/applications/tests/test_ahjo_integration.py index f53ef974ba..5560370db2 100644 --- a/backend/benefit/applications/tests/test_ahjo_integration.py +++ b/backend/benefit/applications/tests/test_ahjo_integration.py @@ -3,7 +3,7 @@ import uuid import zipfile from datetime import date, timedelta -from typing import List +from typing import List, Union from unittest.mock import patch import pytest @@ -15,6 +15,7 @@ from applications.api.v1.ahjo_integration_views import AhjoAttachmentView from applications.enums import ( AhjoCallBackStatus, + AhjoDecisionUpdateType, AhjoRequestType, AhjoStatus as AhjoStatusEnum, ApplicationBatchStatus, @@ -395,10 +396,20 @@ def test_get_attachment_unauthorized_ip_not_allowed( assert response.status_code == 403 -def _get_callback_url(request_type: AhjoRequestType, decided_application: Application): +def _get_callback_url( + request_type: AhjoRequestType, decided_application: Union[Application, None] +): + kwargs_dict = {} + route_name = "ahjo_decision_callback_url" + + if not request_type == AhjoRequestType.SUBSCRIBE_TO_DECISIONS: + kwargs_dict["request_type"] = request_type + kwargs_dict["uuid"] = str(decided_application.id) + route_name = "ahjo_callback_url" + return reverse( - "ahjo_callback_url", - kwargs={"request_type": request_type, "uuid": decided_application.id}, + route_name, + kwargs=kwargs_dict, ) @@ -474,6 +485,7 @@ def test_ahjo_callback_success( attachment.refresh_from_db() assert response.status_code == 200 assert response.data == {"message": "Callback received"} + if request_type == AhjoRequestType.OPEN_CASE: assert decided_application.ahjo_case_id == ahjo_callback_payload["caseId"] assert ( @@ -487,11 +499,13 @@ def test_ahjo_callback_success( assert batch.auto_generated_by_ahjo assert batch.handler == decided_application.calculation.handler assert batch.status == ApplicationBatchStatus.DRAFT + if request_type == AhjoRequestType.UPDATE_APPLICATION: assert ( attachment.ahjo_version_series_id == ahjo_callback_payload["records"][0]["versionSeriesId"] ) + if request_type == AhjoRequestType.SEND_DECISION_PROPOSAL: batch = decided_application.batch assert batch.status == ApplicationBatchStatus.AWAITING_AHJO_DECISION @@ -499,6 +513,44 @@ def test_ahjo_callback_success( assert decided_application.ahjo_status.latest().status == ahjo_status +@pytest.mark.parametrize( + "updatetype_from_ahjo, status_after_callback", + [ + (AhjoDecisionUpdateType.ADDED, AhjoStatusEnum.SIGNED_IN_AHJO), + (AhjoDecisionUpdateType.REMOVED, AhjoStatusEnum.REMOVED_IN_AHJO), + ], +) +def test_subscribe_to_decisions_callback_success( + ahjo_client, + ahjo_user_token, + decided_application, + status_after_callback, + settings, + updatetype_from_ahjo, +): + dummy_case_id = "HEL 1999-123" + decided_application.ahjo_case_id = dummy_case_id + decided_application.save() + + settings.NEXT_PUBLIC_MOCK_FLAG = True + auth_headers = {"HTTP_AUTHORIZATION": "Token " + ahjo_user_token.key} + + callback_payload = { + "updatetype": updatetype_from_ahjo, + "id": f"{uuid.uuid4()}", + "caseId": dummy_case_id, + "caseGuid": f"{uuid.uuid4()}", + } + cb_url = _get_callback_url(AhjoRequestType.SUBSCRIBE_TO_DECISIONS, None) + response = ahjo_client.post(cb_url, **auth_headers, data=callback_payload) + + assert response.status_code == 200 + assert response.data == {"message": "Callback received"} + + decided_application.refresh_from_db() + assert decided_application.ahjo_status.latest().status == status_after_callback + + @pytest.mark.django_db def test_ahjo_open_case_callback_failure( ahjo_client, diff --git a/backend/benefit/applications/tests/test_ahjo_requests.py b/backend/benefit/applications/tests/test_ahjo_requests.py index 69770a28fe..4d15c97449 100644 --- a/backend/benefit/applications/tests/test_ahjo_requests.py +++ b/backend/benefit/applications/tests/test_ahjo_requests.py @@ -72,7 +72,7 @@ def test_ahjo_requests( assert request.application == application assert request.request_type == request_type assert request.request_method == request_method - assert request.url_base == f"{settings.AHJO_REST_API_URL}{API_CASES_BASE}" + assert request.url_base == f"{settings.AHJO_REST_API_URL}" assert request.lang == "fi" assert ( str(request) diff --git a/backend/benefit/helsinkibenefit/urls.py b/backend/benefit/helsinkibenefit/urls.py index 6970cb46a9..8f3eafe359 100644 --- a/backend/benefit/helsinkibenefit/urls.py +++ b/backend/benefit/helsinkibenefit/urls.py @@ -21,6 +21,7 @@ from applications.api.v1.ahjo_integration_views import ( AhjoAttachmentView, AhjoCallbackView, + AhjoDecisionCallbackView, ) from applications.api.v1.review_state_views import ReviewStateView from applications.api.v1.talpa_integration_views import TalpaCallbackView @@ -88,6 +89,11 @@ AhjoCallbackView.as_view(), name="ahjo_callback_url", ), + path( + "v1/ahjo-integration/decision-callback/", + AhjoDecisionCallbackView.as_view(), + name="ahjo_decision_callback_url", + ), path( "v1/ahjo-integration/attachment/", AhjoAttachmentView.as_view(),