diff --git a/backend/benefit/applications/management/commands/open_cases_in_ahjo.py b/backend/benefit/applications/management/commands/open_cases_in_ahjo.py new file mode 100644 index 0000000000..e5acb172ce --- /dev/null +++ b/backend/benefit/applications/management/commands/open_cases_in_ahjo.py @@ -0,0 +1,110 @@ +import logging +import time +from typing import List + +from django.core.exceptions import ImproperlyConfigured +from django.core.management.base import BaseCommand + +from applications.enums import AhjoStatus as AhjoStatusEnum +from applications.models import Application +from applications.services.ahjo_authentication import AhjoToken +from applications.services.ahjo_integration import ( + create_status_for_application, + get_applications_for_open_case, + get_token, + send_open_case_request_to_ahjo, +) + +LOGGER = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Send a request to Ahjo to open cases for applications" + + def add_arguments(self, parser): + parser.add_argument( + "--number", + type=int, + default=50, + help="Number of applications to send open case requests for", + ) + + parser.add_argument( + "--dry-run", + action="store_true", + help="Run the command without making actual changes", + ) + + def handle(self, *args, **options): + try: + ahjo_auth_token = get_token() + except ImproperlyConfigured as e: + LOGGER.error(f"Failed to get auth token from Ahjo: {e}") + return + + number_to_process = options["number"] + dry_run = options["dry_run"] + + applications = get_applications_for_open_case() + + if not applications: + self.stdout.write("No applications to process") + return + + applications = applications[:number_to_process] + + if dry_run: + self.stdout.write( + f"Would send open case requests for {len(applications)} applications to Ahjo" + ) + return + + self.run_requests(applications[:number_to_process], ahjo_auth_token) + + def run_requests(self, applications: List[Application], ahjo_auth_token: AhjoToken): + start_time = time.time() + successful_applications = [] + + self.stdout.write( + f"Sending request to Ahjo to open cases for {len(applications)} applications" + ) + + for application in applications: + if not application.calculation.handler.ad_username: + raise ImproperlyConfigured( + f"No ad_username set for the handler for Ahjo open case request for application {application.id}." + ) + sent_application, response_text = send_open_case_request_to_ahjo( + application, ahjo_auth_token.access_token + ) + if sent_application: + successful_applications.append(sent_application) + self._handle_succesfully_opened_application( + sent_application, response_text + ) + + self.stdout.write( + f"Sent open case requests for {len(successful_applications)} applications to Ahjo" + ) + end_time = time.time() + elapsed_time = end_time - start_time + self.stdout.write( + f"Submitting {len(successful_applications)} open case requests took {elapsed_time} seconds to run." + ) + + def _handle_succesfully_opened_application( + self, application: Application, response_text: str + ): + """Create Ahjo status for application and set Ahjo case guid""" + create_status_for_application( + application, AhjoStatusEnum.REQUEST_TO_OPEN_CASE_SENT + ) + # The guid is returned in the response text in text format {guid}, so remove brackets here + response_text = response_text.replace("{", "").replace("}", "") + application.ahjo_case_guid = response_text + application.save() + + self.stdout.write( + f"Successfully submitted open case request for application {application.id} to Ahjo, \ + received GUID: {response_text}" + ) diff --git a/backend/benefit/applications/management/commands/seed.py b/backend/benefit/applications/management/commands/seed.py index f531aa24b6..1d2aaa4987 100755 --- a/backend/benefit/applications/management/commands/seed.py +++ b/backend/benefit/applications/management/commands/seed.py @@ -7,11 +7,17 @@ from django.utils import timezone from applications.enums import ( + AhjoStatus as AhjoStatusEnum, ApplicationBatchStatus, ApplicationOrigin, ApplicationStatus, ) -from applications.models import Application, ApplicationBasis, ApplicationBatch +from applications.models import ( + AhjoStatus, + Application, + ApplicationBasis, + ApplicationBatch, +) from applications.tests.factories import ( AdditionalInformationNeededApplicationFactory, ApplicationBatchFactory, @@ -65,6 +71,7 @@ def clear_applications(): ApplicationBatch.objects.all().delete() ApplicationBasis.objects.all().delete() + AhjoStatus.objects.all().delete() Terms.objects.all().delete() User.objects.filter(last_login=None).exclude(username="admin").delete() @@ -87,7 +94,13 @@ def _create_batch( apps = [] for _ in range(number): if proposal_for_decision == ApplicationStatus.ACCEPTED: - apps.append(DecidedApplicationFactory()) + app = DecidedApplicationFactory() + apps.append(app) + AhjoStatus.objects.create( + status=AhjoStatusEnum.SUBMITTED_BUT_NOT_SENT_TO_AHJO, + application=app, + ) + elif proposal_for_decision == ApplicationStatus.REJECTED: apps.append(RejectedApplicationFactory()) batch.applications.set(apps) @@ -114,6 +127,13 @@ def _create_batch( random_datetime = f.past_datetime(tzinfo=pytz.UTC) application = factory(application_origin=application_origin) application.created_at = random_datetime + + if factory == HandlingApplicationFactory: + AhjoStatus.objects.create( + status=AhjoStatusEnum.SUBMITTED_BUT_NOT_SENT_TO_AHJO, + application=application, + ) + application.save() application.log_entries.all().update(created_at=random_datetime) diff --git a/backend/benefit/applications/services/ahjo_integration.py b/backend/benefit/applications/services/ahjo_integration.py index 79b5f558ff..6bdd428390 100644 --- a/backend/benefit/applications/services/ahjo_integration.py +++ b/backend/benefit/applications/services/ahjo_integration.py @@ -6,7 +6,7 @@ from collections import defaultdict from dataclasses import dataclass from io import BytesIO -from typing import List, Optional +from typing import List, Optional, Tuple, Union import jinja2 import pdfkit @@ -14,7 +14,7 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist from django.core.files.base import ContentFile -from django.db.models import QuerySet +from django.db.models import F, OuterRef, QuerySet, Subquery from django.urls import reverse from applications.enums import ( @@ -24,7 +24,7 @@ AttachmentType, ) from applications.models import AhjoSetting, AhjoStatus, Application, Attachment -from applications.services.ahjo_authentication import AhjoConnector +from applications.services.ahjo_authentication import AhjoConnector, AhjoToken from applications.services.ahjo_payload import prepare_open_case_payload from applications.services.applications_csv_report import ApplicationsCsvService from applications.services.generate_application_summary import ( @@ -386,25 +386,19 @@ def generate_pdf_summary_as_attachment(application: Application) -> Attachment: return attachment -def get_token() -> str: +def get_token() -> Union[AhjoToken, None]: """Get the access token from Ahjo Service.""" try: ahjo_auth_code = AhjoSetting.objects.get(name="ahjo_code").data - LOGGER.info(f"Retrieved auth code: {ahjo_auth_code}") - connector = AhjoConnector() - - if not connector.is_configured(): - LOGGER.warning("AHJO connector is not configured") - return - return connector.get_access_token(ahjo_auth_code["code"]) - except ObjectDoesNotExist: - LOGGER.error( + except AhjoSetting.DoesNotExist: + raise ImproperlyConfigured( "Error: Ahjo auth code not found in database. Please set the 'ahjo_code' setting." ) - return - except Exception as e: - LOGGER.warning(f"Error retrieving access token: {e}") - return + connector = AhjoConnector() + + if not connector.is_configured(): + raise ImproperlyConfigured("AHJO connector is not configured") + return connector.get_access_token(ahjo_auth_code["code"]) def prepare_headers( @@ -445,13 +439,35 @@ def create_status_for_application(application: Application, status: AhjoStatusEn AhjoStatus.objects.create(application=application, status=status) +def get_applications_for_open_case() -> QuerySet[Application]: + """Query applications which have their latest AhjoStatus relation as SUBMITTED_BUT_NOT_SENT_TO_AHJO + and are in the HANDLING state, so they will have a handler.""" + latest_ahjo_status_subquery = AhjoStatus.objects.filter( + application=OuterRef("pk") + ).order_by("-created_at") + + applications = ( + Application.objects.annotate( + latest_ahjo_status_id=Subquery(latest_ahjo_status_subquery.values("id")[:1]) + ) + .filter( + status=ApplicationStatus.HANDLING, + ahjo_status__id=F("latest_ahjo_status_id"), + ahjo_status__status=AhjoStatusEnum.SUBMITTED_BUT_NOT_SENT_TO_AHJO, + ) + .prefetch_related("attachments", "calculation", "company") + ) + + return applications + + def send_request_to_ahjo( request_type: AhjoRequestType, headers: dict, application: Application, data: dict = {}, timeout: int = 10, -): +) -> Union[Tuple[Application, str], None]: """Send a request to Ahjo.""" headers["Content-Type"] = "application/json" @@ -459,12 +475,10 @@ def send_request_to_ahjo( 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: @@ -474,39 +488,43 @@ def send_request_to_ahjo( response.raise_for_status() if response.ok: - create_status_for_application(application, status) LOGGER.info( f"Request for application {application.id} to Ahjo was successful." ) + return application, response.text except requests.exceptions.HTTPError as e: LOGGER.error( - f"HTTP error occurred while sending a {request_type} request to Ahjo: {e}" + f"HTTP error occurred while sending a {request_type} request for application {application.id} to Ahjo: {e}" ) raise except requests.exceptions.RequestException as e: LOGGER.error( - f"Network error occurred while sending a {request_type} request to Ahjo: {e}" + f"HTTP error occurred while sending a {request_type} request for application {application.id} to Ahjo: {e}" ) raise except Exception as e: LOGGER.error( - f"Error occurred while sending request a {request_type} to Ahjo: {e}" + f"HTTP error occurred while sending a {request_type} request for application {application.id} to Ahjo: {e}" ) raise -def open_case_in_ahjo(application_id: uuid.UUID): +def send_open_case_request_to_ahjo( + application: Application, ahjo_auth_token: str +) -> Union[Tuple[Application, str], None]: """Open a case in Ahjo.""" try: - application = get_application_for_ahjo(application_id) - ahjo_token = get_token() headers = prepare_headers( - ahjo_token.access_token, application.id, AhjoRequestType.OPEN_CASE + ahjo_auth_token, application, AhjoRequestType.OPEN_CASE ) + pdf_summary = generate_pdf_summary_as_attachment(application) data = prepare_open_case_payload(application, pdf_summary) - send_request_to_ahjo(AhjoRequestType.OPEN_CASE, headers, data, application) + + return send_request_to_ahjo( + AhjoRequestType.OPEN_CASE, headers, application, data + ) except ObjectDoesNotExist as e: LOGGER.error(f"Object not found: {e}") except ImproperlyConfigured as e: @@ -518,10 +536,18 @@ def delete_application_in_ahjo(application_id: uuid.UUID): try: application = get_application_for_ahjo(application_id) ahjo_token = get_token() + token = ahjo_token.access_token + headers = prepare_headers( - ahjo_token.access_token, application.id, AhjoRequestType.DELETE_APPLICATION + token, application, AhjoRequestType.DELETE_APPLICATION ) - send_request_to_ahjo(AhjoRequestType.DELETE_APPLICATION, headers, application) + application, _ = send_request_to_ahjo( + AhjoRequestType.DELETE_APPLICATION, headers, application + ) + if application: + create_status_for_application( + application, AhjoStatusEnum.DELETE_REQUEST_SENT + ) except ObjectDoesNotExist as e: LOGGER.error(f"Object not found: {e}") except ImproperlyConfigured as e: diff --git a/backend/benefit/applications/tests/conftest.py b/backend/benefit/applications/tests/conftest.py index a4237a9a58..b0cec3cbab 100755 --- a/backend/benefit/applications/tests/conftest.py +++ b/backend/benefit/applications/tests/conftest.py @@ -63,6 +63,22 @@ def decided_application(mock_get_organisation_roles_and_create_company): ) +@pytest.fixture +def multiple_decided_applications(mock_get_organisation_roles_and_create_company): + with factory.Faker.override_default_locale("fi_FI"): + return DecidedApplicationFactory.create_batch( + 5, company=mock_get_organisation_roles_and_create_company + ) + + +@pytest.fixture +def multiple_handling_applications(mock_get_organisation_roles_and_create_company): + with factory.Faker.override_default_locale("fi_FI"): + return HandlingApplicationFactory.create_batch( + 5, company=mock_get_organisation_roles_and_create_company + ) + + @pytest.fixture def application_batch(): with factory.Faker.override_default_locale("fi_FI"): diff --git a/backend/benefit/applications/tests/test_ahjo_integration.py b/backend/benefit/applications/tests/test_ahjo_integration.py index b268d2aa8d..258fb25f55 100644 --- a/backend/benefit/applications/tests/test_ahjo_integration.py +++ b/backend/benefit/applications/tests/test_ahjo_integration.py @@ -2,7 +2,7 @@ import os import uuid import zipfile -from datetime import date +from datetime import date, timedelta from typing import List from unittest.mock import patch @@ -10,17 +10,18 @@ from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist from django.http import FileResponse from django.urls import reverse +from django.utils import timezone from applications.api.v1.ahjo_integration_views import AhjoAttachmentView from applications.enums import ( AhjoCallBackStatus, AhjoRequestType, - AhjoStatus, + AhjoStatus as AhjoStatusEnum, ApplicationStatus, AttachmentType, BenefitType, ) -from applications.models import Application, Attachment +from applications.models import AhjoStatus, Application, Attachment from applications.services.ahjo_integration import ( ACCEPTED_TITLE, export_application_batch, @@ -30,6 +31,7 @@ generate_single_approved_file, generate_single_declined_file, get_application_for_ahjo, + get_applications_for_open_case, REJECTED_TITLE, ) from applications.tests.factories import ApplicationFactory, DecidedApplicationFactory @@ -374,11 +376,11 @@ def test_get_attachment_unauthorized_ip_not_allowed( [ ( AhjoRequestType.OPEN_CASE, - AhjoStatus.CASE_OPENED, + AhjoStatusEnum.CASE_OPENED, ), ( AhjoRequestType.DELETE_APPLICATION, - AhjoStatus.DELETE_REQUEST_RECEIVED, + AhjoStatusEnum.DELETE_REQUEST_RECEIVED, ), ], ) @@ -497,3 +499,34 @@ def test_generate_pdf_summary_as_attachment(decided_application): assert os.path.exists(attachment.attachment_file.path) if os.path.exists(attachment.attachment_file.path): os.remove(attachment.attachment_file.path) + + +@pytest.mark.django_db +def test_get_applications_for_open_case( + multiple_decided_applications, multiple_handling_applications +): + now = timezone.now() + for application in multiple_handling_applications: + status = AhjoStatus.objects.create( + application=application, + status=AhjoStatusEnum.SUBMITTED_BUT_NOT_SENT_TO_AHJO, + ) + + status.created_at = now - timedelta(days=1) + status.save() + + # create all possible statuses for decided_applications, one day apart in the future + for application in multiple_decided_applications: + for index, value in enumerate(AhjoStatusEnum.choices): + ahjo_status = AhjoStatus.objects.create( + application=application, + status=value[0], + ) + ahjo_status.created_at = now + timedelta(days=index) + ahjo_status.save() + + applications_for_open_case = get_applications_for_open_case() + # only handled_applications should be returned as their last AhjoStatus is SUBMITTED_BUT_NOT_SENT_TO_AHJO + # and their application status is HANDLING + assert applications_for_open_case.count() == len(multiple_handling_applications) + assert list(applications_for_open_case) == multiple_handling_applications diff --git a/backend/benefit/applications/tests/test_ahjo_requests.py b/backend/benefit/applications/tests/test_ahjo_requests.py index af60b1bc95..e49f2e30ad 100644 --- a/backend/benefit/applications/tests/test_ahjo_requests.py +++ b/backend/benefit/applications/tests/test_ahjo_requests.py @@ -6,7 +6,7 @@ from django.conf import settings from django.urls import reverse -from applications.enums import AhjoRequestType, AhjoStatus +from applications.enums import AhjoRequestType from applications.services.ahjo_integration import prepare_headers, send_request_to_ahjo @@ -45,24 +45,21 @@ def test_prepare_headers(settings, request_type, decided_application): @pytest.mark.parametrize( - "request_type, request_url, ahjo_status", + "request_type, request_url", [ ( 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, ): headers = {"Authorization": "Bearer test"} @@ -77,9 +74,6 @@ def test_send_request_to_ahjo( ) 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, application_with_ahjo_case_id): diff --git a/backend/benefit/applications/tests/test_application_tasks.py b/backend/benefit/applications/tests/test_application_tasks.py index 04d504db65..beed409646 100755 --- a/backend/benefit/applications/tests/test_application_tasks.py +++ b/backend/benefit/applications/tests/test_application_tasks.py @@ -2,13 +2,15 @@ import random from datetime import timedelta from io import StringIO +from unittest.mock import MagicMock, patch import pytest from django.core.management import call_command from django.utils import timezone from applications.enums import ApplicationStatus -from applications.models import Application, Attachment +from applications.models import AhjoSetting, Application, Attachment +from applications.services.ahjo_authentication import AhjoToken from applications.tests.factories import CancelledApplicationFactory @@ -147,3 +149,74 @@ def test_user_is_notified_of_upcoming_application_deletion(drafts_about_to_be_de f"Notified users of {drafts_about_to_be_deleted.count()} applications about upcoming application deletion" in out.getvalue() ) + + +@pytest.mark.django_db +def test_open_cases_in_ahjo_success(): + # Mock external services + AhjoSetting.objects.create(name="ahjo_code", data={"code": "12345"}) + with patch( + "applications.management.commands.open_cases_in_ahjo.get_token" + ) as mock_get_token, patch( + "applications.management.commands.open_cases_in_ahjo.get_applications_for_open_case" + ) as mock_get_applications, patch( + "applications.management.commands.open_cases_in_ahjo.send_open_case_request_to_ahjo" + ) as mock_send_request, patch( + "applications.management.commands.open_cases_in_ahjo.create_status_for_application" + ) as mock_create_status: + # Setup mock return values + mock_get_token.return_value = MagicMock(AhjoToken) + mock_get_applications.return_value = [MagicMock(spec=Application)] + mock_send_request.return_value = ( + MagicMock(spec=Application), + "{response_text}", + ) + + number_to_open = 1 + + # Call the command + out = StringIO() + call_command("open_cases_in_ahjo", number=number_to_open, stdout=out) + + # Assertions + assert ( + f"Sending request to Ahjo to open cases for {number_to_open} applications" + in out.getvalue() + ) + assert "Successfully submitted open case request" in out.getvalue() + assert mock_send_request.called + assert mock_send_request.call_count == number_to_open + assert mock_create_status.called + assert mock_create_status.call_count == number_to_open + + assert ( + f"Sent open case requests for {number_to_open} applications to Ahjo" + in out.getvalue() + ) + + +@pytest.mark.django_db +def test_open_cases_in_ahjo_dryrun(): + AhjoSetting.objects.create(name="ahjo_code", data={"code": "12345"}) + + with patch( + "applications.management.commands.open_cases_in_ahjo.get_token" + ) as mock_get_token, patch( + "applications.management.commands.open_cases_in_ahjo.get_applications_for_open_case" + ) as mock_get_applications: + number_to_open = 1 + # Setup mock return values + mock_get_token.return_value = MagicMock(AhjoToken) + mock_get_applications.return_value = [MagicMock(spec=Application)] + + # Call the command + out = StringIO() + call_command( + "open_cases_in_ahjo", dry_run=True, number=number_to_open, stdout=out + ) + + # Capture the output + assert ( + f"Would send open case requests for {number_to_open} applications to Ahjo" + in out.getvalue() + )