From 7a7cafc20c42d0a9b6ffbf22d0ba4d5bc59f2e21 Mon Sep 17 00:00:00 2001 From: Kari Salminen Date: Mon, 9 Feb 2026 11:41:07 +0200 Subject: [PATCH] feat(ks,backend): add & populate Company.created_at & modified_at fields refs YJDH-808 --- ..._company_created_at_company_modified_at.py | 28 +++ .../0010_populate_company_timestamps.py | 29 +++ .../companies/migrations/helpers/__init__.py | 0 .../migrations/helpers/company_timestamps.py | 41 ++++ backend/kesaseteli/companies/models.py | 4 +- .../tests/test_populate_company_timestamps.py | 190 ++++++++++++++++++ 6 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 backend/kesaseteli/companies/migrations/0009_company_created_at_company_modified_at.py create mode 100644 backend/kesaseteli/companies/migrations/0010_populate_company_timestamps.py create mode 100644 backend/kesaseteli/companies/migrations/helpers/__init__.py create mode 100644 backend/kesaseteli/companies/migrations/helpers/company_timestamps.py create mode 100644 backend/kesaseteli/companies/tests/test_populate_company_timestamps.py diff --git a/backend/kesaseteli/companies/migrations/0009_company_created_at_company_modified_at.py b/backend/kesaseteli/companies/migrations/0009_company_created_at_company_modified_at.py new file mode 100644 index 0000000000..c333397a9e --- /dev/null +++ b/backend/kesaseteli/companies/migrations/0009_company_created_at_company_modified_at.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.15 on 2026-02-05 11:09 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("companies", "0008_make_company_business_id_unique"), + ] + + operations = [ + migrations.AddField( + model_name="company", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, + default=django.utils.timezone.now, + verbose_name="time created", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="company", + name="modified_at", + field=models.DateTimeField(auto_now=True, verbose_name="time modified"), + ), + ] diff --git a/backend/kesaseteli/companies/migrations/0010_populate_company_timestamps.py b/backend/kesaseteli/companies/migrations/0010_populate_company_timestamps.py new file mode 100644 index 0000000000..357f72b5c9 --- /dev/null +++ b/backend/kesaseteli/companies/migrations/0010_populate_company_timestamps.py @@ -0,0 +1,29 @@ +from django.db import migrations + +from companies.migrations.helpers.company_timestamps import populate_company_timestamps + + +def populate_company_timestamps_with_apps(apps, schema_editor): + """ + Populate Company.created_at and Company.modified_at fields based on the + earliest EmployerApplication.created_at that references each Company. + + Companies without any EmployerApplication references are left unchanged, + because there is no data to determine appropriate timestamps for them. + """ + company_model = apps.get_model("companies", "Company") + populate_company_timestamps(company_model) + + +class Migration(migrations.Migration): + dependencies = [ + ("companies", "0009_company_created_at_company_modified_at"), + ("applications", "0045_employersummervoucher_job_type_and_more"), + ] + + operations = [ + migrations.RunPython( + populate_company_timestamps_with_apps, + migrations.RunPython.noop, + ), + ] diff --git a/backend/kesaseteli/companies/migrations/helpers/__init__.py b/backend/kesaseteli/companies/migrations/helpers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/kesaseteli/companies/migrations/helpers/company_timestamps.py b/backend/kesaseteli/companies/migrations/helpers/company_timestamps.py new file mode 100644 index 0000000000..81e26ab1fd --- /dev/null +++ b/backend/kesaseteli/companies/migrations/helpers/company_timestamps.py @@ -0,0 +1,41 @@ +import logging + +from django.db.models import Min + +LOGGER = logging.getLogger(__name__) + + +def populate_company_timestamps(company_model): + """ + Populate Company.created_at and Company.modified_at fields based on the + earliest EmployerApplication.created_at that references each Company. + + Companies without any EmployerApplication references are left unchanged, + because there is no data to determine appropriate timestamps for them. + """ + total_count = company_model.objects.count() + + companies_with_min_created_at = ( + company_model.objects.filter(employer_applications__isnull=False) + .annotate(min_application_created_at=Min("employer_applications__created_at")) + .only("id") + ) + + companies_to_update = [] + + for company in companies_with_min_created_at: + company.created_at = company.min_application_created_at + company.modified_at = company.min_application_created_at + companies_to_update.append(company) + + updated_count = company_model.objects.bulk_update( + companies_to_update, + fields=["created_at", "modified_at"], + batch_size=500, # To limit a single batch's SQL UPDATE clause size + ) + + LOGGER.info(f"Handled {total_count} companies:") + LOGGER.info( + f"- Set timestamps using related employer applications: {updated_count}" + ) + LOGGER.info(f"- Did nothing for unused companies: {total_count - updated_count}") diff --git a/backend/kesaseteli/companies/models.py b/backend/kesaseteli/companies/models.py index a097df2a41..4390d97a0a 100644 --- a/backend/kesaseteli/companies/models.py +++ b/backend/kesaseteli/companies/models.py @@ -2,10 +2,10 @@ from django.utils.translation import gettext_lazy as _ from applications.enums import OrganizationType -from shared.models.abstract_models import UUIDModel +from shared.models.abstract_models import TimeStampedModel, UUIDModel -class Company(UUIDModel): +class Company(UUIDModel, TimeStampedModel): name = models.CharField(max_length=256, verbose_name=_("name")) business_id = models.CharField( max_length=64, unique=True, verbose_name=_("business id") diff --git a/backend/kesaseteli/companies/tests/test_populate_company_timestamps.py b/backend/kesaseteli/companies/tests/test_populate_company_timestamps.py new file mode 100644 index 0000000000..d35b933637 --- /dev/null +++ b/backend/kesaseteli/companies/tests/test_populate_company_timestamps.py @@ -0,0 +1,190 @@ +""" +Tests for populate_company_timestamps migration function +used in migration "0010_populate_company_timestamps". + +These tests use the current data models. These tests CAN BE REMOVED +later IF the data MODELS CHANGE radically, rather than trying to +maintain them indefinitely. +""" + +from datetime import datetime, timedelta + +import pytest +from django.utils import timezone +from freezegun import freeze_time + +from common.tests.factories import CompanyFactory, EmployerApplicationFactory +from companies.migrations.helpers.company_timestamps import populate_company_timestamps +from companies.models import Company + + +@pytest.mark.django_db +def test_populate_single_company_with_single_application(): + """ + Test that populate_company_timestamps correctly sets timestamps for a + company with a single employer application. + """ + with freeze_time("2025-01-01 12:00"): + company = CompanyFactory() + + # Create an employer application with an earlier timestamp + with freeze_time("2024-01-15 10:30"): + employer_app = EmployerApplicationFactory(company=company) + + expected_timestamp = employer_app.created_at + assert company.created_at != expected_timestamp + assert company.modified_at != expected_timestamp + + # Run the migration function + populate_company_timestamps(Company) + + # After migration, company should have the employer application's created_at + company.refresh_from_db() + assert company.created_at == expected_timestamp + assert company.modified_at == expected_timestamp + + +@pytest.mark.django_db +def test_populate_company_with_multiple_applications(): + """ + Test that populate_company_timestamps uses the earliest employer + application's created_at timestamp when multiple applications exist. + """ + company = CompanyFactory() + + # Create three employer applications with different timestamps + timestamps = [ + timezone.make_aware(datetime(2024, 3, 20, 14, 0)), + timezone.make_aware(datetime(2024, 1, 10, 9, 0)), # Earliest + timezone.make_aware(datetime(2024, 6, 15, 16, 30)), + ] + + for timestamp in timestamps: + employer_app = EmployerApplicationFactory(company=company) + employer_app.created_at = timestamp + employer_app.save() + + # Run the migration function + populate_company_timestamps(Company) + + # Company should have the earliest timestamp + company.refresh_from_db() + earliest_timestamp = min(timestamps) + assert company.created_at == earliest_timestamp + assert company.modified_at == earliest_timestamp + + +@pytest.mark.django_db +def test_company_without_applications_unchanged(): + """ + Test that companies without any employer applications remain unchanged. + """ + company = CompanyFactory() + assert company.employer_applications.count() == 0 + original_created_at = company.created_at + original_modified_at = company.modified_at + + # Run the migration function + populate_company_timestamps(Company) + + # Company timestamps should remain unchanged + company.refresh_from_db() + assert company.created_at == original_created_at + assert company.modified_at == original_modified_at + + +@pytest.mark.django_db +def test_multiple_companies_with_different_scenarios(): + """ + Test populate_company_timestamps with multiple companies: + some with applications, one without. + """ + base_time = timezone.make_aware(datetime(2024, 1, 1, 0, 0)) + + # 1st company with multiple applications - should use earliest + company_multi_1 = CompanyFactory() + timestamps_multi_1 = [ + base_time + timedelta(days=d) for d in [10, 5, 15] + ] # 5 is earliest + for timestamp in timestamps_multi_1: + app = EmployerApplicationFactory(company=company_multi_1) + app.created_at = timestamp + app.save() + + # 2nd company with multiple applications - should use earliest + company_multi_2 = CompanyFactory() + timestamps_multi_2 = [ + base_time + timedelta(days=d) for d in [-5, -10, 3] + ] # -10 is earliest + for timestamp in timestamps_multi_2: + app = EmployerApplicationFactory(company=company_multi_2) + app.created_at = timestamp + app.save() + + # Company with single application + company_single = CompanyFactory() + timestamp_single = base_time + timedelta(days=20) + app = EmployerApplicationFactory(company=company_single) + app.created_at = timestamp_single + app.save() + + # Company without applications - should remain unchanged + company_none = CompanyFactory() + original_created = company_none.created_at + + populate_company_timestamps(Company) + + # Sanity check that the earliest timestamps for the multi-application companies are different + assert min(timestamps_multi_1) != min(timestamps_multi_2) + + company_multi_1.refresh_from_db() + assert company_multi_1.created_at == min(timestamps_multi_1) + assert company_multi_1.modified_at == min(timestamps_multi_1) + + company_multi_2.refresh_from_db() + assert company_multi_2.created_at == min(timestamps_multi_2) + assert company_multi_2.modified_at == min(timestamps_multi_2) + + company_single.refresh_from_db() + assert company_single.created_at == timestamp_single + assert company_single.modified_at == timestamp_single + + company_none.refresh_from_db() + assert company_none.created_at == original_created + + +@pytest.mark.django_db +def test_populate_company_timestamps_query_count(django_assert_max_num_queries): + """ + Test that populate_company_timestamps uses at most 3 queries. + + NOTE: + With larger datasets, the chunk/bulk sizes in the migration + function will make the actual query count higher! + """ + for i, company in enumerate(CompanyFactory.create_batch(size=15)): + if i < 10: # First 10 companies get 3 applications each, last 5 do not + EmployerApplicationFactory.create_batch(company=company, size=3) + + with django_assert_max_num_queries(3): + populate_company_timestamps(Company) + + +@pytest.mark.django_db +def test_populate_company_timestamps_logger_output(caplog): + """ + Test that populate_company_timestamps logs the expected summary output. + """ + # Create 3 companies: 2 with applications, 1 without + EmployerApplicationFactory(company=CompanyFactory()) + EmployerApplicationFactory(company=CompanyFactory()) + CompanyFactory() + + with caplog.at_level("INFO"): + populate_company_timestamps(Company) + + assert "Handled 3 companies:\n" in caplog.text + assert ( + "- Set timestamps using related employer applications: 2\n" in caplog.text + ) + assert "- Did nothing for unused companies: 1\n" in caplog.text