diff --git a/backend/benefit/applications/models.py b/backend/benefit/applications/models.py index 55f5c90abb..78f5983ba2 100755 --- a/backend/benefit/applications/models.py +++ b/backend/benefit/applications/models.py @@ -399,6 +399,14 @@ def total_deminimis_amount(self): total += deminimis_aid.amount return total + @property + def contact_person(self): + return ( + self.company_contact_person_first_name + + " " + + self.company_contact_person_last_name + ) + def get_log_entry_field(self, to_statuses, field_name): if ( log_entry := self.log_entries.filter(to_status__in=to_statuses) diff --git a/backend/benefit/applications/services/ahjo_integration.py b/backend/benefit/applications/services/ahjo_integration.py index 00be0e7ef2..55c7961b8b 100644 --- a/backend/benefit/applications/services/ahjo_integration.py +++ b/backend/benefit/applications/services/ahjo_integration.py @@ -1,5 +1,8 @@ +import json import logging import os +import urllib.request +import uuid import zipfile from collections import defaultdict from dataclasses import dataclass @@ -12,11 +15,14 @@ from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.db.models import QuerySet +from django.urls import reverse -from applications.enums import ApplicationStatus -from applications.models import AhjoSetting, Application +from applications.enums import 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 from applications.services.applications_csv_report import ApplicationsCsvService +from common.utils import encode_multipart_formdata from companies.models import Company @@ -356,44 +362,132 @@ def export_application_batch(batch) -> bytes: return generate_zip(pdf_files) -def dummy_ahjo_request(): - """Dummy function for preliminary testing of Ahjo integration""" - ahjo_api_url = settings.AHJO_REST_API_URL +def get_token() -> str: + """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(requests) + + 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( "Error: Ahjo auth code not found in database. Please set the 'ahjo_code' setting." ) return - - connector = AhjoConnector(requests) - - if not connector.is_configured(): - LOGGER.warning("AHJO connector is not configured") - return - try: - ahjo_token = connector.get_access_token(ahjo_auth_code["code"]) except Exception as e: LOGGER.warning(f"Error retrieving access token: {e}") return - headers = { - "Authorization": f"Bearer {ahjo_token.access_token}", + + +def prepare_headers(access_token: str, application_uuid: uuid) -> dict: + """Prepare the headers for the Ahjo request.""" + url = reverse("ahjo_callback_url", kwargs={"uuid": str(application_uuid)}) + + return { + "Authorization": f"Bearer {access_token}", + "Accept": "application/hal+json", + "X-CallbackURL": f"{settings.API_BASE_URL}{url}", } - print(headers) + + +def get_application(id: uuid) -> Optional[Application]: + """Get the first accepted application.""" + application = ( + Application.objects.filter(pk=id, status=ApplicationStatus.ACCEPTED) + .prefetch_related("attachments", "calculation", "company") + .first() + ) + if not application: + LOGGER.info("No applications found for Ahjo request.") + # Check that the handler has an ad_username set, if not, log an error and return None + if not application.calculation.handler.ad_username: + LOGGER.error("No ad_username set for the handler for Ahjo request.") + return None + return application + + +def create_status_for_application(application: Application): + """Create a new AhjoStatus for the application.""" + AhjoStatus.objects.create( + application=application, status=AhjoStatusEnum.REQUEST_TO_OPEN_CASE_SENT + ) + + +def do_ahjo_request_with_form_data( + url: str, + headers: dict, + data: dict, + application: Application, +): + json_data = json.dumps(data) + form_data, content_type = encode_multipart_formdata({"case": json_data}) + + headers["Content-Type"] = content_type + + try: + request = urllib.request.Request( + f"{url}/cases", + method="POST", + headers=headers, + data=form_data.encode("utf-8"), + ) + + with urllib.request.urlopen(request) as response: + response_data = response.read() + print(response.status) + print(response_data.decode("utf-8")) + + create_status_for_application(application) + except Exception as e: + # Handle any other error + LOGGER.error(f"Error occurred: {e}") + + +def do_ahjo_request_with_json_payload( + url: str, headers: dict, data: dict, application: Application, timeout: int = 10 +): + headers["Content-Type"] = "application/json" + + json_data = json.dumps(data) + try: - response = requests.get( - f"{ahjo_api_url}/cases", headers=headers, timeout=connector.timout + response = requests.post( + f"{url}/cases", headers=headers, timeout=timeout, data=json_data ) response.raise_for_status() - print(response.json()) + + if response.ok: + create_status_for_application(application) + LOGGER.info( + f"Open case for application {application.id} Request to Ahjo was successful." + ) + except requests.exceptions.HTTPError as e: # Handle the HTTP error - LOGGER.error(f"HTTP error occurred: {e}") + LOGGER.error(f"HTTP error occurred while sending request to Ahjo: {e}") except requests.exceptions.RequestException as e: # Handle the network error - LOGGER.errror(f"Network error occurred: {e}") + LOGGER.error(f"Network error occurred while sending request to Ahjo: {e}") except Exception as e: # Handle any other error - LOGGER.error(f"Error occurred: {e}") + LOGGER.error(f"Error occurred while sending request to Ahjo: {e}") + + +def open_case_in_ahjo(application_id: uuid): + """Open a case in Ahjo.""" + application = get_application(application_id) + # if no suitable application is found, or the handler has no ad_id, bail out + if not application: + return + + ahjo_api_url = settings.AHJO_REST_API_URL + ahjo_token = get_token() + headers = prepare_headers(ahjo_token.access_token, application.id) + data = prepare_open_case_payload(application) + + # do_ahjo_request_with_form_data(ahjo_api_url, headers, data, application) + do_ahjo_request_with_json_payload(ahjo_api_url, headers, data, application) diff --git a/backend/benefit/applications/services/ahjo_payload.py b/backend/benefit/applications/services/ahjo_payload.py new file mode 100644 index 0000000000..c01cafa92c --- /dev/null +++ b/backend/benefit/applications/services/ahjo_payload.py @@ -0,0 +1,135 @@ +import uuid +from datetime import datetime +from typing import List, Union + +from django.conf import settings +from django.urls import reverse +from django.utils.translation import gettext as _ + +from applications.models import Application, Attachment +from common.utils import hash_file +from users.models import User + + +def _prepare_top_level_dict(application: Application, case_records: List[dict]) -> dict: + """Prepare the dictionary that is sent to Ahjo""" + application_date = application.created_at.isoformat() + application_year = application.created_at.year + title = f"Avustuksen myöntäminen, työllisyyspalvelut, \ +työnantajan Helsinki-lisä vuonna {application.created_at.year}, \ +työnantaja {application.company_name}" + + case_dict = { + "Title": title, + "Acquired": application_date, + "ClassificationCode": "02 05 01 00", + "ClassificationTitle": "Kunnan myöntämät avustukset", + "Language": "fi", + "PublicityClass": "Julkinen", + "InternalTitle": f"Avustuksen myöntäminen, työllisyyspalvelut, \ + työnantajan Helsinki-lisä vuonna {application_year}, \ + työnantaja {application.company_name}", + "Subjects": [ + {"Subject": "Helsinki-lisät", "Scheme": "hki-yhpa"}, + {"Subject": "kunnan myöntämät avustukset", "Scheme": "hki-yhpa"}, + {"Subject": "työnantajat", "Scheme": "hki-yhpa"}, + {"Subject": "työllisyydenhoito"}, + ], + "PersonalData": "Sisältää erityisiä henkilötietoja", + "Reference": application.application_number, + "Records": case_records, + "Agents": [ + { + "Role": "sender_initiator", + "CorporateName": application.company.name, + "ContactPerson": application.contact_person, + "Type": "ExterOnal", + "Email": application.company_contact_person_email, + "AddressStreet": application.company.street_address, + "AddressPostalCode": application.company.postcode, + "AddressCity": application.company.city, + } + ], + } + return case_dict + + +def _prepare_record_document_dict(attachment: Attachment) -> dict: + """Prepare a documents dict for a record""" + # 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) + return { + "FileName": f"{attachment.attachment_file.name}", + "FormatName": f"{attachment.content_type}", + "HashAlgorithm": "sha256", + "HashValue": hash_value, + "FileURI": f"{settings.API_BASE_URL}{file_url}", + } + + +def _prepare_record( + record_title: str, + record_type: str, + acquired: datetime, + reference: Union[int, uuid.UUID], + documents: List[dict], + handler: User, + publicity_class: str = "Salassa pidettävä", +): + """Prepare a single record dict for Ahjo.""" + + return { + "Title": record_title, + "Type": record_type, + "Acquired": acquired, + "PublicityClass": publicity_class, + "SecurityReasons": ["JulkL (621/1999) 24.1 § 25 k"], + "Language": "fi", + "PersonalData": "Sisältää erityisiä henkilötietoja", + "Reference": str(reference), + "Documents": documents, + "Agents": [ + { + "Role": "mainCreator", + "Name": f"{handler.last_name}, {handler.first_name}", + "ID": handler.ad_username, + } + ], + } + + +def _prepare_case_records(application: Application) -> List[dict]: + """Prepare the list of case records""" + case_records = [] + handler = application.calculation.handler + main_document_record = _prepare_record( + "Hakemus", + "hakemus", + application.created_at.isoformat(), + application.application_number, + [], # TODO Pdf version of the application goes here with prepare_record() + handler, + ) + + case_records.append(main_document_record) + + for attachment in application.attachments.all(): + document_record = _prepare_record( + "Hakemuksen Liite", + "liite", + attachment.created_at.isoformat(), + attachment.id, + [_prepare_record_document_dict(attachment)], + handler, + ) + case_records.append(document_record) + + return case_records + + +def prepare_open_case_payload(application: Application) -> dict: + "Prepare the complete dictionary payload that is sent to Ahjo" + case_records = _prepare_case_records(application) + payload = _prepare_top_level_dict(application, case_records) + return payload diff --git a/backend/benefit/applications/tests/conftest.py b/backend/benefit/applications/tests/conftest.py index 8e8ba1c98f..8c6aa1a981 100755 --- a/backend/benefit/applications/tests/conftest.py +++ b/backend/benefit/applications/tests/conftest.py @@ -9,6 +9,7 @@ from applications.enums import ApplicationStatus, BenefitType from applications.models import Application +from applications.services.ahjo_payload import prepare_open_case_payload from applications.services.applications_csv_report import ApplicationsCsvService from applications.tests.factories import ( ApplicationBatchFactory, @@ -257,6 +258,79 @@ def set_debug_to_false(settings): settings.DEBUG = False +@pytest.fixture() +def ahjo_payload_record(decided_application): + application = decided_application + + record_title = "Hakemus" + record_type = "hakemus" + acquired = application.created_at.isoformat() + reference = str(application.application_number) + documents = [] + agent = application.calculation.handler + publicity_class = "Salassa pidettävä" + + return { + "Title": record_title, + "Type": record_type, + "Acquired": acquired, + "PublicityClass": publicity_class, + "SecurityReasons": ["JulkL (621/1999) 24.1 § 25 k"], + "Language": "fi", + "PersonalData": "Sisältää erityisiä henkilötietoja", + "Reference": reference, + "Documents": documents, + "Agents": [ + { + "Role": "mainCreator", + "Name": f"{agent.last_name}, {agent.first_name}", + "ID": agent.ad_username, + } + ], + } + + +@pytest.fixture() +def ahjo_open_case_top_level_dict(decided_application): + application = decided_application + title = f"Avustuksen myöntäminen, työllisyyspalvelut, \ +työnantajan Helsinki-lisä vuonna {application.created_at.year}, \ +työnantaja {application.company_name}" + + return { + "Title": title, + "Acquired": application.created_at.isoformat(), + "ClassificationCode": "02 05 01 00", + "ClassificationTitle": "Kunnan myöntämät avustukset", + "Language": "fi", + "PublicityClass": "Julkinen", + "InternalTitle": f"Avustuksen myöntäminen, työllisyyspalvelut, \ + työnantajan Helsinki-lisä vuonna {application.created_at.year}, \ + työnantaja {application.company_name}", + "Subjects": [ + {"Subject": "Helsinki-lisät", "Scheme": "hki-yhpa"}, + {"Subject": "kunnan myöntämät avustukset", "Scheme": "hki-yhpa"}, + {"Subject": "työnantajat", "Scheme": "hki-yhpa"}, + {"Subject": "työllisyydenhoito"}, + ], + "PersonalData": "Sisältää erityisiä henkilötietoja", + "Reference": application.application_number, + "Records": [], + "Agents": [ + { + "Role": "sender_initiator", + "CorporateName": application.company.name, + "ContactPerson": application.contact_person, + "Type": "ExterOnal", + "Email": application.company_contact_person_email, + "AddressStreet": application.company.street_address, + "AddressPostalCode": application.company.postcode, + "AddressCity": application.company.city, + } + ], + } + + def split_lines_at_semicolon(csv_string): # split CSV into lines and columns without using the csv library csv_lines = csv_string.splitlines() @@ -279,3 +353,8 @@ def pytest_sessionfinish(session, exitstatus): except OSError as e: print(f"Error while deleting file in media folder: {e}") print(f"\nTests finished, deleted {number_of_files} files in the media folder") + + +@pytest.fixture() +def ahjo_open_case_payload(decided_application): + return prepare_open_case_payload(decided_application) diff --git a/backend/benefit/applications/tests/test_ahjo_payload.py b/backend/benefit/applications/tests/test_ahjo_payload.py new file mode 100644 index 0000000000..b3fe4e9efa --- /dev/null +++ b/backend/benefit/applications/tests/test_ahjo_payload.py @@ -0,0 +1,95 @@ +from django.urls import reverse + +from applications.services.ahjo_payload import ( + _prepare_case_records, + _prepare_record, + _prepare_record_document_dict, + _prepare_top_level_dict, +) +from common.utils import hash_file + + +def test_prepare_record(decided_application, ahjo_payload_record): + application = decided_application + + record = _prepare_record( + ahjo_payload_record["Title"], + ahjo_payload_record["Type"], + application.created_at.isoformat(), + application.application_number, + [], + application.calculation.handler, + ) + + assert ahjo_payload_record == record + + +def test_prepare_record_document_dict(decided_application, settings): + settings.DEBUG = True + settings.API_BASE_URL = "http://test.com" + attachment = decided_application.attachments.first() + hash_value = hash_file(attachment.attachment_file) + file_url = reverse("ahjo_attachment_url", kwargs={"uuid": attachment.id}) + + want = { + "FileName": attachment.attachment_file.name, + "FormatName": attachment.content_type, + "HashAlgorithm": "sha256", + "HashValue": hash_value, + "FileURI": f"{settings.API_BASE_URL}{file_url}", + } + + got = _prepare_record_document_dict(attachment) + + assert want == got + + +def test_prepare_case_records(decided_application, settings): + settings.DEBUG = True + application = decided_application + handler = application.calculation.handler + handler_name = f"{handler.last_name}, {handler.first_name}" + want = [ + { + "Title": "Hakemus", + "Type": "hakemus", + "Acquired": application.created_at.isoformat(), + "PublicityClass": "Salassa pidettävä", + "SecurityReasons": ["JulkL (621/1999) 24.1 § 25 k"], + "Language": "fi", + "PersonalData": "Sisältää erityisiä henkilötietoja", + "Reference": str(application.application_number), + "Documents": [], + "Agents": [ + { + "Role": "mainCreator", + "Name": handler_name, + "ID": handler.ad_username, + } + ], + } + ] + + for attachment in application.attachments.all(): + document_record = _prepare_record( + "Hakemuksen Liite", + "liite", + attachment.created_at.isoformat(), + attachment.id, + [_prepare_record_document_dict(attachment)], + handler, + ) + + want.append(document_record) + + got = _prepare_case_records(application) + + assert want == got + + +def test_prepare_top_level_dict(decided_application, ahjo_open_case_top_level_dict): + application = decided_application + + got = _prepare_top_level_dict(application, []) + + assert ahjo_open_case_top_level_dict == got diff --git a/backend/benefit/applications/tests/test_ahjo_requests.py b/backend/benefit/applications/tests/test_ahjo_requests.py new file mode 100644 index 0000000000..f490d6b2be --- /dev/null +++ b/backend/benefit/applications/tests/test_ahjo_requests.py @@ -0,0 +1,90 @@ +import uuid +from unittest.mock import patch + +import pytest +import requests +import requests_mock +from django.urls import reverse + +from applications.enums import AhjoStatus +from applications.services.ahjo_integration import ( + do_ahjo_request_with_json_payload, + prepare_headers, +) + + +def test_prepare_headers(settings): + 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={"uuid": str(application_uuid)}) + expected_headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/hal+json", + "X-CallbackURL": f"{settings.API_BASE_URL}{url}", + } + + assert headers == expected_headers + + +def test_do_ahjo_request_with_json_payload_success( + decided_application, 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 + ) + decided_application.refresh_from_db() + assert ( + decided_application.ahjo_status.latest().status + == AhjoStatus.REQUEST_TO_OPEN_CASE_SENT + ) + + +@patch("applications.services.ahjo_integration.LOGGER") +def test_http_error(mock_logger, decided_application, ahjo_open_case_payload): + url = "http://mockedurl.com" + 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) + + 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" + headers = {} + data = ahjo_open_case_payload + application = decided_application + + 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) + + 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" + 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) + + mock_logger.error.assert_called() diff --git a/backend/benefit/helsinkibenefit/settings.py b/backend/benefit/helsinkibenefit/settings.py index 2acddb29cc..ac5ba0c56c 100644 --- a/backend/benefit/helsinkibenefit/settings.py +++ b/backend/benefit/helsinkibenefit/settings.py @@ -20,6 +20,7 @@ default_var_root = environ.Path(checkout_dir("var")) env = environ.Env( + API_BASE_URL=(str, "https://localhost:8000"), DEBUG=(bool, False), SECRET_KEY=(str, ""), MEDIA_ROOT=(environ.Path(), default_var_root("media")), @@ -170,6 +171,8 @@ os.environ["HTTPS"] = "on" +API_BASE_URL = env.str("API_BASE_URL") + BASE_DIR = str(checkout_dir) DEBUG = env.bool("DEBUG")