diff --git a/backend/benefit/applications/admin.py b/backend/benefit/applications/admin.py index bc89b9b664..20cfb90097 100644 --- a/backend/benefit/applications/admin.py +++ b/backend/benefit/applications/admin.py @@ -1,4 +1,6 @@ +from django import forms from django.contrib import admin +from django.utils.safestring import mark_safe from applications.models import ( AhjoSetting, @@ -7,6 +9,7 @@ ApplicationBatch, ApplicationLogEntry, Attachment, + DecisionProposalTemplateSection, DeMinimisAid, Employee, ) @@ -107,6 +110,26 @@ class AhjoSettingAdmin(admin.ModelAdmin): search_fields = ["name"] +class SafeTextareaWidget(forms.Textarea): + def format_value(self, value): + # Marks the value as safe, preventing auto-escaping + return mark_safe(value) if value is not None else "" + + +class DecisionProposalTemplateSectionFormAdmin(forms.ModelForm): + class Meta: + model = DecisionProposalTemplateSection + fields = "__all__" + widgets = { + "template_text": SafeTextareaWidget(), + } + + +class DecisionProposalTemplateSectionAdmin(admin.ModelAdmin): + list_display = ["name"] + search_fields = ["name"] + + admin.site.register(Application, ApplicationAdmin) admin.site.register(ApplicationBatch, ApplicationBatchAdmin) admin.site.register(DeMinimisAid) @@ -115,3 +138,6 @@ class AhjoSettingAdmin(admin.ModelAdmin): admin.site.register(ApplicationBasis, ApplicationBasisAdmin) admin.site.register(ApplicationLogEntry) admin.site.register(AhjoSetting, AhjoSettingAdmin) +admin.site.register( + DecisionProposalTemplateSection, DecisionProposalTemplateSectionAdmin +) diff --git a/backend/benefit/applications/api/v1/ahjo_decision_views.py b/backend/benefit/applications/api/v1/ahjo_decision_views.py new file mode 100644 index 0000000000..e5f31a3ecf --- /dev/null +++ b/backend/benefit/applications/api/v1/ahjo_decision_views.py @@ -0,0 +1,67 @@ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema, OpenApiParameter +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from applications.api.v1.serializers.decision_proposal_template import ( + DecisionProposalTemplateSectionSerializer, +) +from applications.enums import ApplicationStatus +from applications.models import Application, DecisionProposalTemplateSection +from applications.services.ahjo_decision_service import process_template_sections +from common.permissions import BFIsHandler + + +class DecisionProposalTemplateSectionList(APIView): + """ + View to list the decision proposal templates with placeholders replaced by actual application data. + * Only handlers are able to access this view. + """ + + permission_classes = [BFIsHandler] + + @extend_schema( + parameters=[ + OpenApiParameter( + name="uuid", + description="UUID of the application", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + ), + ], + description=("API for querying decision proposal templates"), + request=DecisionProposalTemplateSectionSerializer, + ) + def get(self, request, format=None) -> Response: + application_id = self.request.query_params.get("application_id") + try: + application = ( + Application.objects.filter( + pk=application_id, status=ApplicationStatus.ACCEPTED + ) + .prefetch_related("calculation", "company") + .first() + ) + except Application.DoesNotExist: + return Response( + {"message": "Application not found"}, status=status.HTTP_404_NOT_FOUND + ) + + decision_types = self.request.query_params.getlist("decision_type") + + section_types = self.request.query_params.getlist("section_type") + template_sections = DecisionProposalTemplateSection.objects.filter( + section_type__in=section_types, decision_type__in=decision_types + ) + + replaced_template_sections = process_template_sections( + template_sections, application + ) + + serializer = DecisionProposalTemplateSectionSerializer( + replaced_template_sections, many=True + ) + + return Response(serializer.data) diff --git a/backend/benefit/applications/api/v1/ahjo_integration_views.py b/backend/benefit/applications/api/v1/ahjo_integration_views.py index 64782e1944..43396a7458 100644 --- a/backend/benefit/applications/api/v1/ahjo_integration_views.py +++ b/backend/benefit/applications/api/v1/ahjo_integration_views.py @@ -117,6 +117,11 @@ def post(self, request, *args, **kwargs): {"error": "Application not found"}, status=status.HTTP_404_NOT_FOUND ) + if request_type == AhjoRequestType.SEND_DECISION_PROPOSAL: + return self.handle_success_callback( + request, application, callback_data, request_type + ) + if callback_data["message"] == AhjoCallBackStatus.SUCCESS: return self.handle_success_callback( request, application, callback_data, request_type @@ -141,7 +146,9 @@ def handle_success_callback( self._handle_delete_callback() ahjo_status = AhjoStatusEnum.DELETE_REQUEST_RECEIVED info = f"Application was marked for cancellation in Ahjo with request id: {callback_data['requestId']}" - + elif request_type == AhjoRequestType.SEND_DECISION_PROPOSAL: + ahjo_status = AhjoStatusEnum.DECISION_PROPOSAL_ACCEPTED + info = "Decision proposal was sent to Ahjo" AhjoStatus.objects.create(application=application, status=ahjo_status) audit_logging.log( request.user, @@ -196,6 +203,10 @@ def _save_version_series_id( attachment.ahjo_version_series_id = cb_record.get("versionSeriesId") attachment.save() + def handle_decision_proposal_success(self): + # do anything that needs to be done when Ahjo accepts a decision proposal + pass + def _log_failure_details(self, application, callback_data): LOGGER.error( f"Received unsuccessful callback for application {application.id} \ diff --git a/backend/benefit/applications/api/v1/serializers/ahjo_callback.py b/backend/benefit/applications/api/v1/serializers/ahjo_callback.py index 669fcdd9f5..b9b2ce1bec 100644 --- a/backend/benefit/applications/api/v1/serializers/ahjo_callback.py +++ b/backend/benefit/applications/api/v1/serializers/ahjo_callback.py @@ -2,11 +2,11 @@ class AhjoCallbackSerializer(serializers.Serializer): - message = serializers.CharField() - requestId = serializers.UUIDField(format="hex_verbose") + message = serializers.CharField(required=False) + requestId = serializers.UUIDField(format="hex_verbose", required=False) caseId = serializers.CharField(required=False) caseGuid = serializers.UUIDField(format="hex_verbose", required=False) - records = serializers.ListField() + records = serializers.ListField(required=False) # You can add additional validation here if needed def validate_message(self, message): diff --git a/backend/benefit/applications/api/v1/serializers/decision_proposal_template.py b/backend/benefit/applications/api/v1/serializers/decision_proposal_template.py new file mode 100644 index 0000000000..43426a9ad0 --- /dev/null +++ b/backend/benefit/applications/api/v1/serializers/decision_proposal_template.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from applications.models import DecisionProposalTemplateSection + + +class DecisionProposalTemplateSectionSerializer(serializers.ModelSerializer): + class Meta: + model = DecisionProposalTemplateSection + fields = ["id", "name", "section_type", "template_text"] diff --git a/backend/benefit/applications/enums.py b/backend/benefit/applications/enums.py index 75c38760c0..0f60ea5ea3 100644 --- a/backend/benefit/applications/enums.py +++ b/backend/benefit/applications/enums.py @@ -101,6 +101,10 @@ class AttachmentType(models.TextChoices): FULL_APPLICATION = "full_application", _("full application") OTHER_ATTACHMENT = "other_attachment", _("other attachment") PDF_SUMMARY = "pdf_summary", _("pdf summary") + DECISION_TEXT_XML = "decision_text_xml", _("public decision text xml attachment") + DECISION_TEXT_SECRET_XML = "decision_text_secret_xml", _( + "non-public decision text xml attachment" + ) class AttachmentRequirement(models.TextChoices): @@ -188,3 +192,18 @@ class AhjoRequestType(models.TextChoices): OPEN_CASE = "open_case", _("Open case in Ahjo") DELETE_APPLICATION = "delete_application", _("Delete application in Ahjo") UPDATE_APPLICATION = "update_application", _("Update application in Ahjo") + SEND_DECISION_PROPOSAL = "send_decision", _("Send decision to Ahjo") + + +class DecisionProposalTemplateSectionType(models.TextChoices): + DECISION_SECTION = "decision_section", _( + "Template part for the decision section of a application decision proposal" + ) + JUSTIFICATION_SECTION = "justification_section", _( + "Template part for the decision justification section of a decision proposal" + ) + + +class DecisionType(models.TextChoices): + ACCEPTED = "accepted_decision", _("An accepted decision") + DENIED = "denied_decision", _("A denied decision") diff --git a/backend/benefit/applications/management/commands/seed.py b/backend/benefit/applications/management/commands/seed.py index 1d2aaa4987..f367eb04c6 100755 --- a/backend/benefit/applications/management/commands/seed.py +++ b/backend/benefit/applications/management/commands/seed.py @@ -11,19 +11,26 @@ ApplicationBatchStatus, ApplicationOrigin, ApplicationStatus, + DecisionType, ) from applications.models import ( + AhjoDecisionText, AhjoStatus, Application, ApplicationBasis, ApplicationBatch, ) +from applications.tests.common import create_decision_text_for_application from applications.tests.factories import ( + AcceptedDecisionProposalFactory, + AcceptedDecisionProposalJustificationFactory, AdditionalInformationNeededApplicationFactory, ApplicationBatchFactory, ApplicationWithAttachmentFactory, CancelledApplicationFactory, DecidedApplicationFactory, + DeniedDecisionProposalFactory, + DeniedDecisionProposalJustificationFactory, HandlingApplicationFactory, ReceivedApplicationFactory, RejectedApplicationFactory, @@ -74,6 +81,7 @@ def clear_applications(): AhjoStatus.objects.all().delete() Terms.objects.all().delete() User.objects.filter(last_login=None).exclude(username="admin").delete() + AhjoDecisionText.objects.all().delete() def run_seed(number): @@ -95,6 +103,7 @@ def _create_batch( for _ in range(number): if proposal_for_decision == ApplicationStatus.ACCEPTED: app = DecidedApplicationFactory() + create_decision_text_for_application(app) apps.append(app) AhjoStatus.objects.create( status=AhjoStatusEnum.SUBMITTED_BUT_NOT_SENT_TO_AHJO, @@ -102,7 +111,11 @@ def _create_batch( ) elif proposal_for_decision == ApplicationStatus.REJECTED: - apps.append(RejectedApplicationFactory()) + app = RejectedApplicationFactory() + create_decision_text_for_application( + app, decision_type=DecisionType.DENIED + ) + apps.append(app) batch.applications.set(apps) batch.handler = User.objects.filter(is_staff=True).last() batch.save() @@ -166,6 +179,15 @@ def _create_batch( Application.objects.filter(pk__in=ids).update(modified_at=draft_deletion_threshold) Application.objects.exclude(pk__in=ids).update(modified_at=draft_notify_threshold) + _create_templates() + def _past_datetime(days: int) -> datetime: return timezone.now() - timedelta(days=days) + + +def _create_templates(): + AcceptedDecisionProposalFactory(), + AcceptedDecisionProposalJustificationFactory(), + DeniedDecisionProposalFactory(), + DeniedDecisionProposalJustificationFactory(), diff --git a/backend/benefit/applications/migrations/0056_decisionproposaltemplatesection.py b/backend/benefit/applications/migrations/0056_decisionproposaltemplatesection.py new file mode 100644 index 0000000000..c5fbe36256 --- /dev/null +++ b/backend/benefit/applications/migrations/0056_decisionproposaltemplatesection.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.23 on 2024-02-14 08:57 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('applications', '0055_add_attachment_version_id_and_hash_20240131_1620'), + ] + + operations = [ + migrations.CreateModel( + name='DecisionProposalTemplateSection', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='time created')), + ('modified_at', models.DateTimeField(auto_now=True, verbose_name='time modified')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('section_type', models.CharField(choices=[('decision_section', 'Template part for the decision section of a application decision proposal'), ('justification_section', 'Template part for the decision justification section of a decision proposal')], default='decision_section', max_length=64, verbose_name='type of the decision proposal template section')), + ('decision_type', models.CharField(choices=[('accepted_decision', 'An accepted decision'), ('denied_decision', 'A denied decision')], default='accepted_decision', max_length=64, verbose_name='type of the decision')), + ('language', models.CharField(choices=[('fi', 'suomi'), ('sv', 'svenska'), ('en', 'english')], default='fi', max_length=2)), + ('template_text', models.TextField(verbose_name='decision proposal section text content')), + ('name', models.CharField(max_length=256, verbose_name='name of the decision proposal template section')), + ], + options={ + 'verbose_name': 'decision proposal template section', + 'verbose_name_plural': 'decision proposal template sections', + 'db_table': 'bf_applications_decision_proposal_template_section', + }, + ), + ] diff --git a/backend/benefit/applications/migrations/0057_attachment_type_decision_text.py b/backend/benefit/applications/migrations/0057_attachment_type_decision_text.py new file mode 100644 index 0000000000..8deac8cfb1 --- /dev/null +++ b/backend/benefit/applications/migrations/0057_attachment_type_decision_text.py @@ -0,0 +1,42 @@ +# Generated by Django 3.2.23 on 2024-02-14 10:34 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('applications', '0056_decisionproposaltemplatesection'), + ] + + operations = [ + migrations.AlterField( + model_name='attachment', + name='attachment_type', + field=models.CharField(choices=[('employment_contract', 'employment contract'), ('pay_subsidy_decision', 'pay subsidy decision'), ('commission_contract', 'commission contract'), ('education_contract', 'education contract of the apprenticeship office'), ('helsinki_benefit_voucher', 'helsinki benefit voucher'), ('employee_consent', 'employee consent'), ('full_application', 'full application'), ('other_attachment', 'other attachment'), ('pdf_summary', 'pdf summary'), ('decision_text_xml', 'public decision text xml attachment'), ('decision_text_secret_xml', 'non-public decision text xml attachment')], max_length=64, verbose_name='attachment type in business rules'), + ), + migrations.AlterField( + model_name='historicalattachment', + name='attachment_type', + field=models.CharField(choices=[('employment_contract', 'employment contract'), ('pay_subsidy_decision', 'pay subsidy decision'), ('commission_contract', 'commission contract'), ('education_contract', 'education contract of the apprenticeship office'), ('helsinki_benefit_voucher', 'helsinki benefit voucher'), ('employee_consent', 'employee consent'), ('full_application', 'full application'), ('other_attachment', 'other attachment'), ('pdf_summary', 'pdf summary'), ('decision_text_xml', 'public decision text xml attachment'), ('decision_text_secret_xml', 'non-public decision text xml attachment')], max_length=64, verbose_name='attachment type in business rules'), + ), + migrations.CreateModel( + name='AhjoDecisionText', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='time created')), + ('modified_at', models.DateTimeField(auto_now=True, verbose_name='time modified')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('decision_type', models.CharField(choices=[('accepted_decision', 'An accepted decision'), ('denied_decision', 'A denied decision')], default='accepted_decision', max_length=64, verbose_name='type of the decision')), + ('language', models.CharField(choices=[('fi', 'suomi'), ('sv', 'svenska'), ('en', 'english')], default='fi', max_length=2)), + ('decision_text', models.TextField(verbose_name='decision text content')), + ('application', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='applications.application', verbose_name='application')), + ], + options={ + 'verbose_name': 'ahjo decision text', + 'verbose_name_plural': 'ahjo decision texts', + 'db_table': 'bf_applications_ahjo_decision_text', + }, + ), + ] diff --git a/backend/benefit/applications/models.py b/backend/benefit/applications/models.py index fe1bf4ae70..5ab4bc4fbf 100755 --- a/backend/benefit/applications/models.py +++ b/backend/benefit/applications/models.py @@ -20,6 +20,8 @@ ApplicationTalpaStatus, AttachmentType, BenefitType, + DecisionProposalTemplateSectionType, + DecisionType, PaySubsidyGranted, ) from applications.exceptions import ( @@ -942,3 +944,81 @@ class Meta: ordering = ["application__created_at", "created_at"] get_latest_by = "created_at" UniqueConstraint(fields=["application_id", "status"], name="unique_status") + + +class DecisionProposalTemplateSection(UUIDModel, TimeStampedModel): + """Model representing a template section of a decision proposal text, usually either the decision + text or the following justification text. + """ + + section_type = models.CharField( + max_length=64, + verbose_name=_("type of the decision proposal template section"), + choices=DecisionProposalTemplateSectionType.choices, + default=DecisionProposalTemplateSectionType.DECISION_SECTION, + ) + + decision_type = models.CharField( + max_length=64, + verbose_name=_("type of the decision"), + choices=DecisionType.choices, + default=DecisionType.ACCEPTED, + ) + + language = models.CharField( + choices=APPLICATION_LANGUAGE_CHOICES, + default=APPLICATION_LANGUAGE_CHOICES[0][0], + max_length=2, + ) + + template_text = models.TextField( + verbose_name=_("decision proposal section text content") + ) + + name = models.CharField( + max_length=256, verbose_name=_("name of the decision proposal template section") + ) + + def __str__(self): + return self.name + + class Meta: + db_table = "bf_applications_decision_proposal_template_section" + verbose_name = _("decision proposal template section") + verbose_name_plural = _("decision proposal template sections") + + +class AhjoDecisionText(UUIDModel, TimeStampedModel): + """Model representing a submitted decision text submitted to Ahjo for an application.""" + + decision_type = models.CharField( + max_length=64, + verbose_name=_("type of the decision"), + choices=DecisionType.choices, + default=DecisionType.ACCEPTED, + ) + + language = models.CharField( + choices=APPLICATION_LANGUAGE_CHOICES, + default=APPLICATION_LANGUAGE_CHOICES[0][0], + max_length=2, + ) + + decision_text = models.TextField(verbose_name=_("decision text content")) + + application = models.OneToOneField( + Application, + verbose_name=_("application"), + on_delete=models.CASCADE, + ) + + def __str__(self): + return ( + "Ahjo decision text for application %s" + % self.application.application_number + ) + + class Meta: + db_table = "bf_applications_ahjo_decision_text" + verbose_name = _("ahjo decision text") + verbose_name_plural = _("ahjo decision texts") diff --git a/backend/benefit/applications/services/ahjo_decision_service.py b/backend/benefit/applications/services/ahjo_decision_service.py new file mode 100644 index 0000000000..bb7eba35db --- /dev/null +++ b/backend/benefit/applications/services/ahjo_decision_service.py @@ -0,0 +1,37 @@ +from string import Template +from typing import List + +from applications.enums import DecisionProposalTemplateSectionType +from applications.models import Application, DecisionProposalTemplateSection + + +def replace_decision_template_placeholders( + text_to_replace: str, + application: Application, + decision_maker: str = "Päättäjä x (Helsinki-lisä-suunnittelija/Tiimipäällikkö)", +) -> str: + """Replace the placeholders starting with $ in the decision template with real data""" + text_to_replace = Template(text_to_replace) + try: + return text_to_replace.substitute( + decision_maker=decision_maker, + company=application.company.name, + total_amount=application.calculation.calculated_benefit_amount, + benefit_date_range=f"{application.calculation.start_date}-{application.calculation.end_date}", + ) + except Exception as e: + raise ValueError(f"Error in preparing the decision proposal template: {e}") + + +def process_template_sections( + template_sections: List[DecisionProposalTemplateSection], + application: Application, +) -> List[DecisionProposalTemplateSection]: + """Loop through the template sections and conditionally + replace placeholders if section is a decision section""" + for section in template_sections: + if section.section_type == DecisionProposalTemplateSectionType.DECISION_SECTION: + section.template_text = replace_decision_template_placeholders( + section.template_text, application + ) + return template_sections diff --git a/backend/benefit/applications/services/ahjo_integration.py b/backend/benefit/applications/services/ahjo_integration.py index 0fff79252e..9ef27a3ae5 100644 --- a/backend/benefit/applications/services/ahjo_integration.py +++ b/backend/benefit/applications/services/ahjo_integration.py @@ -15,6 +15,7 @@ from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist from django.core.files.base import ContentFile from django.db.models import F, OuterRef, QuerySet, Subquery +from django.template.loader import render_to_string from django.urls import reverse from applications.enums import ( @@ -23,9 +24,16 @@ ApplicationStatus, AttachmentType, ) -from applications.models import AhjoSetting, AhjoStatus, Application, Attachment +from applications.models import ( + AhjoDecisionText, + AhjoSetting, + AhjoStatus, + Application, + Attachment, +) from applications.services.ahjo_authentication import AhjoConnector, AhjoToken from applications.services.ahjo_payload import ( + prepare_decision_proposal_payload, prepare_open_case_payload, prepare_update_application_payload, ) @@ -33,6 +41,7 @@ from applications.services.generate_application_summary import ( generate_application_summary_file, ) +from calculator.enums import RowType from companies.models import Company @@ -374,17 +383,64 @@ def export_application_batch(batch) -> bytes: return generate_zip(pdf_files) -def generate_pdf_summary_as_attachment(application: Application) -> Attachment: - """Generate a pdf summary of the given application and return it as an Attachment.""" - pdf_data = generate_application_summary_file(application) - pdf_file = ContentFile( - pdf_data, f"application_summary_{application.application_number}.pdf" +# Constants +XML_VERSION = "" +PDF_CONTENT_TYPE = "application/pdf" +XML_CONTENT_TYPE = "application/xml" + + +def generate_secret_xml_attachment(application: Application) -> bytes: + calculation_rows = application.calculation.rows.all() + + sub_total_rows = calculation_rows.filter( + row_type=RowType.HELSINKI_BENEFIT_SUB_TOTAL_EUR ) + + context = { + "application": application, + "benefit_type": "Palkan Helsinki-lisä", + "calculation_rows": sub_total_rows, + } + xml_content = render_to_string("secret_decision.xml", context) + return xml_content.encode("utf-8") + + +def generate_public_xml_attachment(content: str) -> bytes: + xml_str = f"""{XML_VERSION}{content}""" + xml_bytes = xml_str.encode("utf-8") + return xml_bytes + + +def generate_application_attachment( + application: Application, type: AttachmentType +) -> Attachment: + """Generate a xml decision of the given application and return it as an Attachment.""" + if type == AttachmentType.PDF_SUMMARY: + attachment_data = generate_application_summary_file(application) + attachment_filename = ( + f"application_summary_{application.application_number}.pdf" + ) + content_type = PDF_CONTENT_TYPE + elif type == AttachmentType.DECISION_TEXT_XML: + decision = AhjoDecisionText.objects.get(application=application) + attachment_data = generate_public_xml_attachment(decision.decision_text) + attachment_filename = f"decision_text_{application.application_number}.xml" + content_type = XML_CONTENT_TYPE + elif type == AttachmentType.DECISION_TEXT_SECRET_XML: + attachment_data = generate_secret_xml_attachment(application) + attachment_filename = ( + f"decision_text_secret_{application.application_number}.xml" + ) + content_type = XML_CONTENT_TYPE + else: + raise ValueError(f"Invalid attachment type {type}") + + attachment_file = ContentFile(attachment_data, attachment_filename) attachment = Attachment.objects.create( application=application, - attachment_file=pdf_file, - content_type="application/pdf", - attachment_type=AttachmentType.PDF_SUMMARY, + attachment_file=attachment_file, + content_type=content_type, + attachment_type=type, ) return attachment @@ -421,12 +477,10 @@ def prepare_headers( def get_application_for_ahjo(id: uuid.UUID) -> Optional[Application]: - """Get the first accepted application.""" - application = ( - Application.objects.filter(pk=id, status=ApplicationStatus.ACCEPTED) - .prefetch_related("attachments", "calculation", "company") - .first() - ) + """Get the first accepted application with attachment, calculation and company.""" + application = Application.objects.select_related( + "calculation", "company", "employee" + ).get(pk=id) if not application: raise ObjectDoesNotExist("No applications found for Ahjo request.") # Check that the handler has an ad_username set, if not, ImproperlyConfigured @@ -489,6 +543,11 @@ def send_request_to_ahjo( data = json.dumps(data) api_url = f"{url_base}/{application.ahjo_case_id}/records" + elif request_type == AhjoRequestType.SEND_DECISION_PROPOSAL: + method = "POST" + api_url = f"{url_base}/{application.ahjo_case_id}/records" + data = json.dumps(data) + try: response = requests.request( method, api_url, headers=headers, timeout=timeout, data=data @@ -497,7 +556,7 @@ def send_request_to_ahjo( if response.ok: LOGGER.info( - f"Request for application {application.id} to Ahjo was successful." + f"Request {request_type} for application {application.id} to Ahjo was successful." ) return application, response.text @@ -527,7 +586,9 @@ def send_open_case_request_to_ahjo( ahjo_auth_token, application, AhjoRequestType.OPEN_CASE ) - pdf_summary = generate_pdf_summary_as_attachment(application) + pdf_summary = generate_application_attachment( + application, AttachmentType.PDF_SUMMARY + ) data = prepare_open_case_payload(application, pdf_summary) return send_request_to_ahjo( @@ -568,11 +629,16 @@ def update_application_in_ahjo(application: Application, ahjo_auth_token: str): ahjo_auth_token, application, AhjoRequestType.UPDATE_APPLICATION ) - pdf_summary = generate_pdf_summary_as_attachment(application) + pdf_summary = generate_application_attachment( + application, AttachmentType.PDF_SUMMARY + ) data = prepare_update_application_payload(application, pdf_summary) result = send_request_to_ahjo( - AhjoRequestType.UPDATE_APPLICATION, headers, application, data + AhjoRequestType.UPDATE_APPLICATION, + headers, + application, + data, ) if result: create_status_for_application( @@ -582,3 +648,47 @@ def update_application_in_ahjo(application: Application, ahjo_auth_token: str): LOGGER.error(f"Object not found: {e}") except ImproperlyConfigured as e: LOGGER.error(f"Improperly configured: {e}") + + +def send_decision_proposal_to_ahjo(application_id: uuid.UUID): + """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.SEND_DECISION_PROPOSAL, + ) + delete_existing_xml_attachments(application) + decision_xml = generate_application_attachment( + application, AttachmentType.DECISION_TEXT_XML + ) + secret_xml = generate_application_attachment( + application, AttachmentType.DECISION_TEXT_SECRET_XML + ) + data = prepare_decision_proposal_payload(application, decision_xml, secret_xml) + application, _ = send_request_to_ahjo( + AhjoRequestType.SEND_DECISION_PROPOSAL, headers, application, data + ) + create_status_for_application( + application, AhjoStatusEnum.DECISION_PROPOSAL_SENT + ) + except ObjectDoesNotExist as e: + LOGGER.error(f"Object not found: {e}") + except ImproperlyConfigured as e: + LOGGER.error(f"Improperly configured: {e}") + + +def delete_existing_xml_attachments(application: Application): + """Delete any existing decision text attachments from the application.""" + # TODO delete files from disk also + Attachment.objects.filter( + application=application, attachment_type=AttachmentType.DECISION_TEXT_XML + ).delete() + Attachment.objects.filter( + application=application, attachment_type=AttachmentType.DECISION_TEXT_SECRET_XML + ).delete() + LOGGER.info( + f"Deleted existing decision text attachments for application {application.id}" + ) diff --git a/backend/benefit/applications/services/ahjo_payload.py b/backend/benefit/applications/services/ahjo_payload.py index ba03ee174d..c3ddd2b7f4 100644 --- a/backend/benefit/applications/services/ahjo_payload.py +++ b/backend/benefit/applications/services/ahjo_payload.py @@ -170,3 +170,49 @@ def prepare_update_application_payload( """Prepare the payload that is sent to Ahjo when an application is updated, \ in this case it only contains a Records dict""" return {"Records": _prepare_case_records(application, pdf_summary, is_update=True)} + + +def prepare_decision_proposal_payload( + application: Application, decision_xml: Attachment, secret_xml: Attachment +) -> dict: + """Prepare the payload that is sent to Ahjo when a decision proposal is created""" + handler = application.calculation.handler + inspector_dict = {"Role": "inspector", "Name": "Tarkastaja, Tero", "ID": "terot"} + # TODO remove hard coded decision maker + decision_maker_dict = {"Role": "decisionMaker", "ID": "U02120013070VH2"} + + main_creator_dict = { + "Role": "mainCreator", + "Name": f"{handler.last_name}, {handler.first_name}", + "ID": handler.ad_username, + } + + proposal_dict = { + "records": [ + { + "Title": "Avustuksen myöntäminen, Työllisyyspalvelut, työllisyydenhoidon Helsinki-lisä vuonna 2024", + "Type": "viranhaltijan päätös", + "PublicityClass": "Julkinen", + "Language": "fi", + "PersonalData": "Sisältää henkilötietoja", + "Documents": [_prepare_record_document_dict(decision_xml)], + "Agents": [ + main_creator_dict, + inspector_dict, + decision_maker_dict, + ], + }, + { + "Title": "Päätöksen liite", + "Type": "viranhaltijan päätöksen liite", + "PublicityClass": "Salassa pidettävä", + "SecurityReasons": ["JulkL (621/1999) 24.1 § 25 k"], + "Language": "fi", + "PersonalData": "Sisältää erityisiä henkilötietoja", + "Documents": [_prepare_record_document_dict(secret_xml)], + "Agents": [main_creator_dict], + }, + ] + } + + return proposal_dict diff --git a/backend/benefit/applications/templates/secret_decision.xml b/backend/benefit/applications/templates/secret_decision.xml new file mode 100644 index 0000000000..afad1eee44 --- /dev/null +++ b/backend/benefit/applications/templates/secret_decision.xml @@ -0,0 +1,53 @@ + + +
+

Työllisyydenhoidon Helsinki-lisän myöntäminen

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Hakemusnumero{{application.application_number}}
Hakija{{application.company.name}}
Y-tunnus{{application.company.business_id}}
Työllistetyn sukunimi{{application.employee.last_name}}
Työllistetyn etunimi{{application.employee.first_name}}
Tukimuoto{{benefit_type}}
+ + + + + + + + + + {% for row in calculation_rows %} + + + + + + {% endfor %} + +
aikatuki/kktuki yhteensä €
{{row.start_date}} - {{row.end_date}}{{row.amount}}{{application.calculation.calculated_benefit_amount}}
+
+ + diff --git a/backend/benefit/applications/tests/common.py b/backend/benefit/applications/tests/common.py index b007446732..dd9af7a1c2 100644 --- a/backend/benefit/applications/tests/common.py +++ b/backend/benefit/applications/tests/common.py @@ -4,7 +4,18 @@ import pytest +from applications.enums import DecisionType +from applications.models import AhjoDecisionText, Application +from applications.services.ahjo_decision_service import ( + replace_decision_template_placeholders, +) from applications.services.csv_export_base import CsvExportBase +from applications.tests.factories import ( + AcceptedDecisionProposalFactory, + AcceptedDecisionProposalJustificationFactory, + DeniedDecisionProposalFactory, + DeniedDecisionProposalJustificationFactory, +) def check_csv_cell_list_lines_generator( @@ -50,3 +61,42 @@ def check_csv_string_lines_generator( ) + default_csv_dialect.lineterminator ) + + +def create_decision_text_for_application( + application: Application, decision_type: DecisionType = DecisionType.ACCEPTED +) -> AhjoDecisionText: + """An utility function to create a decision text for an application. + Used for testing and seeding purposes.""" + text = _generate_decision_text_string(application, decision_type) + _set_handler_to_ahjo_test_user(application) + return AhjoDecisionText.objects.create( + application=application, + decision_text=text, + decision_type=decision_type, + ) + + +def _set_handler_to_ahjo_test_user(application: Application) -> None: + """An utility function to set the handler of an application to the Ahjo test user. + Used only for testing purposes.""" + handler = application.calculation.handler + handler.first_name = "AhjoHYTvalmTA1H2" + handler.last_name = "AhjoHyte2" + handler.ad_username = "ahjohytvalmta1h2" + handler.save() + + +def _generate_decision_text_string( + application: Application, decision_type: DecisionType +) -> str: + if decision_type == DecisionType.ACCEPTED: + decision_section = AcceptedDecisionProposalFactory() + justification_section = AcceptedDecisionProposalJustificationFactory() + else: + decision_section = DeniedDecisionProposalFactory() + justification_section = DeniedDecisionProposalJustificationFactory() + decision_string = f"""
{decision_section.template_text}
\ +
{justification_section.template_text}
""" + + return replace_decision_template_placeholders(decision_string, application) diff --git a/backend/benefit/applications/tests/conftest.py b/backend/benefit/applications/tests/conftest.py index b69491b658..a1915ac867 100755 --- a/backend/benefit/applications/tests/conftest.py +++ b/backend/benefit/applications/tests/conftest.py @@ -11,10 +11,12 @@ from applications.models import Application from applications.services.applications_csv_report import ApplicationsCsvService from applications.tests.factories import ( + AcceptedDecisionProposalFactory, ApplicationBatchFactory, ApplicationFactory, CancelledApplicationFactory, DecidedApplicationFactory, + DeniedDecisionProposalFactory, EmployeeFactory, HandlingApplicationFactory, ReceivedApplicationFactory, @@ -399,6 +401,16 @@ def ahjo_open_case_top_level_dict(decided_application): } +@pytest.fixture() +def accepted_ahjo_decision_section(): + return AcceptedDecisionProposalFactory() + + +@pytest.fixture() +def denied_ahjo_decision_section(): + return DeniedDecisionProposalFactory() + + def split_lines_at_semicolon(csv_string): # split CSV into lines and columns without using the csv library csv_lines = csv_string.splitlines() diff --git a/backend/benefit/applications/tests/factories.py b/backend/benefit/applications/tests/factories.py index 116d32c909..fc7bc91b7c 100755 --- a/backend/benefit/applications/tests/factories.py +++ b/backend/benefit/applications/tests/factories.py @@ -9,6 +9,8 @@ ApplicationStatus, ApplicationStep, BenefitType, + DecisionProposalTemplateSectionType, + DecisionType, PaySubsidyGranted, ) from applications.models import ( @@ -20,6 +22,7 @@ Attachment, ATTACHMENT_CONTENT_TYPE_CHOICES, AttachmentType, + DecisionProposalTemplateSection, DeMinimisAid, Employee, ) @@ -358,3 +361,134 @@ class ApplicationBatchFactory(BaseApplicationBatchFactory): class Meta: model = ApplicationBatch + + +class AcceptedDecisionProposalFactory(factory.django.DjangoModelFactory): + section_type = DecisionProposalTemplateSectionType.DECISION_SECTION + decision_type = DecisionType.ACCEPTED + name = "Myönteisen päätöksen Päätös-osion teksti" + template_text = """

$decision_maker päätti myöntää $company:lle Työnantajan Helsinki-lisää \ +käytettäväksi työllistetyn helsinkiläisen työllistämiseksi $total_amount euroa ajalle $benefit_date_range.

+

Helsinki-lisään on varattu talousarviossa Helsingin kaupungin Työllisyyspalveluille \ +vuosittain budjetoitu määräraha. Avustuksen kustannukset maksetaan \ +kaupungin Työllisyyspalveluille osoitetusta määrärahasta talousarvion \ +erikseen määritellyltä kohdalta. Työnantajan Helsinki-lisä on aina harkinnanvarainen.

+""" + + class Meta: + model = DecisionProposalTemplateSection + + +class AcceptedDecisionProposalJustificationFactory(factory.django.DjangoModelFactory): + section_type = DecisionProposalTemplateSectionType.JUSTIFICATION_SECTION + decision_type = DecisionType.ACCEPTED + name = "Myönteisen Päätöksen perustelut-osion teksti" + template_text = """

Helsingin kaupunginhallituksen elinkeinojaosto on 11.9.2023 § 30 päättänyt \ +tukea rahallisesti yksityisen ja kolmannen sektorin työnantajia, \ +jotka tarjoavat työtä kaupungin työllisyydenhoidon \ +kohderyhmiin kuuluville helsinkiläisille.

\ +

Avustus

+

Kaupunginhallituksen elinkeinojaosto on päätöksellään 11.9.2023 § 30 \ +hyväksynyt työnantajan Helsinki-lisän myöntämistä koskevat ehdot. \ +Helsinki-lisän myöntämisessä noudatetaan lisäksi kaupunginhallituksen \ +28.10.2019 § 723 hyväksymiä Helsingin kaupungin avustusten yleisohjeita.

\ +

Helsinki-lisässä on kyse työllistettävän henkilön palkkauskustannuksiin \ +kohdistuvasta avustuksesta. Helsinki-lisä kohdistuu palkan sivukuluihin ja \ +lomarahaan sekä bruttopalkan siihen osaan, jota palkkatuki ei kata. \ +Tuen määrään vaikuttavat samoihin kustannuksiin myönnetyt muut tuet \ +(esim. palkkatuki ja oppisopimuksen koulutuskorvaus). Yritykselle ja taloudellista \ +toimintaa harjoittavalle yhteisölle avustus myönnetään vähämerkityksisenä \ +tukena eli ns. de minimis -tukena, ei koskaan RPA-tukena. \ +Yleishyödyllisille yhteisöille avustus myönnetään valtiontukisääntelyn \ +ulkopuolisena tukena, jos yhdistys ei harjoita taloudellista toimintaa

+

Avustusta myönnetään valtiontukisäännöissä määrätyn kasautumissäännö \ +sekä tuen enimmäisintensiteetin mukaisesti (tuen määrä suhteessa \ +tukikelpoisiin kustannuksiin). Helsinki-lisä voi olla enintään 800 euroa \ +kuukaudessa. Avustusta ei saa siirtää toisen tahon tai henkilön käytettäväksi. \ +Avustus maksetaan hakemuksessa ilmoitetulle pankkitilille.

\ +

Helsinki-lisää saa käyttää ainoastaan kaupunginhallituksen elinkeinojaosto \ +päätöksen 11.9.2023 §30 mukaisiin tarkoituksiin. Avustuksen saajan tulee \ +viipymättä palauttaa virheellisesti, liikaa tai ilmeisen perusteettomasti saamansa avustus. \ +Väärinkäyttötapauksessa kaupunki voi periä maksetun avustuksen takaisin.

+

Avustuksen saaja sitoutuu antamaan tarvittaessa kaupungille tarvittavat tiedot sen \ +varmistamiseksi, että avustusta ei ole käytetty ehtojen vastaisesti. \ +Mikäli avustus maksetaan ennen päätöksen lainvoimaisuutta, avustuksen saaja sitoutuu \ +palauttamaan jo maksetut avustukset, jos päätös muutoksenhaun johdosta muuttuu. \ +

\ +

Valtiontukiarviointi

\ +

Yritykselle ja taloudellista toimintaa harjoittavalle yhteisölle avustus \ +myönnetään vähämerkityksisenä tukena eli ns. de minimis -tukena. Tuen myöntämisessä \ +noudatetaan komission asetusta (EU) 2023/2831, annettu 13.12.2023, Euroopan unionista \ +tehdyn sopimuksen 107 ja 108 artiklan soveltamisesta vähämerkityksiseen \ +tukeen (EUVL L2023/281, 15.12.2023).

\ +

Kullekin hakijalle myönnettävän de minimis -tuen määrä ilmenee \ +hakijakohtaisesta liitteestä. Avustuksen saajalle voidaan myöntää de minimis \ +-tukena enintään 300 000 euroa kuluvan vuoden ja kahden sitä edeltäneen kahden \ +vuoden muodostaman jakson aikana. Avustuksen saaja vastaa siitä, että eri tahojen \ +(mm. ministeriöt, ministeriöiden alaiset viranomaiset, Business Finland, Finnvera Oyj, \ +kunnat, maakuntien liitot) myöntämien de minimis -tukien yhteismäärä ei ylitä tätä määrää. \ +Avustuksen saaja on avustushakemuksessa \ +ilmoittanut kaupungille kaikkien saamiensa de minimis -tukien määrät ja myöntöajankohdat.

+""" + + class Meta: + model = DecisionProposalTemplateSection + + +class DeniedDecisionProposalFactory(factory.django.DjangoModelFactory): + section_type = DecisionProposalTemplateSectionType.DECISION_SECTION + decision_type = DecisionType.DENIED + name = "Kielteisen päätöksen Päätöksen päätös-osion teksti" + template_text = """

$decision_maker päätti hylätä $company:n hakemuksen koskien Työnantajan \ +Helsinki-lisää, koska myöntämisen ehdot eivät täyty.

\ +

Helsinki-lisään on varattu talousarviossa Helsingin kaupungin Työllisyyspalveluille \ +vuosittain budjetoitu määräraha. Avustuksen kustannukset maksetaan kaupungin \ +Työllisyyspalveluille osoitetusta määrärahasta. Työnantajan Helsinki-lisä on aina harkinnanvarainen.

+""" + + class Meta: + model = DecisionProposalTemplateSection + + +class DeniedDecisionProposalJustificationFactory(factory.django.DjangoModelFactory): + section_type = DecisionProposalTemplateSectionType.JUSTIFICATION_SECTION + decision_type = DecisionType.DENIED + name = "Kielteisen päätöksen Päätöksen perustelut-osion teksti" + template_text = """

Helsingin kaupunginhallituksen elinkeinojaosto on 11.9.2023 § 30 päättänyt \ +tukea rahallisesti yksityisen ja kolmannen sektorin työnantajia, \ +jotka tarjoavat työtä kaupungin työllisyydenhoidon kohderyhmiin \ +kuuluville helsinkiläisille.

\ +

Lisätään/kirjoitetaan jokaisen päätöksen yksittäinen perustelu tähän.

\ +

Työnantajan hakemus ei täytä Helsinki-lisän ehtoja, joten avustusta ei voida myöntää.

\ +

Avustus

+

Kaupunginhallituksen elinkeinojaosto on päätöksellään 11.9.2023 § 30 \ +hyväksynyt työnantajan Helsinki-lisän myöntämistä koskevat ehdot. \ +Helsinki-lisän myöntämisessä noudatetaan lisäksi kaupunginhallituksen \ +28.10.2019 § 723 hyväksymiä Helsingin kaupungin avustusten yleisohjeita.

\ +

Helsinki-lisässä on kyse työllistettävän henkilön palkkauskustannuksiin \ +kohdistuvasta avustuksesta. Helsinki-lisä kohdistuu palkan sivukuluihin ja \ +lomarahaan sekä bruttopalkan siihen osaan, jota palkkatuki ei kata. \ +Tuen määrään vaikuttavat samoihin kustannuksiin myönnetyt muut tuet \ +(esim. palkkatuki ja oppisopimuksen koulutuskorvaus). Yritykselle ja taloudellista \ +toimintaa harjoittavalle yhteisölle avustus myönnetään vähämerkityksisenä \ +tukena eli ns. de minimis -tukena, ei koskaan RPA-tukena. \ +Yleishyödyllisille yhteisöille avustus myönnetään valtiontukisääntelyn \ +ulkopuolisena tukena, jos yhdistys ei harjoita taloudellista toimintaa

+

Avustusta myönnetään valtiontukisäännöissä määrätyn kasautumissäännö \ +sekä tuen enimmäisintensiteetin mukaisesti (tuen määrä suhteessa \ +tukikelpoisiin kustannuksiin). Helsinki-lisä voi olla enintään 800 euroa \ +kuukaudessa. Avustusta ei saa siirtää toisen tahon tai henkilön käytettäväksi. \ +Avustus maksetaan hakemuksessa ilmoitetulle pankkitilille.

\ +

Helsinki-lisää saa käyttää ainoastaan kaupunginhallituksen elinkeinojaosto \ +päätöksen 11.9.2023 §30 mukaisiin tarkoituksiin. Avustuksen saajan tulee \ +viipymättä palauttaa virheellisesti, liikaa tai ilmeisen perusteettomasti saamansa avustus. \ +Väärinkäyttötapauksessa kaupunki voi periä maksetun avustuksen takaisin.

+

Avustuksen saaja sitoutuu antamaan tarvittaessa kaupungille tarvittavat tiedot sen \ +varmistamiseksi, että avustusta ei ole käytetty ehtojen vastaisesti. \ +Mikäli avustus maksetaan ennen päätöksen lainvoimaisuutta, avustuksen saaja sitoutuu \ +palauttamaan jo maksetut avustukset, jos päätös muutoksenhaun johdosta muuttuu. \ +

+""" + + class Meta: + model = DecisionProposalTemplateSection diff --git a/backend/benefit/applications/tests/test_ahjo_decisions.py b/backend/benefit/applications/tests/test_ahjo_decisions.py new file mode 100644 index 0000000000..61ab4441bb --- /dev/null +++ b/backend/benefit/applications/tests/test_ahjo_decisions.py @@ -0,0 +1,82 @@ +import pytest +from rest_framework.reverse import reverse + +from applications.enums import DecisionProposalTemplateSectionType, DecisionType +from applications.services.ahjo_decision_service import ( + replace_decision_template_placeholders, +) + + +def test_replace_accepted_decision_template_placeholders( + decided_application, accepted_ahjo_decision_section +): + replaced_template = replace_decision_template_placeholders( + accepted_ahjo_decision_section.template_text, decided_application + ) + + assert decided_application.company.name in replaced_template + assert ( + f"{decided_application.calculation.calculated_benefit_amount}" + in replaced_template + ) + assert ( + f"{decided_application.calculation.start_date}-{decided_application.calculation.end_date}" + in replaced_template + ) + + +def test_replace_denied_decision_template_placeholders( + decided_application, denied_ahjo_decision_section +): + replaced_template = replace_decision_template_placeholders( + denied_ahjo_decision_section.template_text, decided_application + ) + + assert decided_application.company.name in replaced_template + + +def test_anonymous_client_cannot_access_templates( + anonymous_client, decided_application +): + url = f"""{reverse("template_section_list")}?application_id={decided_application.id}""" + response = anonymous_client.get(url) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "decision_type,section_type", + [ + ( + DecisionType.ACCEPTED, + DecisionProposalTemplateSectionType.DECISION_SECTION, + ), + ( + DecisionType.ACCEPTED, + DecisionProposalTemplateSectionType.JUSTIFICATION_SECTION, + ), + ( + DecisionType.DENIED, + DecisionProposalTemplateSectionType.DECISION_SECTION, + ), + ( + DecisionType.DENIED, + DecisionProposalTemplateSectionType.JUSTIFICATION_SECTION, + ), + ], +) +def test_handler_gets_the_template_sections( + decided_application, handler_api_client, decision_type, section_type +): + url = f"""{reverse("template_section_list")}?application_id={decided_application.id}\ +&decision_type={decision_type}\ +§ion_type={section_type}""" + response = handler_api_client.get(url) + assert response.status_code == 200 + for template in response.data: + assert template.decision_type == decision_type + assert template.section_type == section_type + if ( + template.section_type + == DecisionProposalTemplateSectionType.DECISION_SECTION + ): + assert decided_application.company.name in template.template_text diff --git a/backend/benefit/applications/tests/test_ahjo_integration.py b/backend/benefit/applications/tests/test_ahjo_integration.py index 9beffea984..179add65e6 100644 --- a/backend/benefit/applications/tests/test_ahjo_integration.py +++ b/backend/benefit/applications/tests/test_ahjo_integration.py @@ -21,13 +21,13 @@ AttachmentType, BenefitType, ) -from applications.models import AhjoStatus, Application, Attachment +from applications.models import AhjoDecisionText, AhjoStatus, Application, Attachment from applications.services.ahjo_integration import ( ACCEPTED_TITLE, export_application_batch, ExportFileInfo, + generate_application_attachment, generate_composed_files, - generate_pdf_summary_as_attachment, generate_single_approved_file, generate_single_declined_file, get_application_for_ahjo, @@ -415,7 +415,9 @@ def test_ahjo_callback_success( ): settings.NEXT_PUBLIC_MOCK_FLAG = True auth_headers = {"HTTP_AUTHORIZATION": "Token " + ahjo_user_token.key} - attachment = generate_pdf_summary_as_attachment(decided_application) + attachment = generate_application_attachment( + decided_application, AttachmentType.PDF_SUMMARY + ) attachment_hash_value = hash_file(attachment.attachment_file) attachment.ahjo_hash_value = attachment_hash_value attachment.save() @@ -543,7 +545,9 @@ def test_get_application_for_ahjo_no_ad_username(decided_application): @pytest.mark.django_db def test_generate_pdf_summary_as_attachment(decided_application): - attachment = generate_pdf_summary_as_attachment(decided_application) + attachment = generate_application_attachment( + decided_application, AttachmentType.PDF_SUMMARY + ) assert isinstance(attachment, Attachment) assert attachment.application == decided_application @@ -559,6 +563,49 @@ def test_generate_pdf_summary_as_attachment(decided_application): os.remove(attachment.attachment_file.path) +@pytest.mark.django_db +def test_generate_ahjo_public_decision_text_xml(decided_application): + AhjoDecisionText.objects.create( + application=decided_application, decision_text="test" + ) + attachment = generate_application_attachment( + decided_application, AttachmentType.DECISION_TEXT_XML + ) + assert isinstance(attachment, Attachment) + + assert attachment.application == decided_application + assert attachment.content_type == "application/xml" + assert attachment.attachment_type == AttachmentType.DECISION_TEXT_XML + assert ( + attachment.attachment_file.name + == f"decision_text_{decided_application.application_number}.xml" + ) + assert attachment.attachment_file.size > 0 + 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_generate_ahjo_secret_decision_text_xml(decided_application): + attachment = generate_application_attachment( + decided_application, AttachmentType.DECISION_TEXT_SECRET_XML + ) + assert isinstance(attachment, Attachment) + + assert attachment.application == decided_application + assert attachment.content_type == "application/xml" + assert attachment.attachment_type == AttachmentType.DECISION_TEXT_SECRET_XML + assert ( + attachment.attachment_file.name + == f"decision_text_secret_{decided_application.application_number}.xml" + ) + assert attachment.attachment_file.size > 0 + 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 diff --git a/backend/benefit/helsinkibenefit/urls.py b/backend/benefit/helsinkibenefit/urls.py index 7c521e0de2..106a735df1 100644 --- a/backend/benefit/helsinkibenefit/urls.py +++ b/backend/benefit/helsinkibenefit/urls.py @@ -12,6 +12,7 @@ from rest_framework_nested import routers from applications.api.v1 import application_batch_views, views as application_views +from applications.api.v1.ahjo_decision_views import DecisionProposalTemplateSectionList from applications.api.v1.ahjo_integration_views import ( AhjoAttachmentView, AhjoCallbackView, @@ -81,6 +82,11 @@ TalpaCallbackView.as_view(), name="talpa_callback_url", ), + path( + "v1/decision-proposal-sections/", + DecisionProposalTemplateSectionList.as_view(), + name="template_section_list", + ), path("gdpr-api/v1/user/", UserUuidGDPRAPIView.as_view(), name="gdpr_v1"), path("v1/", include((router.urls, "v1"), namespace="v1")), path("v1/", include(applicant_app_router.urls)),