From c4ce7bd5648577b0d9e5a2648f5a749380ff0e4a Mon Sep 17 00:00:00 2001 From: Kari Salminen Date: Tue, 3 Feb 2026 14:51:36 +0200 Subject: [PATCH 1/3] feat(ks,backend): add OrganizationType enum & Company.organization_type refs YJDH-788 --- backend/kesaseteli/applications/enums.py | 24 +++++++++ ...employersummervoucher_job_type_and_more.py | 50 +++++++++++++++++++ backend/kesaseteli/applications/models.py | 7 +++ backend/kesaseteli/common/tests/factories.py | 6 +++ .../0007_company_organization_type.py | 27 ++++++++++ backend/kesaseteli/companies/models.py | 16 +++++- .../companies/tests/test_company_api.py | 4 +- 7 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 backend/kesaseteli/applications/migrations/0045_employersummervoucher_job_type_and_more.py create mode 100644 backend/kesaseteli/companies/migrations/0007_company_organization_type.py diff --git a/backend/kesaseteli/applications/enums.py b/backend/kesaseteli/applications/enums.py index 9a18998230..51c75c8fc0 100644 --- a/backend/kesaseteli/applications/enums.py +++ b/backend/kesaseteli/applications/enums.py @@ -176,6 +176,30 @@ class AttachmentType(models.TextChoices): PAYSLIP = "payslip", _("payslip") +class OrganizationType(models.TextChoices): + COMPANY = "company", _("Company") + ASSOCIATION = "association", _("Association") + PARISH = "parish", _("Parish") + OTHER = "other", _("Other") + + +class JobType(models.TextChoices): + SPORTS_AND_LEISURE = "sports_and_leisure", _("Sports and leisure") + MAINTENANCE_AND_CONSTRUCTION = ( + "maintenance_and_construction", + _("Maintenance and construction"), + ) + RESTAURANT_AND_CAFE = "restaurant_and_cafe", _("Restaurant and cafe sector") + RETAIL = "retail", _("Retail sector") + OFFICE_AND_MEDIA = "office_and_media", _("Office and media") + GARDENING_AND_AGRICULTURE = ( + "gardening_and_agriculture", + _("Gardening and agricultural work"), + ) + SERVICE = "service", _("Service sector") + OTHER = "other", _("Other") + + class HiredWithoutVoucherAssessment(models.TextChoices): YES = "yes", _("yes") NO = "no", _("no") diff --git a/backend/kesaseteli/applications/migrations/0045_employersummervoucher_job_type_and_more.py b/backend/kesaseteli/applications/migrations/0045_employersummervoucher_job_type_and_more.py new file mode 100644 index 0000000000..ea50b472fe --- /dev/null +++ b/backend/kesaseteli/applications/migrations/0045_employersummervoucher_job_type_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 5.1.15 on 2026-02-03 12:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("applications", "0044_remove_employersummervoucher_target_group_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="employersummervoucher", + name="job_type", + field=models.CharField( + blank=True, + choices=[ + ("sports_and_leisure", "Sports and leisure"), + ("maintenance_and_construction", "Maintenance and construction"), + ("restaurant_and_cafe", "Restaurant and cafe sector"), + ("retail", "Retail sector"), + ("office_and_media", "Office and media"), + ("gardening_and_agriculture", "Gardening and agricultural work"), + ("service", "Service sector"), + ("other", "Other"), + ], + max_length=64, + verbose_name="job type", + ), + ), + migrations.AddField( + model_name="historicalemployersummervoucher", + name="job_type", + field=models.CharField( + blank=True, + choices=[ + ("sports_and_leisure", "Sports and leisure"), + ("maintenance_and_construction", "Maintenance and construction"), + ("restaurant_and_cafe", "Restaurant and cafe sector"), + ("retail", "Retail sector"), + ("office_and_media", "Office and media"), + ("gardening_and_agriculture", "Gardening and agricultural work"), + ("service", "Service sector"), + ("other", "Other"), + ], + max_length=64, + verbose_name="job type", + ), + ), + ] diff --git a/backend/kesaseteli/applications/models.py b/backend/kesaseteli/applications/models.py index aec73792eb..c298004c6d 100644 --- a/backend/kesaseteli/applications/models.py +++ b/backend/kesaseteli/applications/models.py @@ -31,6 +31,7 @@ EmailTemplateType, EmployerApplicationStatus, HiredWithoutVoucherAssessment, + JobType, VtjTestCase, YouthApplicationStatus, ) @@ -1370,6 +1371,12 @@ def get_target_group_display(self): employment_description = models.TextField( verbose_name=_("employment description"), blank=True ) + job_type = models.CharField( + max_length=64, + verbose_name=_("job type"), + blank=True, + choices=JobType.choices, + ) hired_without_voucher_assessment = models.CharField( max_length=32, verbose_name=_("hired without voucher assessment"), diff --git a/backend/kesaseteli/common/tests/factories.py b/backend/kesaseteli/common/tests/factories.py index 978fecaa7e..6d8e95a712 100644 --- a/backend/kesaseteli/common/tests/factories.py +++ b/backend/kesaseteli/common/tests/factories.py @@ -15,6 +15,8 @@ EmployerApplicationStatus, get_supported_languages, HiredWithoutVoucherAssessment, + JobType, + OrganizationType, VtjTestCase, YouthApplicationStatus, ) @@ -49,6 +51,9 @@ class CompanyFactory(SaveAfterPostGenerationMixin, factory.django.DjangoModelFac street_address = factory.Faker("street_address") postcode = factory.Faker("postcode") city = factory.Faker("city") + organization_type = factory.Faker( + "random_element", elements=OrganizationType.values + ) ytj_json = factory.Faker("json") class Meta: @@ -101,6 +106,7 @@ class EmployerSummerVoucherFactory( "pydecimal", left_digits=4, right_digits=2, min_value=1 ) employment_description = factory.Faker("sentence") + job_type = factory.Faker("random_element", elements=JobType.values) hired_without_voucher_assessment = factory.Faker( "random_element", elements=HiredWithoutVoucherAssessment.values ) diff --git a/backend/kesaseteli/companies/migrations/0007_company_organization_type.py b/backend/kesaseteli/companies/migrations/0007_company_organization_type.py new file mode 100644 index 0000000000..ddd193561b --- /dev/null +++ b/backend/kesaseteli/companies/migrations/0007_company_organization_type.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.15 on 2026-02-03 12:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("companies", "0006_alter_company_options"), + ] + + operations = [ + migrations.AddField( + model_name="company", + name="organization_type", + field=models.CharField( + blank=True, + choices=[ + ("company", "Company"), + ("association", "Association"), + ("parish", "Parish"), + ("other", "Other"), + ], + max_length=64, + verbose_name="organization type", + ), + ), + ] diff --git a/backend/kesaseteli/companies/models.py b/backend/kesaseteli/companies/models.py index d33541e546..f63531f908 100644 --- a/backend/kesaseteli/companies/models.py +++ b/backend/kesaseteli/companies/models.py @@ -1,6 +1,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from applications.enums import OrganizationType from shared.models.abstract_models import UUIDModel @@ -17,7 +18,20 @@ class Company(UUIDModel): ) postcode = models.CharField(max_length=256, blank=True, verbose_name=_("postcode")) city = models.CharField(max_length=256, blank=True, verbose_name=_("city")) - + organization_type = models.CharField( + # Rationale for this field: + # + # Organization type is similar to company_form data which comes from VTJ, + # but has specific choices requested by product owner for reporting purposes. + # + # For example parish (i.e. seurakunta in Finnish) is a category which + # is at least not evidently mappable from company_form value, and thus + # needs to be asked from the user. + max_length=64, + verbose_name=_("organization type"), + blank=True, + choices=OrganizationType.choices, + ) ytj_json = models.JSONField(blank=True, null=True, verbose_name=_("ytj json")) class Meta: diff --git a/backend/kesaseteli/companies/tests/test_company_api.py b/backend/kesaseteli/companies/tests/test_company_api.py index f9700cf8dc..5d2f410876 100644 --- a/backend/kesaseteli/companies/tests/test_company_api.py +++ b/backend/kesaseteli/companies/tests/test_company_api.py @@ -56,7 +56,7 @@ def test_get_mock_company_not_found_from_ytj(api_client): for field in [ f for f in Company._meta.fields - if f.name not in ["id", "name", "business_id", "ytj_json"] + if f.name not in ["id", "name", "business_id", "organization_type", "ytj_json"] ]: assert response.data[field.name] == "" @@ -154,7 +154,7 @@ def test_get_company_not_found_from_ytj(api_client, requests_mock, user): for field in [ f for f in Company._meta.fields - if f.name not in ["id", "name", "business_id", "ytj_json"] + if f.name not in ["id", "name", "business_id", "organization_type", "ytj_json"] ]: assert response.data[field.name] == "" From c6534c2c7cd9898fdb0e7edfd23f81c6c7819bce Mon Sep 17 00:00:00 2001 From: Kari Salminen Date: Tue, 3 Feb 2026 14:55:19 +0200 Subject: [PATCH 2/3] feat(ks,backend): make Company.business_id unique checked that dev/test/staging/production have only unique business_id values in Company objects, so this should be safe to do refs YJDH-788 --- .../0008_make_company_business_id_unique.py | 19 +++++++++++++++++++ backend/kesaseteli/companies/models.py | 4 +++- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 backend/kesaseteli/companies/migrations/0008_make_company_business_id_unique.py diff --git a/backend/kesaseteli/companies/migrations/0008_make_company_business_id_unique.py b/backend/kesaseteli/companies/migrations/0008_make_company_business_id_unique.py new file mode 100644 index 0000000000..c1b79ec2fc --- /dev/null +++ b/backend/kesaseteli/companies/migrations/0008_make_company_business_id_unique.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.15 on 2026-02-03 12:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("companies", "0007_company_organization_type"), + ] + + operations = [ + migrations.AlterField( + model_name="company", + name="business_id", + field=models.CharField( + max_length=64, unique=True, verbose_name="business id" + ), + ), + ] diff --git a/backend/kesaseteli/companies/models.py b/backend/kesaseteli/companies/models.py index f63531f908..a097df2a41 100644 --- a/backend/kesaseteli/companies/models.py +++ b/backend/kesaseteli/companies/models.py @@ -7,7 +7,9 @@ class Company(UUIDModel): name = models.CharField(max_length=256, verbose_name=_("name")) - business_id = models.CharField(max_length=64, verbose_name=_("business id")) + business_id = models.CharField( + max_length=64, unique=True, verbose_name=_("business id") + ) company_form = models.CharField( max_length=64, blank=True, verbose_name=_("company form") ) From 12e322fd1d7357bf6318deba031a3eaecc682004 Mon Sep 17 00:00:00 2001 From: Kari Salminen Date: Tue, 3 Feb 2026 16:29:36 +0200 Subject: [PATCH 3/3] feat(ks,frontend): add translations for organization type & job type refs YJDH-788 --- .../employer/public/locales/en/common.json | 18 ++++++++++++++++++ .../employer/public/locales/fi/common.json | 18 ++++++++++++++++++ .../employer/public/locales/sv/common.json | 18 ++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/frontend/kesaseteli/employer/public/locales/en/common.json b/frontend/kesaseteli/employer/public/locales/en/common.json index 8674463831..f7bd2ab1c0 100644 --- a/frontend/kesaseteli/employer/public/locales/en/common.json +++ b/frontend/kesaseteli/employer/public/locales/en/common.json @@ -89,6 +89,22 @@ "primary_target_group": "9th grader or TUVA grader", "secondary_target_group": "Other age group" }, + "organization_type": { + "company": "Company", + "association": "Association", + "parish": "Parish", + "other": "Other" + }, + "job_type": { + "sports_and_leisure": "Sports and leisure time", + "maintenance_and_construction": "Maintenance and construction", + "restaurant_and_cafe": "Restaurant and cafe sector", + "retail": "Retail sector", + "office_and_media": "Office and media work", + "gardening_and_agriculture": "Gardening and agricultural work", + "service": "Service sector", + "other": "Other" + }, "hired_without_voucher_assessment": { "yes": "Yes", "no": "No", @@ -96,6 +112,8 @@ } }, "inputs": { + "organization_type": "Organization type", + "job_type": "Main category of work tasks", "contact_person_name": "Name of the contact person", "contact_person_email": "Email of the contact person", "street_address": "Company street address", diff --git a/frontend/kesaseteli/employer/public/locales/fi/common.json b/frontend/kesaseteli/employer/public/locales/fi/common.json index ac98850e20..8421e8f0a2 100644 --- a/frontend/kesaseteli/employer/public/locales/fi/common.json +++ b/frontend/kesaseteli/employer/public/locales/fi/common.json @@ -89,6 +89,22 @@ "primary_target_group": "9. luokkalainen tai TUVA-luokkalainen", "secondary_target_group": "Jokin muu ikäryhmä" }, + "organization_type": { + "company": "Yritys", + "association": "Yhdistys", + "parish": "Seurakunta", + "other": "Muu" + }, + "job_type": { + "sports_and_leisure": "Liikunta- ja vapaa-aika", + "maintenance_and_construction": "Huolto- ja rakennustehtävät", + "restaurant_and_cafe": "Ravintola- ja kahvila-ala", + "retail": "Kaupan ala", + "office_and_media": "Toimisto- ja mediatyö", + "gardening_and_agriculture": "Puutarha- ja maataloustyö", + "service": "Palveluala", + "other": "Muu" + }, "hired_without_voucher_assessment": { "yes": "Kyllä", "no": "En", @@ -96,6 +112,8 @@ } }, "inputs": { + "organization_type": "Organisaatiotyyppi", + "job_type": "Työtehtävien pääluokka", "contact_person_name": "Yhteyshenkilön nimi", "contact_person_email": "Yhteyshenkilön sähköposti", "street_address": "Työpaikan lähiosoite", diff --git a/frontend/kesaseteli/employer/public/locales/sv/common.json b/frontend/kesaseteli/employer/public/locales/sv/common.json index 8f977acd60..8c17d97a25 100644 --- a/frontend/kesaseteli/employer/public/locales/sv/common.json +++ b/frontend/kesaseteli/employer/public/locales/sv/common.json @@ -89,6 +89,22 @@ "primary_target_group": "Niondeklassist eller TUVAklassist", "secondary_target_group": "Annan åldersgrupp" }, + "organization_type": { + "company": "Företag", + "association": "Förening", + "parish": "Församling", + "other": "Annat" + }, + "job_type": { + "sports_and_leisure": "Sport och fritid", + "maintenance_and_construction": "Underhåll och byggande", + "restaurant_and_cafe": "Restaurang och café", + "retail": "Detaljhandel", + "office_and_media": "Kontor och media", + "gardening_and_agriculture": "Trädgårdsarbete och jordbruk", + "service": "Tjänster", + "other": "Annat" + }, "hired_without_voucher_assessment": { "yes": "Jo", "no": "Nej", @@ -96,6 +112,8 @@ } }, "inputs": { + "organization_type": "Organisationstyp", + "job_type": "Huvudkategori av arbetsuppgifter", "contact_person_name": "Kontakt personens namn", "contact_person_email": "Kontakt personens epost", "street_address": "Arbetsplatsens gatuadress",