From 097b09944c83e03ff0dae0848ab07d33f3dc8900 Mon Sep 17 00:00:00 2001 From: Kari Salminen Date: Thu, 18 Apr 2024 17:31:58 +0300 Subject: [PATCH] feat(ks-backend): add create_without_ssn endpoint & tests for it This new endpoint can be used to create youth applications without a permanent Finnish personal identity code refs YJDH-697 --- .../applications/api/v1/serializers.py | 92 ++- .../kesaseteli/applications/api/v1/views.py | 73 ++- ...non_vtj_birthdate_and_home_municipality.py | 23 + ...34_make_social_security_number_optional.py | 20 + .../0035_add_youth_application_creator.py | 26 + backend/kesaseteli/applications/models.py | 74 ++- .../applications/tests/test_excel_export.py | 2 +- ...st_youth_application_create_without_ssn.py | 568 ++++++++++++++++++ .../tests/test_youth_applications_api.py | 81 +-- backend/kesaseteli/common/tests/factories.py | 80 +++ .../kesaseteli/common/tests/test_factories.py | 69 ++- backend/kesaseteli/common/urls.py | 144 +++++ 12 files changed, 1170 insertions(+), 82 deletions(-) create mode 100644 backend/kesaseteli/applications/migrations/0033_add_non_vtj_birthdate_and_home_municipality.py create mode 100644 backend/kesaseteli/applications/migrations/0034_make_social_security_number_optional.py create mode 100644 backend/kesaseteli/applications/migrations/0035_add_youth_application_creator.py create mode 100644 backend/kesaseteli/applications/tests/test_youth_application_create_without_ssn.py diff --git a/backend/kesaseteli/applications/api/v1/serializers.py b/backend/kesaseteli/applications/api/v1/serializers.py index c1d40e654b..b64d1bd802 100644 --- a/backend/kesaseteli/applications/api/v1/serializers.py +++ b/backend/kesaseteli/applications/api/v1/serializers.py @@ -1,5 +1,5 @@ import json -from datetime import datetime +from datetime import date, datetime from typing import Optional, Union import filetype @@ -12,7 +12,11 @@ from rest_framework import serializers from applications.api.v1.validators import validate_additional_info_user_reasons -from applications.enums import AttachmentType, EmployerApplicationStatus +from applications.enums import ( + AttachmentType, + EmployerApplicationStatus, + get_supported_languages, +) from applications.models import ( Attachment, EmployerApplication, @@ -488,6 +492,7 @@ class Meta: ] read_only_fields = [ "id", + "creator", "created_at", "modified_at", "receipt_confirmed_at", @@ -497,6 +502,8 @@ class Meta: "additional_info_user_reasons", "additional_info_description", "additional_info_provided_at", + "non_vtj_birthdate", + "non_vtj_home_municipality", ] + vtj_data_fields fields = read_only_fields + [ "first_name", @@ -521,6 +528,11 @@ class Meta: encrypted_handler_vtj_json = serializers.SerializerMethodField( "get_encrypted_handler_vtj_json" ) + creator = serializers.PrimaryKeyRelatedField( + required=False, + allow_null=True, + queryset=HandlerPermission.get_handler_users_queryset(), + ) handler = serializers.PrimaryKeyRelatedField( required=False, allow_null=True, @@ -560,6 +572,82 @@ class Meta: ] +class NonVtjYouthApplicationSerializer(serializers.ModelSerializer): + """ + Serializer for creating youth applications without VTJ data. + + NOTE: + Use ONLY when applicant has no permanent Finnish personal identity code. + """ + + class Meta: + model = YouthApplication + fields = [ + "first_name", + "last_name", + "non_vtj_birthdate", + "non_vtj_home_municipality", + "school", + "is_unlisted_school", + "email", + "phone_number", + "postcode", + "language", + "receipt_confirmed_at", + "status", + "creator", + "handler", + "additional_info_provided_at", + "additional_info_user_reasons", + "additional_info_description", + ] + + def validate_additional_info_description(self, value): + if value is None or str(value).strip() == "": + raise serializers.ValidationError( + {"additional_info_description": _("Must be set")} + ) + return value + + def validate_language(self, value): + if value not in get_supported_languages(): + raise serializers.ValidationError({"language": _("Invalid language")}) + return value + + def validate_non_vtj_birthdate(self, value): + if not isinstance(value, date): + try: + date.fromisoformat(value) + except (TypeError, ValueError): + raise serializers.ValidationError( + {"non_vtj_birthdate": _("Invalid date")} + ) + return value + + def validate(self, data): + data = super().validate(data) + self.validate_additional_info_description( + data.get("additional_info_description", None) + ) + self.validate_language(data.get("language", None)) + self.validate_non_vtj_birthdate(data.get("non_vtj_birthdate", None)) + return data + + def to_internal_value(self, data): + # Convert optional fields' input values from None to "" + if data.get("additional_info_description", None) is None: + data["additional_info_description"] = "" + if data.get("non_vtj_home_municipality", None) is None: + data["non_vtj_home_municipality"] = "" + return super().to_internal_value(data) + + creator = serializers.PrimaryKeyRelatedField( + required=not HandlerPermission.allow_empty_handler(), + allow_null=HandlerPermission.allow_empty_handler(), + queryset=HandlerPermission.get_handler_users_queryset(), + ) + + class YouthApplicationAdditionalInfoSerializer(serializers.ModelSerializer): def validate_additional_info_user_reasons(self, value): validate_additional_info_user_reasons(value, allow_empty_list=False) diff --git a/backend/kesaseteli/applications/api/v1/views.py b/backend/kesaseteli/applications/api/v1/views.py index b19c842e97..f9d1c13b0b 100644 --- a/backend/kesaseteli/applications/api/v1/views.py +++ b/backend/kesaseteli/applications/api/v1/views.py @@ -10,7 +10,7 @@ from django.db.utils import ProgrammingError from django.http import FileResponse, HttpResponse, HttpResponseRedirect, JsonResponse from django.shortcuts import redirect -from django.utils import translation +from django.utils import timezone, translation from django.utils.decorators import method_decorator from django.utils.text import format_lazy from django.utils.translation import gettext_lazy as _ @@ -34,6 +34,7 @@ AttachmentSerializer, EmployerApplicationSerializer, EmployerSummerVoucherSerializer, + NonVtjYouthApplicationSerializer, SchoolSerializer, YouthApplicationAdditionalInfoSerializer, YouthApplicationHandlingSerializer, @@ -41,6 +42,7 @@ YouthApplicationStatusSerializer, ) from applications.enums import ( + AdditionalInfoUserReason, EmployerApplicationStatus, YouthApplicationRejectedReason, YouthApplicationStatus, @@ -145,7 +147,10 @@ def retrieve(self, request, *args, **kwargs): youth_application: YouthApplication = self.get_object().lock_for_update() # Update unhandled youth applications' encrypted_handler_vtj_json so # handlers can accept/reject using it - if not youth_application.is_handled: + if ( + youth_application.has_social_security_number + and not youth_application.is_handled + ): youth_application.encrypted_handler_vtj_json = ( youth_application.fetch_vtj_json( end_user=VTJClient.get_end_user(request) @@ -293,7 +298,7 @@ def fetch_employee_data(self, request, *args, **kwargs) -> HttpResponse: "employee_name": youth_application.name, "employee_ssn": youth_application.social_security_number, "employee_phone_number": youth_application.phone_number, - "employee_home_city": youth_application.vtj_home_municipality, + "employee_home_city": youth_application.home_municipality, "employee_postcode": youth_application.postcode, "employee_school": youth_application.school, }, @@ -306,7 +311,10 @@ def fetch_employee_data(self, request, *args, **kwargs) -> HttpResponse: def accept(self, request, *args, **kwargs) -> HttpResponse: youth_application: YouthApplication = self.get_object().lock_for_update() - if settings.NEXT_PUBLIC_DISABLE_VTJ: + if ( + settings.NEXT_PUBLIC_DISABLE_VTJ + or not youth_application.has_social_security_number + ): encrypted_handler_vtj_json = None else: try: @@ -351,7 +359,10 @@ def accept(self, request, *args, **kwargs) -> HttpResponse: def reject(self, request, *args, **kwargs) -> HttpResponse: youth_application: YouthApplication = self.get_object().lock_for_update() - if settings.NEXT_PUBLIC_DISABLE_VTJ: + if ( + settings.NEXT_PUBLIC_DISABLE_VTJ + or not youth_application.has_social_security_number + ): encrypted_handler_vtj_json = None else: try: @@ -573,6 +584,58 @@ def create(self, request, *args, **kwargs): # noqa: C901 ) raise + @transaction.atomic + @enforce_handler_view_adfs_login + @action(methods=["post"], detail=False, url_path="create-without-ssn") + def create_without_ssn(self, request, *args, **kwargs) -> HttpResponse: + """ + Create a YouthApplication without a social security number. + """ + try: + data = request.data.copy() + data["is_unlisted_school"] = True + data["receipt_confirmed_at"] = timezone.now() + data["additional_info_provided_at"] = timezone.now() + data["additional_info_user_reasons"] = [AdditionalInfoUserReason.OTHER] + data["status"] = YouthApplicationStatus.ADDITIONAL_INFORMATION_PROVIDED + data["creator"] = request.user.id + data["handler"] = None + + serializer = NonVtjYouthApplicationSerializer( + data=data, context=self.get_serializer_context() + ) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + app: YouthApplication = serializer.instance + + was_email_sent = app.send_processing_email_to_handler(request) + if not was_email_sent: + transaction.set_rollback(True) + with translation.override(app.language): + return HttpResponse( + _("Failed to send manual processing email to handler"), + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + LOGGER.info( + f"Created youth application {app.id} without social security number: " + "Sending application to be processed by a handler" + ) + + headers = self.get_success_headers(serializer.data) + return Response( + data={"id": app.id}, + status=status.HTTP_201_CREATED, + headers=headers, + ) + except ValidationError as e: + LOGGER.error( + "Youth application without social security number " + "submission rejected because of validation error. " + f"Validation error codes: {str(e.get_codes())}" + ) + raise + class EmployerApplicationViewSet(AuditLoggingModelViewSet): queryset = EmployerApplication.objects.all() diff --git a/backend/kesaseteli/applications/migrations/0033_add_non_vtj_birthdate_and_home_municipality.py b/backend/kesaseteli/applications/migrations/0033_add_non_vtj_birthdate_and_home_municipality.py new file mode 100644 index 0000000000..7a0d2791aa --- /dev/null +++ b/backend/kesaseteli/applications/migrations/0033_add_non_vtj_birthdate_and_home_municipality.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.23 on 2024-04-16 11:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('applications', '0032_alter_employersummervoucher_target_group'), + ] + + operations = [ + migrations.AddField( + model_name='youthapplication', + name='non_vtj_birthdate', + field=models.DateField(blank=True, default=None, help_text='Birthdate of person who has no permanent Finnish personal identity code, and thus no data obtainable through the VTJ integration', null=True, verbose_name='non-vtj birthdate'), + ), + migrations.AddField( + model_name='youthapplication', + name='non_vtj_home_municipality', + field=models.CharField(blank=True, default='', help_text='Home municipality of person who has no permanent Finnish personal identity code, and thus no data obtainable through the VTJ integration', max_length=64, verbose_name='non-vtj home municipality'), + ), + ] diff --git a/backend/kesaseteli/applications/migrations/0034_make_social_security_number_optional.py b/backend/kesaseteli/applications/migrations/0034_make_social_security_number_optional.py new file mode 100644 index 0000000000..6cdf50566f --- /dev/null +++ b/backend/kesaseteli/applications/migrations/0034_make_social_security_number_optional.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.23 on 2024-04-16 13:46 + +import common.utils +from django.db import migrations +import encrypted_fields.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('applications', '0033_add_non_vtj_birthdate_and_home_municipality'), + ] + + operations = [ + migrations.AlterField( + model_name='youthapplication', + name='social_security_number', + field=encrypted_fields.fields.SearchField(blank=True, db_index=True, encrypted_field_name='encrypted_social_security_number', hash_key='ee235e39ebc238035a6264c063dd829d4b6d2270604b57ee1f463e676ec44669', max_length=66, null=True, validators=[common.utils.validate_optional_finnish_social_security_number]), + ), + ] diff --git a/backend/kesaseteli/applications/migrations/0035_add_youth_application_creator.py b/backend/kesaseteli/applications/migrations/0035_add_youth_application_creator.py new file mode 100644 index 0000000000..88864e0429 --- /dev/null +++ b/backend/kesaseteli/applications/migrations/0035_add_youth_application_creator.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.23 on 2024-04-19 11:09 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('applications', '0034_make_social_security_number_optional'), + ] + + operations = [ + migrations.AddField( + model_name='youthapplication', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_youth_applications', to=settings.AUTH_USER_MODEL, verbose_name='creator'), + ), + migrations.AlterField( + model_name='youthapplication', + name='handler', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='handled_youth_applications', to=settings.AUTH_USER_MODEL, verbose_name='handler'), + ), + ] diff --git a/backend/kesaseteli/applications/models.py b/backend/kesaseteli/applications/models.py index 70c1f1986e..f565e092a8 100644 --- a/backend/kesaseteli/applications/models.py +++ b/backend/kesaseteli/applications/models.py @@ -41,7 +41,7 @@ are_same_text_lists, are_same_texts, send_mail_with_error_logging, - validate_finnish_social_security_number, + validate_optional_finnish_social_security_number, ) from companies.models import Company from shared.common.utils import MatchesAnyOfQuerySet, social_security_number_birthdate @@ -193,7 +193,27 @@ class YouthApplication(LockForUpdateMixin, TimeStampedModel, UUIDModel): social_security_number = SearchField( hash_key=settings.SOCIAL_SECURITY_NUMBER_HASH_KEY, encrypted_field_name="encrypted_social_security_number", - validators=[validate_finnish_social_security_number], + validators=[validate_optional_finnish_social_security_number], + ) + non_vtj_birthdate = models.DateField( + null=True, + blank=True, + default=None, + verbose_name=_("non-vtj birthdate"), + help_text=_( + "Birthdate of person who has no permanent Finnish personal identity code, " + "and thus no data obtainable through the VTJ integration" + ), + ) + non_vtj_home_municipality = models.CharField( + blank=True, + default="", + max_length=64, + verbose_name=_("non-vtj home municipality"), + help_text=_( + "Home municipality of person who has no permanent Finnish personal identity code, " + "and thus no data obtainable through the VTJ integration" + ), ) school = models.CharField( max_length=256, @@ -244,10 +264,18 @@ class YouthApplication(LockForUpdateMixin, TimeStampedModel, UUIDModel): choices=YouthApplicationStatus.choices, default=YouthApplicationStatus.SUBMITTED, ) + creator = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + related_name="created_youth_applications", + verbose_name=_("creator"), + blank=True, + null=True, + ) handler = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, - related_name="youth_applications", + related_name="handled_youth_applications", verbose_name=_("handler"), blank=True, null=True, @@ -599,8 +627,13 @@ def can_accept_manually(self, handler, encrypted_handler_vtj_json) -> bool: return ( self.status in YouthApplicationStatus.acceptable_values() and HandlerPermission.has_user_permission(handler) + and ( + encrypted_handler_vtj_json is None + or self.is_valid_encrypted_handler_vtj_json(encrypted_handler_vtj_json) + ) and ( settings.NEXT_PUBLIC_DISABLE_VTJ + or not self.has_social_security_number or self.is_valid_encrypted_handler_vtj_json(encrypted_handler_vtj_json) ) and not self.has_youth_summer_voucher @@ -661,8 +694,13 @@ def can_reject(self, handler, encrypted_handler_vtj_json) -> bool: return ( self.status in YouthApplicationStatus.rejectable_values() and HandlerPermission.has_user_permission(handler) + and ( + encrypted_handler_vtj_json is None + or self.is_valid_encrypted_handler_vtj_json(encrypted_handler_vtj_json) + ) and ( settings.NEXT_PUBLIC_DISABLE_VTJ + or not self.has_social_security_number or self.is_valid_encrypted_handler_vtj_json(encrypted_handler_vtj_json) ) ) @@ -689,9 +727,14 @@ def reject(self, handler, encrypted_handler_vtj_json) -> bool: @property def birthdate(self) -> date: """ - Applicant's birthdate based on their social security number. + Applicant's birthdate based on their social security number, + or on their provided birthdate through other means as a fallback. """ - return social_security_number_birthdate(self.social_security_number) + return ( + social_security_number_birthdate(self.social_security_number) + if self.has_social_security_number + else self.non_vtj_birthdate + ) @property def is_9th_grader_age(self) -> bool: @@ -738,9 +781,17 @@ def vtj_home_municipality(self) -> Optional[str]: return vtj_home_municipality[0] return "" + @property + def home_municipality(self) -> Optional[str]: + return ( + self.vtj_home_municipality + if self.has_social_security_number + else self.non_vtj_home_municipality + ) + @property def is_helsinkian(self) -> bool: - return are_same_texts(self.vtj_home_municipality, "Helsinki") + return are_same_texts(self.home_municipality, "Helsinki") @property def is_last_name_as_in_vtj(self) -> bool: @@ -759,6 +810,10 @@ def can_accept_automatically(self) -> bool: and not self.has_youth_summer_voucher ) + @property + def has_social_security_number(self) -> bool: + return bool(self.social_security_number) + @property def need_additional_info(self) -> bool: """ @@ -768,11 +823,10 @@ def need_additional_info(self) -> bool: otherwise False. Note that this value does NOT change based on whether additional info has been provided or not. """ - if settings.NEXT_PUBLIC_DISABLE_VTJ: - return True - return ( - self.is_applicant_dead_according_to_vtj + settings.NEXT_PUBLIC_DISABLE_VTJ + or not self.has_social_security_number + or self.is_applicant_dead_according_to_vtj or not self.is_social_security_number_valid_according_to_vtj or not self.is_last_name_as_in_vtj or not self.is_applicant_in_target_group diff --git a/backend/kesaseteli/applications/tests/test_excel_export.py b/backend/kesaseteli/applications/tests/test_excel_export.py index a43a73cf0d..783a782754 100644 --- a/backend/kesaseteli/applications/tests/test_excel_export.py +++ b/backend/kesaseteli/applications/tests/test_excel_export.py @@ -408,7 +408,7 @@ def youth_application_sorting_key(app: YouthApplication): return app.created_at, app.pk InactiveYouthApplicationFactory() # This should not be exported - apps = ( + apps: List[YouthApplication] = ( ActiveYouthApplicationFactory.create_batch(size=12) + [ YouthApplicationFactory(status=status) diff --git a/backend/kesaseteli/applications/tests/test_youth_application_create_without_ssn.py b/backend/kesaseteli/applications/tests/test_youth_application_create_without_ssn.py new file mode 100644 index 0000000000..10873dcfbb --- /dev/null +++ b/backend/kesaseteli/applications/tests/test_youth_application_create_without_ssn.py @@ -0,0 +1,568 @@ +from datetime import date +from typing import List +from unittest import mock + +import pytest +from django.core import mail +from django.test import override_settings +from django.utils import timezone +from freezegun import freeze_time +from rest_framework import status + +from applications.enums import ( + AdditionalInfoUserReason, + get_supported_languages, + YouthApplicationStatus, +) +from applications.models import YouthApplication +from applications.tests.data.mock_vtj import mock_vtj_person_id_query_not_found_content +from common.urls import ( + get_create_without_ssn_url, + get_django_adfs_login_url, + get_processing_url, + handler_403_url, +) +from shared.common.tests.factories import HandlerUserFactory +from shared.common.tests.utils import utc_datetime + +FROZEN_TEST_TIME = utc_datetime(2023, 12, 31, 23, 59, 59) +NON_TEST_TIME = utc_datetime(2022, 1, 1) + +VALID_TEST_DATA = { + "first_name": "Testi", + "last_name": "Testaaja", + "email": "test@example.org", + "school": "Testikoulu", + "phone_number": "+358-50-1234567", + "postcode": "00123", + "language": "sv", + "non_vtj_birthdate": "2012-12-31", + "non_vtj_home_municipality": "Kirkkonummi", + "additional_info_description": "Testilisätiedot", +} + +EXPECTED_PROCESSING_EMAIL_BODY_TEMPLATE = """ +Seuraava henkilö on pyytänyt Kesäseteliä: + +Testi Testaaja +Postinumero: 00123 +Koulu: Testikoulu +Puhelinnumero: +358-50-1234567 +Sähköposti: test@example.org + +{processing_url} +""".strip() + +REQUIRED_FIELDS = [ + "first_name", + "last_name", + "email", + "school", + "phone_number", + "postcode", + "language", + "non_vtj_birthdate", + "additional_info_description", +] + +OPTIONAL_FIELDS = [ + "non_vtj_home_municipality", +] + + +@pytest.fixture(autouse=True) +def mock_flag_disabled_in_this_file(settings): + settings.NEXT_PUBLIC_MOCK_FLAG = False + + +def test_input_data_partitioning(): + assert len(REQUIRED_FIELDS) == len(set(REQUIRED_FIELDS)) + assert len(OPTIONAL_FIELDS) == len(set(OPTIONAL_FIELDS)) + assert set(REQUIRED_FIELDS).isdisjoint(set(OPTIONAL_FIELDS)) + assert sorted(VALID_TEST_DATA.keys()) == sorted(REQUIRED_FIELDS + OPTIONAL_FIELDS) + + +@pytest.mark.django_db +def test_valid_post_returns_only_created_youth_application_id(staff_client): + assert YouthApplication.objects.count() == 0 + + response = staff_client.post( + get_create_without_ssn_url(), + data=VALID_TEST_DATA, + content_type="application/json", + ) + + assert response.status_code == status.HTTP_201_CREATED + assert YouthApplication.objects.count() == 1 + created_app = YouthApplication.objects.first() + + assert response.json() == {"id": str(created_app.id)} + + +@pytest.mark.django_db +@freeze_time(FROZEN_TEST_TIME) +def test_valid_post(staff_client): + response = staff_client.post( + get_create_without_ssn_url(), + data=VALID_TEST_DATA, + content_type="application/json", + ) + + assert response.status_code == status.HTTP_201_CREATED + + app = YouthApplication.objects.get(id=response.json()["id"]) + + assert app.first_name == "Testi" + assert app.last_name == "Testaaja" + assert app.email == "test@example.org" + assert app.is_unlisted_school is True + assert app.school == "Testikoulu" + assert app.phone_number == "+358-50-1234567" + assert app.postcode == "00123" + assert app.language == "sv" + assert app.non_vtj_birthdate == date(2012, 12, 31) + assert app.non_vtj_home_municipality == "Kirkkonummi" + assert app.additional_info_description == "Testilisätiedot" + + # Check the fields that should remain the same regardless of input data + assert app.social_security_number == "" + assert app.encrypted_original_vtj_json is None + assert app.encrypted_handler_vtj_json is None + assert app.status == YouthApplicationStatus.ADDITIONAL_INFORMATION_PROVIDED.value + assert app.additional_info_user_reasons == ["other"] + assert not app.handler + assert not app.has_youth_summer_voucher + + # Make sure creator was set to request's non-anonymous user + assert response.wsgi_request.user is not None + assert app.creator == response.wsgi_request.user + assert app.creator.id is not None + assert app.creator.is_anonymous is False + + # Make sure timestamps are set correctly + assert app.created_at == timezone.now() + assert app.modified_at == timezone.now() + assert app.receipt_confirmed_at == timezone.now() + assert app.additional_info_provided_at == timezone.now() + assert app.handled_at is None + + +@pytest.mark.django_db +@freeze_time(FROZEN_TEST_TIME) +def test_valid_post_with_extra_fields(staff_client): + creator_given_as_input_data = HandlerUserFactory() + handler_given_as_input_data = HandlerUserFactory() + + response = staff_client.post( + get_create_without_ssn_url(), + data={ + "first_name": "Maija", + "last_name": "Meikäläinen", + "email": "maija@meikalainen.org", + "school": "Maijan testikoulu", + "phone_number": "+358-40-7654321", + "postcode": "12345", + "language": "fi", + "non_vtj_birthdate": "2022-02-28", + "non_vtj_home_municipality": "Vantaa", + "additional_info_description": "Lisätietoja käsittelijöille", + # Extra fields + "social_security_number": "111111-111C", + "encrypted_original_vtj_json": mock_vtj_person_id_query_not_found_content(), + "encrypted_handler_vtj_json": mock_vtj_person_id_query_not_found_content(), + "is_unlisted_school": False, + "status": YouthApplicationStatus.ACCEPTED.value, + "handler": handler_given_as_input_data.pk, + "creator": creator_given_as_input_data.pk, + "created_at": NON_TEST_TIME, + "modified_at": NON_TEST_TIME, + "receipt_confirmed_at": NON_TEST_TIME, + "handled_at": NON_TEST_TIME, + "additional_info_provided_at": NON_TEST_TIME, + "additional_info_user_reasons": [AdditionalInfoUserReason.UNLISTED_SCHOOL], + "inexistent_field": "This field should not exist", + }, + content_type="application/json", + ) + + assert response.status_code == status.HTTP_201_CREATED + app = YouthApplication.objects.get(id=response.json()["id"]) + + assert app.first_name == "Maija" + assert app.last_name == "Meikäläinen" + assert app.email == "maija@meikalainen.org" + assert app.is_unlisted_school is True + assert app.school == "Maijan testikoulu" + assert app.phone_number == "+358-40-7654321" + assert app.postcode == "12345" + assert app.language == "fi" + assert app.non_vtj_birthdate == date(2022, 2, 28) + assert app.non_vtj_home_municipality == "Vantaa" + assert app.additional_info_description == "Lisätietoja käsittelijöille" + + # Check the fields that should remain the same regardless of input data + assert app.social_security_number == "" + assert app.encrypted_original_vtj_json is None + assert app.encrypted_handler_vtj_json is None + assert app.status == YouthApplicationStatus.ADDITIONAL_INFORMATION_PROVIDED.value + assert app.additional_info_user_reasons == ["other"] + assert not app.handler + assert not app.has_youth_summer_voucher + + # Make sure creator was set to request's non-anonymous user + assert response.wsgi_request.user is not None + assert app.creator == response.wsgi_request.user + assert app.creator != creator_given_as_input_data + assert app.creator.id is not None + assert app.creator.is_anonymous is False + + # Make sure timestamps are set correctly + assert app.created_at == timezone.now() + assert app.modified_at == timezone.now() + assert app.receipt_confirmed_at == timezone.now() + assert app.additional_info_provided_at == timezone.now() + assert app.handled_at is None + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "now", + [ + utc_datetime(2022, 1, 1), + utc_datetime(2023, 12, 31, 23, 59, 59), + utc_datetime(2024, 2, 3, 4, 5, 6), + ], +) +def test_valid_post_timestamps(staff_client, now): + with freeze_time(now): + response = staff_client.post( + get_create_without_ssn_url(), + data=VALID_TEST_DATA, + content_type="application/json", + ) + + assert response.status_code == status.HTTP_201_CREATED + app = YouthApplication.objects.get(id=response.json()["id"]) + + assert app.created_at == now + assert app.modified_at == now + assert app.receipt_confirmed_at == now + assert app.additional_info_provided_at == now + assert app.handled_at is None + + +@pytest.mark.django_db +@pytest.mark.parametrize("field_to_remove", OPTIONAL_FIELDS) +def test_valid_post_with_optional_field_missing_uses_field_default( + staff_client, field_to_remove +): + data = VALID_TEST_DATA.copy() + del data[field_to_remove] + + response = staff_client.post( + get_create_without_ssn_url(), + data=data, + content_type="application/json", + ) + + assert response.status_code == status.HTTP_201_CREATED + app = YouthApplication.objects.get(id=response.json()["id"]) + assert ( + getattr(app, field_to_remove) + == YouthApplication._meta.get_field(field_to_remove).get_default() + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize("field_to_empty", OPTIONAL_FIELDS) +@pytest.mark.parametrize("empty_value", [None, "", " "]) +def test_valid_post_with_optional_field_empty_uses_field_default( + staff_client, field_to_empty, empty_value +): + data = VALID_TEST_DATA.copy() + data[field_to_empty] = empty_value + + response = staff_client.post( + get_create_without_ssn_url(), + data=data, + content_type="application/json", + ) + + assert response.status_code == status.HTTP_201_CREATED + app = YouthApplication.objects.get(id=response.json()["id"]) + assert ( + getattr(app, field_to_empty) + == YouthApplication._meta.get_field(field_to_empty).get_default() + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize("field_to_remove", REQUIRED_FIELDS) +def test_post_with_required_field_missing_returns_bad_request( + staff_client, field_to_remove +): + data = VALID_TEST_DATA.copy() + del data[field_to_remove] + + response = staff_client.post( + get_create_without_ssn_url(), + data=data, + content_type="application/json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert not YouthApplication.objects.exists() + + +@pytest.mark.django_db +@pytest.mark.parametrize("field_to_empty", REQUIRED_FIELDS) +@pytest.mark.parametrize("empty_value", [None, "", " \t\n"]) +def test_post_with_required_field_empty_returns_bad_request( + staff_client, field_to_empty, empty_value +): + data = VALID_TEST_DATA.copy() + data[field_to_empty] = empty_value + + response = staff_client.post( + get_create_without_ssn_url(), + data=data, + content_type="application/json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert not YouthApplication.objects.exists() + + +@pytest.mark.django_db +def test_valid_post_as_anonymous_redirects_to_adfs_login(client): + response = client.post( + get_create_without_ssn_url(), + data=VALID_TEST_DATA, + content_type="application/json", + ) + + assert response.wsgi_request.user.is_anonymous + assert response.status_code == status.HTTP_302_FOUND + assert response.url == get_django_adfs_login_url( + redirect_url=get_create_without_ssn_url() + ) + assert not YouthApplication.objects.exists() + + +@pytest.mark.django_db +def test_valid_post_as_non_handler_user_redirects_to_forbidden_page(user_client): + response = user_client.post( + get_create_without_ssn_url(), + data=VALID_TEST_DATA, + content_type="application/json", + ) + + user = response.wsgi_request.user + assert user.is_active + assert not user.is_anonymous + assert not user.is_staff + assert not user.is_superuser + assert response.status_code == status.HTTP_302_FOUND + assert response.url == handler_403_url() + assert not YouthApplication.objects.exists() + + +@pytest.mark.django_db +@override_settings( + EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend", + DEFAULT_FROM_EMAIL="Test sender ", + HANDLER_EMAIL="Test handler ", +) +@pytest.mark.parametrize("youth_application_language", get_supported_languages()) +def test_valid_post_sends_finnish_plaintext_only_processing_email( + staff_client, youth_application_language +): + assert len(mail.outbox) == 0 + data = VALID_TEST_DATA.copy() + data["language"] = youth_application_language + response = staff_client.post( + get_create_without_ssn_url(), + data=data, + content_type="application/json", + ) + + assert response.status_code == status.HTTP_201_CREATED + + assert len(mail.outbox) == 1 + processing_email = mail.outbox[-1] + assert processing_email.subject == "Nuoren kesäsetelihakemus: Testi Testaaja" + assert processing_email.from_email == "Test sender " + assert processing_email.to == ["Test handler "] + assert processing_email.body == EXPECTED_PROCESSING_EMAIL_BODY_TEMPLATE.format( + processing_url=response.wsgi_request.build_absolute_uri( + get_processing_url(pk=response.json()["id"]) + ) + ) + assert processing_email.alternatives == [] # No HTML version + assert processing_email.attachments == [] + + +@pytest.mark.django_db +@override_settings( + EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend", + DEFAULT_FROM_EMAIL="Test sender ", + HANDLER_EMAIL="Test handler ", +) +def test_valid_post_rolls_back_transaction_if_email_sending_fails(staff_client): + with mock.patch.object( + YouthApplication, "send_processing_email_to_handler", return_value=False + ) as send_mail_mock: + response = staff_client.post( + get_create_without_ssn_url(), + data=VALID_TEST_DATA, + content_type="application/json", + ) + send_mail_mock.assert_called_once() + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert not YouthApplication.objects.exists() + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "field,invalid_value,expected_error_codes", + [ + # First name: + ("first_name", "", ["blank"]), + ("first_name", " ", ["blank"]), + ("first_name", "\n", ["blank"]), + # Last name: + ("last_name", "", ["blank"]), + ("last_name", " ", ["blank"]), + ("last_name", "\n", ["blank"]), + # Language: + ("language", "-", ["invalid_choice"]), + ("language", "", ["invalid_choice"]), + ("language", " ", ["invalid_choice"]), + ("language", "\n", ["invalid_choice"]), + ("language", "empty", ["invalid_choice"]), + ("language", "pl", ["invalid_choice"]), + ("language", "asdf", ["invalid_choice"]), + # Non-VTJ birthdate: + ("non_vtj_birthdate", "-", ["invalid"]), + ("non_vtj_birthdate", " ", ["invalid"]), + ("non_vtj_birthdate", "\n", ["invalid"]), + ("non_vtj_birthdate", "empty", ["invalid"]), + ("non_vtj_birthdate", "2022-02-32", ["invalid"]), + ("non_vtj_birthdate", "2022-02-25T13:34:26Z", ["invalid"]), + ("non_vtj_birthdate", "Jan 2 2012", ["invalid"]), + ("non_vtj_birthdate", "1.3.2023", ["invalid"]), + # Postcode: + ("postcode", "-", ["invalid"]), + ("postcode", "", ["blank"]), + ("postcode", " ", ["blank"]), + ("postcode", "\n", ["blank"]), + ("postcode", "empty", ["invalid"]), + ("postcode", "1234", ["invalid"]), + ("postcode", "123456", ["invalid", "max_length"]), + ("postcode", "1234a", ["invalid"]), + # Phone number: + ("phone_number", "-", ["invalid"]), + ("phone_number", "", ["blank"]), + ("phone_number", " ", ["blank"]), + ("phone_number", "\n", ["blank"]), + ("phone_number", "123", ["invalid"]), + ("phone_number", "+400-123456789", ["invalid"]), + # School + ("school", "", ["blank"]), + ("school", " ", ["blank"]), + ("school", "\n", ["blank"]), + # Email + ("email", "", ["blank"]), + ("email", " ", ["blank"]), + ("email", "\n", ["blank"]), + ("email", "test_email_without_at_sign", ["invalid"]), + ("email", "@", ["invalid"]), + ("email", "example.org", ["invalid"]), + ("email", "https://example.org/", ["invalid"]), + ], +) +def test_post_field_validation_failure( + staff_client, field, invalid_value, expected_error_codes: List[str] +): + data = VALID_TEST_DATA.copy() + data[field] = invalid_value + + response = staff_client.post( + get_create_without_ssn_url(), + data=data, + content_type="application/json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert list(response.data.keys()) == [field] + assert sorted(error.code for error in response.data[field]) == sorted( + expected_error_codes + ) + assert not YouthApplication.objects.exists() + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "field,text_length", + [ + ("first_name", 128), + ("last_name", 128), + ("non_vtj_home_municipality", 64), + ("postcode", 5), + ("phone_number", 64), + ("school", 256), + ("email", 254), + ("additional_info_description", 4096), + ], +) +def test_post_field_max_length_validation_succeeds(staff_client, field, text_length): + data = VALID_TEST_DATA.copy() + data[field] = "x" * text_length + + response = staff_client.post( + get_create_without_ssn_url(), + data=data, + content_type="application/json", + ) + + assert response.status_code in [ + status.HTTP_201_CREATED, + status.HTTP_400_BAD_REQUEST, + ] + + # Some other validation error happened related to the field but not max_length + if response.status_code == status.HTTP_400_BAD_REQUEST: + assert list(response.data.keys()) == [field] + assert "max_length" not in [error.code for error in response.data[field]] + assert not YouthApplication.objects.exists() + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "field,text_length", + [ + ("first_name", 129), + ("last_name", 129), + ("non_vtj_home_municipality", 65), + ("postcode", 6), + ("phone_number", 65), + ("school", 257), + ("email", 255), + ("additional_info_description", 4097), + ], +) +def test_post_field_max_length_validation_fails(staff_client, field, text_length): + data = VALID_TEST_DATA.copy() + data[field] = "x" * text_length + + response = staff_client.post( + get_create_without_ssn_url(), + data=data, + content_type="application/json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert list(response.data.keys()) == [field] + assert "max_length" in [error.code for error in response.data[field]] + assert not YouthApplication.objects.exists() diff --git a/backend/kesaseteli/applications/tests/test_youth_applications_api.py b/backend/kesaseteli/applications/tests/test_youth_applications_api.py index ee5efddfe0..d10e3dadef 100644 --- a/backend/kesaseteli/applications/tests/test_youth_applications_api.py +++ b/backend/kesaseteli/applications/tests/test_youth_applications_api.py @@ -4,7 +4,6 @@ import uuid from datetime import date, timedelta from difflib import SequenceMatcher -from enum import auto, Enum from typing import List, NamedTuple, Optional from unittest import mock from urllib.parse import urlparse @@ -12,7 +11,6 @@ import factory.random import pytest from dateutil.relativedelta import relativedelta -from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth.models import AnonymousUser from django.core import mail from django.test import override_settings @@ -53,7 +51,17 @@ YouthApplicationFactory, ) from common.tests.utils import get_random_social_security_number_for_year -from common.urls import handler_403_url, handler_youth_application_processing_url +from common.urls import ( + get_accept_url, + get_activation_url, + get_additional_info_url, + get_create_without_ssn_url, + get_detail_url, + get_list_url, + get_processing_url, + RedirectTo, + reverse_youth_application_action, +) from shared.audit_log.models import AuditLogEntry from shared.common.lang_test_utils import ( assert_email_body_language, @@ -112,30 +120,6 @@ def create_same_person_previous_year_accepted_application( return result -def reverse_youth_application_action(action, pk): - return reverse(f"v1:youthapplication-{action}", kwargs={"pk": pk}) - - -class RedirectTo(Enum): - adfs_login = auto() - handler_403 = auto() - handler_process = auto() - - @staticmethod - def get_redirect_url(redirect_to, youth_application_action, youth_application_pk): - return { - RedirectTo.adfs_login: get_django_adfs_login_url( - redirect_url=reverse_youth_application_action( - youth_application_action, youth_application_pk - ) - ), - RedirectTo.handler_403: handler_403_url(), - RedirectTo.handler_process: handler_youth_application_processing_url( - youth_application_pk - ), - }[redirect_to] - - def get_random_pk() -> uuid.UUID: return uuid.uuid4() @@ -197,6 +181,7 @@ def get_read_only_fields() -> List[str]: """ return [ "id", + "creator", "created_at", "modified_at", "receipt_confirmed_at", @@ -208,6 +193,8 @@ def get_read_only_fields() -> List[str]: "additional_info_user_reasons", "additional_info_description", "additional_info_provided_at", + "non_vtj_birthdate", + "non_vtj_home_municipality", ] @@ -303,42 +290,6 @@ def test_youth_application_serializer_fields(): ) -def get_list_url(): - return reverse("v1:youthapplication-list") - - -def get_activation_url(pk): - return reverse_youth_application_action("activate", pk) - - -def get_detail_url(pk): - return reverse_youth_application_action("detail", pk) - - -def get_processing_url(pk): - return reverse_youth_application_action("process", pk) - - -def get_accept_url(pk): - return reverse_youth_application_action("accept", pk) - - -def get_additional_info_url(pk): - return reverse_youth_application_action("additional-info", pk) - - -def get_reject_url(pk): - return reverse_youth_application_action("reject", pk) - - -def get_django_adfs_login_url(redirect_url): - return "{login_url}?{redirect_field_name}={redirect_url}".format( - login_url=reverse("django_auth_adfs:login"), - redirect_field_name=REDIRECT_FIELD_NAME, - redirect_url=redirect_url, - ) - - def get_test_vtj_json() -> dict: return {"first_name": "Maija", "last_name": "Meikäläinen"} @@ -359,6 +310,7 @@ def get_test_vtj_json() -> dict: "accept", "activate", "additional-info", + "create-without-ssn", "detail", "list", "process", @@ -373,6 +325,7 @@ def get_test_vtj_json() -> dict: ("patch", "accept"), ("patch", "reject"), ("post", "additional-info"), + ("post", "create-without-ssn"), ("post", "list"), ] ], @@ -402,6 +355,8 @@ def test_youth_applications_not_allowed_methods( if action == "list": endpoint_url = get_list_url() + elif action == "create-without-ssn": + endpoint_url = get_create_without_ssn_url() else: endpoint_url = reverse_youth_application_action(action, pk=youth_application.pk) diff --git a/backend/kesaseteli/common/tests/factories.py b/backend/kesaseteli/common/tests/factories.py index d411e9a539..45c1ad48bc 100644 --- a/backend/kesaseteli/common/tests/factories.py +++ b/backend/kesaseteli/common/tests/factories.py @@ -360,6 +360,9 @@ def determine_target_group_social_security_number(youth_application): @factory.django.mute_signals(post_save) class AbstractYouthApplicationFactory(factory.django.DjangoModelFactory): + creator = ( + None # For most cases there's no creator, only non-VTJ applications have one + ) created_at = timezone.now() modified_at = factory.LazyAttribute(determine_modified_at) first_name = factory.Faker("first_name") @@ -415,6 +418,83 @@ class YouthApplicationFactory(AbstractYouthApplicationFactory): status = factory.Faker("random_element", elements=YouthApplicationStatus.values) +class AbstractNonVtjYouthApplicationFactory(AbstractYouthApplicationFactory): + """ + An abstract base class for youth applications created using + YouthApplicationViewSet.create_without_ssn endpoint. + + These youth applications don't have a social security number or VTJ data. + """ + + # Non-VTJ youth applications always have empty social security number and no VTJ data: + social_security_number = "" + encrypted_original_vtj_json = None + encrypted_handler_vtj_json = None + + # Fields that are only applicable to non-VTJ youth applications: + non_vtj_birthdate = factory.Faker("date_of_birth", minimum_age=1, maximum_age=99) + non_vtj_home_municipality = factory.Faker( + "random_element", + elements=[ + "Helsinki", + "Espoo", + "Vantaa", + "Utsjoki", + "Stockholm", + "Tallinn", + "", # Not a required field so may be empty + ], + ) + + # Non-VTJ youth applications should always have a creator + # as they are created by a handler user: + creator = factory.SubFactory(HandlerUserFactory) + + # Non-VTJ youth applications should always have additional info: + additional_info_description = factory.Faker("text", max_nb_chars=80) + + # All non-VTJ youth applications are created into a state from whence they can + # directly be approved/rejected. Thus, all their timestamps should be set to + # the same value i.e. the time of creation: + created_at = timezone.now() + modified_at = timezone.now() + receipt_confirmed_at = timezone.now() + additional_info_provided_at = timezone.now() + + # Fields that always have the same values in all non-VTJ youth applications: + is_unlisted_school = True + additional_info_user_reasons = [AdditionalInfoUserReason.OTHER.value] + + class Meta: + abstract = True + + +class AcceptableNonVtjYouthApplicationFactory(AbstractNonVtjYouthApplicationFactory): + """ + A youth application created using YouthApplicationViewSet.create_without_ssn endpoint. + """ + + status = YouthApplicationStatus.ADDITIONAL_INFORMATION_PROVIDED.value + + +class AcceptedNonVtjYouthApplicationFactory(AbstractNonVtjYouthApplicationFactory): + """ + A youth application created using YouthApplicationViewSet.create_without_ssn endpoint + and then accepted. + """ + + status = YouthApplicationStatus.ACCEPTED.value + + +class RejectedNonVtjYouthApplicationFactory(AbstractNonVtjYouthApplicationFactory): + """ + A youth application created using YouthApplicationViewSet.create_without_ssn endpoint + and then rejected. + """ + + status = YouthApplicationStatus.REJECTED.value + + class UnhandledYouthApplicationFactory(AbstractYouthApplicationFactory): status = factory.Faker( "random_element", elements=YouthApplicationStatus.unhandled_values() diff --git a/backend/kesaseteli/common/tests/test_factories.py b/backend/kesaseteli/common/tests/test_factories.py index 32415ee35a..84091c7a9a 100644 --- a/backend/kesaseteli/common/tests/test_factories.py +++ b/backend/kesaseteli/common/tests/test_factories.py @@ -3,11 +3,17 @@ from freezegun import freeze_time from requests.exceptions import ReadTimeout -from applications.enums import VtjTestCase, YouthApplicationStatus +from applications.enums import ( + AdditionalInfoUserReason, + VtjTestCase, + YouthApplicationStatus, +) from applications.models import YouthApplication from applications.tests.conftest import * # noqa from common.tests.factories import ( + AcceptableNonVtjYouthApplicationFactory, AcceptableYouthApplicationFactory, + AcceptedNonVtjYouthApplicationFactory, AcceptedYouthApplicationFactory, ActiveUnhandledYouthApplicationFactory, ActiveYouthApplicationFactory, @@ -19,11 +25,21 @@ InactiveVtjTestCaseYouthApplicationFactory, InactiveYouthApplicationFactory, RejectableYouthApplicationFactory, + RejectedNonVtjYouthApplicationFactory, RejectedYouthApplicationFactory, UnhandledYouthApplicationFactory, YouthApplicationFactory, YouthSummerVoucherFactory, ) +from shared.common.utils import social_security_number_birthdate + +EXPECTED_NON_VTJ_YOUTH_APPLICATION_ATTRIBUTES = { + "is_unlisted_school": True, + "social_security_number": "", + "encrypted_original_vtj_json": None, + "encrypted_handler_vtj_json": None, + "additional_info_user_reasons": [AdditionalInfoUserReason.OTHER.value], +} @freeze_time() @@ -99,6 +115,21 @@ [YouthApplicationStatus.SUBMITTED.value], {}, ), + ( + AcceptableNonVtjYouthApplicationFactory, + YouthApplicationStatus.ADDITIONAL_INFORMATION_PROVIDED.value, + EXPECTED_NON_VTJ_YOUTH_APPLICATION_ATTRIBUTES, + ), + ( + AcceptedNonVtjYouthApplicationFactory, + YouthApplicationStatus.ACCEPTED.value, + EXPECTED_NON_VTJ_YOUTH_APPLICATION_ATTRIBUTES, + ), + ( + RejectedNonVtjYouthApplicationFactory, + YouthApplicationStatus.REJECTED.value, + EXPECTED_NON_VTJ_YOUTH_APPLICATION_ATTRIBUTES, + ), ], ) def test_youth_application_factory( # noqa: C901 @@ -146,6 +177,39 @@ def test_youth_application_factory( # noqa: C901 assert not youth_application.can_set_additional_info assert youth_application.has_additional_info + assert youth_application.has_social_security_number == bool( + youth_application.social_security_number + ) + assert bool(youth_application.social_security_number) == ( + not youth_application.non_vtj_birthdate + ) + assert bool(youth_application.social_security_number) == ( + not youth_application.creator + ) + + # Non-VTJ youth applications, i.e. youth applications without a social + # security number, are created into state ADDITIONAL_INFORMATION_PROVIDED + # and can only be either ACCEPTED or REJECTED after that: + if not youth_application.has_social_security_number: + assert youth_application.status in [ + YouthApplicationStatus.ADDITIONAL_INFORMATION_PROVIDED.value, + YouthApplicationStatus.ACCEPTED.value, + YouthApplicationStatus.REJECTED.value, + ] + + # non_vtj_home_municipality can only be set if non_vtj_birthdate is set: + assert not ( + youth_application.non_vtj_home_municipality + and not youth_application.non_vtj_birthdate + ) + + if youth_application.has_social_security_number: + assert youth_application.birthdate == social_security_number_birthdate( + youth_application.social_security_number + ) + else: + assert youth_application.birthdate == youth_application.non_vtj_birthdate + # Test VTJ test cases if youth_application.is_vtj_test_case: vtj_test_case = youth_application.vtj_test_case @@ -197,6 +261,9 @@ def test_youth_application_factory( # noqa: C901 AdditionalInfoRequestedYouthApplicationFactory, AdditionalInfoProvidedYouthApplicationFactory, InactiveNeedAdditionalInfoYouthApplicationFactory, + AcceptableNonVtjYouthApplicationFactory, + AcceptedNonVtjYouthApplicationFactory, + RejectedNonVtjYouthApplicationFactory, ], ) @pytest.mark.parametrize("next_public_mock_flag", [False, True]) diff --git a/backend/kesaseteli/common/urls.py b/backend/kesaseteli/common/urls.py index b53cfca236..6d8c052e98 100644 --- a/backend/kesaseteli/common/urls.py +++ b/backend/kesaseteli/common/urls.py @@ -1,11 +1,155 @@ +from enum import auto, Enum from urllib.parse import urljoin from django.conf import settings +from django.contrib.auth import REDIRECT_FIELD_NAME +from rest_framework.reverse import reverse def handler_403_url(): + """ + Get handlers' 403 (i.e. Forbidden) URL. + :return: URL for handlers' 403 page. + """ return urljoin(settings.HANDLER_URL, "/fi/403") def handler_youth_application_processing_url(youth_application_pk): + """ + Get handlers' youth application processing URL. + :param youth_application_pk: Youth application's primary key. + :return: URL for processing the youth application. + """ return urljoin(settings.HANDLER_URL, f"/?id={youth_application_pk}") + + +def get_list_url(): + """ + Get youth application list URL. + :return: URL for youth application list. Can be used for posting new youth + applications. + """ + return reverse("v1:youthapplication-list") + + +def get_create_without_ssn_url(): + """ + Get backend's URL for creating a youth application without social security number. + :return: Backend's URL for creating a youth application without social security number. + """ + return reverse("v1:youthapplication-create-without-ssn") + + +def reverse_youth_application_action(action, pk): + """ + Reverse youth application's action URL. + :param action: Action to reverse, e.g. "accept", "activate", "additional-info", + "detail", "process", "reject" + :param pk: Youth application's primary key. + :return: URL for the given youth application's given action. + """ + return reverse(f"v1:youthapplication-{action}", kwargs={"pk": pk}) + + +def get_activation_url(pk): + """ + Get youth application's activation URL. + :param pk: Youth application's primary key. + :return: URL for activating the youth application. + """ + return reverse_youth_application_action("activate", pk) + + +def get_detail_url(pk): + """ + Get youth application's detail info URL. + :param pk: Youth application's primary key. + :return: URL for viewing the youth application's detail info. + """ + return reverse_youth_application_action("detail", pk) + + +def get_processing_url(pk): + """ + Get youth application's processing URL in the backend, not in the handlers' UI. + :param pk: Youth application's primary key. + :return: URL for processing the youth application. + """ + return reverse_youth_application_action("process", pk) + + +def get_accept_url(pk): + """ + Get youth application's accept URL. + :param pk: Youth application's primary key. + :return: URL for accepting the youth application. + """ + return reverse_youth_application_action("accept", pk) + + +def get_additional_info_url(pk): + """ + Get youth application's additional info URL. + :param pk: Youth application's primary key. + :return: URL for providing additional info for the youth application. + """ + return reverse_youth_application_action("additional-info", pk) + + +def get_reject_url(pk): + """ + Get youth application's reject URL. + :param pk: Youth application's primary key. + :return: URL for rejecting the youth application. + """ + return reverse_youth_application_action("reject", pk) + + +def get_django_adfs_login_url(redirect_url): + """ + Get Django ADFS login URL. + :param redirect_url: URL to redirect to after successful login. + :return: URL for Django ADFS login with the redirect URL as the "next" parameter. + """ + return "{login_url}?{redirect_field_name}={redirect_url}".format( + login_url=reverse("django_auth_adfs:login"), + redirect_field_name=REDIRECT_FIELD_NAME, + redirect_url=redirect_url, + ) + + +class RedirectTo(Enum): + """ + Enum for redirecting to different URLs. + + Enum values: + - adfs_login: Redirect to Django ADFS login. + - handler_403: Redirect to handlers' 403 page. + - handler_process: Redirect to handlers' youth application processing page. + """ + + adfs_login = auto() + handler_403 = auto() + handler_process = auto() + + @staticmethod + def get_redirect_url(redirect_to, youth_application_action, youth_application_pk): + """ + Get redirect URL based on the given redirect enum value. + :param redirect_to: Enum value for redirecting to different URLs. + :param youth_application_action: Action to reverse, e.g. "accept", "activate", + "additional-info", "detail", "process", "reject" + :param youth_application_pk: Youth application's primary key. + :return: URL for the given redirect enum value. + """ + return { + RedirectTo.adfs_login: get_django_adfs_login_url( + redirect_url=reverse_youth_application_action( + youth_application_action, youth_application_pk + ) + ), + RedirectTo.handler_403: handler_403_url(), + RedirectTo.handler_process: handler_youth_application_processing_url( + youth_application_pk + ), + }[redirect_to]