From 57fc4eeb0ab07def26be35a3e95199e82ea84162 Mon Sep 17 00:00:00 2001 From: rikuke <33894149+rikuke@users.noreply.github.com> Date: Mon, 5 Feb 2024 11:19:35 +0200 Subject: [PATCH] Hl 1132 ahjo versionid (#2789) * feat: version series id, file hash for attachment * feat: save version series id in callback success * chore: handle callback failure * chore: refactor to reduce cognitive complexity --- .../api/v1/ahjo_integration_views.py | 97 ++++++++++++++----- .../api/v1/serializers/ahjo_callback.py | 1 + ...hment_version_id_and_hash_20240131_1620.py | 33 +++++++ backend/benefit/applications/models.py | 5 + .../applications/services/ahjo_payload.py | 2 + .../tests/test_ahjo_integration.py | 74 ++++++++++++-- 6 files changed, 180 insertions(+), 32 deletions(-) create mode 100644 backend/benefit/applications/migrations/0055_add_attachment_version_id_and_hash_20240131_1620.py diff --git a/backend/benefit/applications/api/v1/ahjo_integration_views.py b/backend/benefit/applications/api/v1/ahjo_integration_views.py index 4ae69f1d6c..64782e1944 100644 --- a/backend/benefit/applications/api/v1/ahjo_integration_views.py +++ b/backend/benefit/applications/api/v1/ahjo_integration_views.py @@ -24,6 +24,10 @@ LOGGER = logging.getLogger(__name__) +class AhjoCallbackError(Exception): + pass + + class AhjoAttachmentView(APIView): authentication_classes = [ TokenAuthentication, @@ -104,26 +108,41 @@ def post(self, request, *args, **kwargs): callback_data = serializer.validated_data application_id = self.kwargs["uuid"] + request_type = self.kwargs["request_type"] - if callback_data["message"] == AhjoCallBackStatus.SUCCESS: - ahjo_request_id = callback_data["requestId"] - application = get_object_or_404(Application, pk=application_id) - request_type = self.kwargs["request_type"] + try: + application = Application.objects.get(pk=application_id) + except Application.DoesNotExist: + return Response( + {"error": "Application not found"}, status=status.HTTP_404_NOT_FOUND + ) + if callback_data["message"] == AhjoCallBackStatus.SUCCESS: + return self.handle_success_callback( + request, application, callback_data, request_type + ) + elif callback_data["message"] == AhjoCallBackStatus.FAILURE: + return self.handle_failure_callback(application, callback_data) + + def handle_success_callback( + self, + request, + application: Application, + callback_data: dict, + request_type: AhjoRequestType, + ) -> Response: + try: if request_type == AhjoRequestType.OPEN_CASE: - application = self._handle_open_case_callback( - application, callback_data - ) + self._handle_open_case_success(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}""" + info = f"Application ahjo_case_guid and ahjo_case_id were updated by Ahjo \ + with request id: {callback_data['requestId']}" 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}""" + info = f"Application was marked for cancellation in Ahjo with request id: {callback_data['requestId']}" AhjoStatus.objects.create(application=application, status=ahjo_status) - audit_logging.log( request.user, "", @@ -132,27 +151,59 @@ def post(self, request, *args, **kwargs): additional_information=info, ) + return Response({"message": "Callback received"}, status=status.HTTP_200_OK) + except AhjoCallbackError as e: return Response( - {"message": "Callback received"}, - status=status.HTTP_200_OK, + {"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) - elif callback_data["message"] == AhjoCallBackStatus.FAILURE: - LOGGER.error( - f"""Error: Received unsuccessful callback from Ahjo - for application {application_id} with request_id {callback_data['requestId']}""" - ) - return Response( - {"message": "Callback received but unsuccessful"}, - status=status.HTTP_200_OK, - ) + def handle_failure_callback( + self, application: Application, callback_data: dict + ) -> Response: + self._log_failure_details(application, callback_data) + return Response( + {"message": "Callback received but request was unsuccessful at AHJO"}, + status=status.HTTP_400_BAD_REQUEST, + ) - def _handle_open_case_callback(self, application: Application, callback_data: dict): + def _handle_open_case_success(self, application: Application, callback_data: dict): + """Update the application with the case id (diaarinumero) and case guid from the Ahjo callback data.""" application.ahjo_case_guid = callback_data["caseGuid"] application.ahjo_case_id = callback_data["caseId"] + cb_records = callback_data.get("records", []) + + self._save_version_series_id(application, cb_records) + application.save() return application def _handle_delete_callback(self): # do anything that needs to be done when Ahjo sends a delete callback pass + + def _save_version_series_id( + self, application: Application, cb_records: list + ) -> None: + """Save the version series id for each attachment in the callback data \ + if the calculated sha256 hashes match.""" + attachment_map = { + attachment.ahjo_hash_value: attachment + for attachment in application.attachments.all() + } + for cb_record in cb_records: + attachment = attachment_map.get(cb_record.get("hashValue")) + if attachment: + attachment.ahjo_version_series_id = cb_record.get("versionSeriesId") + attachment.save() + + def _log_failure_details(self, application, callback_data): + LOGGER.error( + f"Received unsuccessful callback for application {application.id} \ + with request_id {callback_data['requestId']}" + ) + for cb_record in callback_data.get("records", []): + if cb_record.get("status") == AhjoCallBackStatus.FAILURE: + LOGGER.error( + f"Ahjo reports failure with record, hash value {cb_record['hashValue']} \ + and fileURI {cb_record['fileUri']}" + ) diff --git a/backend/benefit/applications/api/v1/serializers/ahjo_callback.py b/backend/benefit/applications/api/v1/serializers/ahjo_callback.py index 7cc22bc394..5983372d88 100644 --- a/backend/benefit/applications/api/v1/serializers/ahjo_callback.py +++ b/backend/benefit/applications/api/v1/serializers/ahjo_callback.py @@ -6,6 +6,7 @@ class AhjoCallbackSerializer(serializers.Serializer): requestId = serializers.UUIDField(format="hex_verbose") caseId = serializers.CharField() caseGuid = serializers.UUIDField(format="hex_verbose") + records = serializers.ListField() # You can add additional validation here if needed def validate_message(self, message): diff --git a/backend/benefit/applications/migrations/0055_add_attachment_version_id_and_hash_20240131_1620.py b/backend/benefit/applications/migrations/0055_add_attachment_version_id_and_hash_20240131_1620.py new file mode 100644 index 0000000000..338373312e --- /dev/null +++ b/backend/benefit/applications/migrations/0055_add_attachment_version_id_and_hash_20240131_1620.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.23 on 2024-01-31 14:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('applications', '0054_alter_historicalattachment_attachment_type'), + ] + + operations = [ + migrations.AddField( + model_name='attachment', + name='ahjo_hash_value', + field=models.CharField(blank=True, max_length=64, null=True), + ), + migrations.AddField( + model_name='attachment', + name='ahjo_version_series_id', + field=models.CharField(blank=True, max_length=64, null=True), + ), + migrations.AddField( + model_name='historicalattachment', + name='ahjo_hash_value', + field=models.CharField(blank=True, max_length=64, null=True), + ), + migrations.AddField( + model_name='historicalattachment', + name='ahjo_version_series_id', + field=models.CharField(blank=True, max_length=64, null=True), + ), + ] diff --git a/backend/benefit/applications/models.py b/backend/benefit/applications/models.py index baedf9d9d5..530cbe609d 100755 --- a/backend/benefit/applications/models.py +++ b/backend/benefit/applications/models.py @@ -856,6 +856,11 @@ class Attachment(UUIDModel, TimeStampedModel): verbose_name=_("technical content type of the attachment"), ) attachment_file = models.FileField(verbose_name=_("application attachment content")) + + ahjo_version_series_id = models.CharField(max_length=64, null=True, blank=True) + + ahjo_hash_value = models.CharField(max_length=64, null=True, blank=True) + history = HistoricalRecords( table_name="bf_applications_attachment_history", cascade_delete_history=True ) diff --git a/backend/benefit/applications/services/ahjo_payload.py b/backend/benefit/applications/services/ahjo_payload.py index e403c13659..3a511d9bf0 100644 --- a/backend/benefit/applications/services/ahjo_payload.py +++ b/backend/benefit/applications/services/ahjo_payload.py @@ -65,6 +65,8 @@ def _prepare_record_document_dict(attachment: Attachment) -> dict: # If were running in mock mode, use the local file URI file_url = reverse("ahjo_attachment_url", kwargs={"uuid": attachment.id}) hash_value = hash_file(attachment.attachment_file) + attachment.ahjo_hash_value = hash_value + attachment.save() return { "FileName": f"{attachment.attachment_file.name}", "FormatName": f"{attachment.content_type}", diff --git a/backend/benefit/applications/tests/test_ahjo_integration.py b/backend/benefit/applications/tests/test_ahjo_integration.py index 258fb25f55..2b7ca12cf2 100644 --- a/backend/benefit/applications/tests/test_ahjo_integration.py +++ b/backend/benefit/applications/tests/test_ahjo_integration.py @@ -37,6 +37,7 @@ from applications.tests.factories import ApplicationFactory, DecidedApplicationFactory from calculator.models import Calculation from calculator.tests.factories import PaySubsidyFactory +from common.utils import hash_file from companies.tests.factories import CompanyFactory from helsinkibenefit.tests.conftest import * # noqa from shared.common.tests.utils import normalize_whitespace @@ -371,6 +372,24 @@ def test_get_attachment_unauthorized_ip_not_allowed( assert response.status_code == 403 +@pytest.fixture +def ahjo_callback_payload(): + return { + "message": "", + "requestId": f"{uuid.uuid4()}", + "caseId": "HEL 2023-999999", + "caseGuid": f"{uuid.uuid4()}", + "records": [ + { + "fileURI": "https://example.com", + "status": "Success", + "hashValue": "", + "versionSeriesId": f"{uuid.uuid4()}", + } + ], + } + + @pytest.mark.parametrize( "request_type, ahjo_status", [ @@ -392,30 +411,67 @@ def test_ahjo_callback_success( settings, request_type, ahjo_status, + ahjo_callback_payload, ): settings.NEXT_PUBLIC_MOCK_FLAG = True auth_headers = {"HTTP_AUTHORIZATION": "Token " + ahjo_user_token.key} - callback_payload = { - "message": AhjoCallBackStatus.SUCCESS, - "requestId": f"{uuid.uuid4()}", - "caseId": "HEL 2023-999999", - "caseGuid": f"{uuid.uuid4()}", - } + attachment = generate_pdf_summary_as_attachment(decided_application) + attachment_hash_value = hash_file(attachment.attachment_file) + attachment.ahjo_hash_value = attachment_hash_value + attachment.save() + ahjo_callback_payload["message"] = AhjoCallBackStatus.SUCCESS + ahjo_callback_payload["records"][0]["hashValue"] = attachment_hash_value + url = reverse( "ahjo_callback_url", kwargs={"request_type": request_type, "uuid": decided_application.id}, ) - response = ahjo_client.post(url, **auth_headers, data=callback_payload) + response = ahjo_client.post(url, **auth_headers, data=ahjo_callback_payload) decided_application.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 == callback_payload["caseId"] - assert str(decided_application.ahjo_case_guid) == callback_payload["caseGuid"] + attachment.refresh_from_db() + + assert decided_application.ahjo_case_id == ahjo_callback_payload["caseId"] + assert ( + str(decided_application.ahjo_case_guid) == ahjo_callback_payload["caseGuid"] + ) + assert ( + attachment.ahjo_version_series_id + == ahjo_callback_payload["records"][0]["versionSeriesId"] + ) assert decided_application.ahjo_status.latest().status == ahjo_status +@pytest.mark.django_db +def test_ahjo_open_case_callback_failure( + ahjo_client, + ahjo_user_token, + decided_application, + settings, + ahjo_callback_payload, +): + ahjo_callback_payload["message"] = AhjoCallBackStatus.FAILURE + + url = reverse( + "ahjo_callback_url", + kwargs={ + "request_type": AhjoRequestType.OPEN_CASE, + "uuid": decided_application.id, + }, + ) + settings.NEXT_PUBLIC_MOCK_FLAG = True + auth_headers = {"HTTP_AUTHORIZATION": "Token " + ahjo_user_token.key} + response = ahjo_client.post(url, **auth_headers, data=ahjo_callback_payload) + + assert response.status_code == 400 + assert response.data == { + "message": "Callback received but request was unsuccessful at AHJO" + } + + @pytest.mark.parametrize( "request_type", [