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}} |
+
+
+
+
+
+
+ aika |
+ tuki/kk |
+ tuki yhteensä € |
+
+
+
+ {% for row in calculation_rows %}
+
+ {{row.start_date}} - {{row.end_date}} |
+ {{row.amount}} |
+ {{application.calculation.calculated_benefit_amount}} |
+
+ {% endfor %}
+
+
+
+
+
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)),