From ef92cc4610c0beb14ad9616319f83635802bed81 Mon Sep 17 00:00:00 2001 From: Aleksandr Meshchriakov Date: Tue, 4 Aug 2026 23:25:26 +0200 Subject: [PATCH] feat: limit source exports to current year --- docs/source-record-export-matrix-ru.md | 36 ++++- .../commands/build_source_record_exports.py | 1 + .../0009_source_record_export_year_indexes.py | 39 +++++ src/organizations/source_record_export.py | 110 +++++++++++-- src/organizations/tasks.py | 1 + .../test_source_record_export.py | 145 +++++++++++++++++- tests/apps/organizations/test_tasks.py | 1 + 7 files changed, 308 insertions(+), 25 deletions(-) create mode 100644 src/organizations/migrations/0009_source_record_export_year_indexes.py diff --git a/docs/source-record-export-matrix-ru.md b/docs/source-record-export-matrix-ru.md index e95f84c..5e0f3a5 100644 --- a/docs/source-record-export-matrix-ru.md +++ b/docs/source-record-export-matrix-ru.md @@ -11,6 +11,11 @@ Frontend отправляет администраторский JavaScript. Ticket передаётся в теле формы, не попадает в URL и после первого запроса становится недействительным. +Имя скачиваемого архива формируется по выбранным источникам и времени создания +запроса: `_YYYYMMDD_HHMMSS.zip`, а для нескольких источников их +имена соединяются через `__`. Это же имя возвращается в `file_name` при выдаче +ticket и затем используется в `Content-Disposition`. + Совместимый администраторский `POST /api/v2/organization-source-records/export/` по-прежнему сразу возвращает тот же ZIP API-клиентам. Крупный XLSX может состоять из нескольких файлов @@ -22,6 +27,13 @@ XLSX или JSON заново. Он упаковывает файлы после архива. Поэтому `Content-Length` у ответа отсутствует. Если ни одного поколения ещё нет, API отвечает `503` с кодом `source_export_not_ready`. +Каждое поколение содержит только текущий календарный год в timezone сервиса. +Для записей с предметной датой год определяется по ней, для записей без такой +даты — по `created_at`, для финансовых отчётов — по году `financial_lines`. +Вложенные финансовые строки других лет исключаются. После смены года поколение +прошлого года не раздаётся: до первой успешной сборки нового года API отвечает +`503 source_export_not_ready`. + ## Матрица | Группа API | Файл | CSV | XLSX | JSON | @@ -43,9 +55,9 @@ XLSX или JSON заново. Он упаковывает файлы после Все форматы начинают строку организации с полей `Наименование`, `ИНН`, `ОГРН`, `КПП`, `ОКПО`, после которых следуют поля исходной записи и развёрнутого -`payload`. Все записи включаются в публичные файлы, а техническое наименование -внешнего поставщика нейтрализуется. Исходные значения в БД сохраняются для -работы интеграции и дедупликации. +`payload`. Все записи текущего года включаются в публичные файлы, а техническое +наименование внешнего поставщика нейтрализуется. Исходные значения в БД +сохраняются для работы интеграции и дедупликации. ## Ночная генерация @@ -57,10 +69,12 @@ Celery Beat запускает Генератор: 1. читает каждую группу из БД один раз без глобальной сортировки миллионов строк; + выборка текущего года использует функциональный индекс по году строковой + `record_date`, а записи без предметной даты — индекс `created_at`; 2. пишет compact JSON-массив на диск и использует его как готовый JSON без второй копии; 3. потоково создаёт CSV и XLSX без накопления всех строк в памяти; 4. разбивает XLSX по умолчанию по 100 000 строк на отдельные файлы, ограничивая временный XML и не превышая лимит Excel; -5. записывает размеры файлов и номера частей в manifest; +5. записывает размеры файлов, номера частей и календарный `export_year` в manifest; 6. атомарно переключает `current.json` только после готовности всей матрицы; 7. сохраняет текущее и предыдущее поколения по умолчанию. @@ -72,10 +86,16 @@ Celery Beat запускает Атомарная публикация требует одновременно хранить уже опубликованные поколения и одно новое поколение в staging. Минимальный запас под артефакты рассчитывается как `(SOURCE_RECORD_EXPORT_GENERATIONS_TO_KEEP + 1) * размер поколения`, плюс -рабочий запас файловой системы. На снимке dev от 2026-08-03 одно поколение -заняло 16,55 ГБ (54 физических файла), поэтому при значении `2` следует -выделить не менее 55 ГБ свободного места под каталог выгрузок. Временная копия -целого ZIP при скачивании не создаётся. +рабочий запас файловой системы. Исторический снимок dev от 2026-08-03 до +ограничения по году занимал 16,55 ГБ (54 физических файла); актуальный размер +годового поколения нужно брать из `total_size` результата команды сборки. +Временная копия целого ZIP при скачивании не создаётся. + +На локальном снимке от 2026-08-04 поколение за 2026 год содержит 122 582 записи +и занимает 809 768 139 байт (772,3 MiB) для всей матрицы из 25 файлов. Архив со +всеми источниками оценивается в 410,1 MiB для JSON, 289,9 MiB для CSV и +72,3 MiB для XLSX; ZIP использует `ZIP_STORED`, поэтому к сумме файлов +добавляется только небольшой служебный overhead. ## Хранение и первый запуск diff --git a/src/organizations/management/commands/build_source_record_exports.py b/src/organizations/management/commands/build_source_record_exports.py index 712e48f..6a7c8dc 100644 --- a/src/organizations/management/commands/build_source_record_exports.py +++ b/src/organizations/management/commands/build_source_record_exports.py @@ -21,6 +21,7 @@ class Command(BaseAppCommand): { "generation_id": generation.generation_id, "generated_at": generation.generated_at, + "export_year": generation.export_year, "artifacts_count": generation.artifacts_count, "files_count": generation.files_count, "records_count": generation.records_count, diff --git a/src/organizations/migrations/0009_source_record_export_year_indexes.py b/src/organizations/migrations/0009_source_record_export_year_indexes.py new file mode 100644 index 0000000..c721a2c --- /dev/null +++ b/src/organizations/migrations/0009_source_record_export_year_indexes.py @@ -0,0 +1,39 @@ +"""Add online indexes used by current-year source-record exports.""" + +from django.db import migrations + + +class Migration(migrations.Migration): + """Index normalized record years and dateless-record creation timestamps.""" + + atomic = False + + dependencies = [ + ("organizations", "0008_seed_nightly_source_record_exports"), + ] + + operations = [ + migrations.RunSQL( + sql=( + "CREATE INDEX CONCURRENTLY IF NOT EXISTS " + "organizations_source_record_export_year_idx " + "ON organizations_source_record " + "(((substring(record_date FROM '([0-9]{4})'))::integer))" + ), + reverse_sql=( + "DROP INDEX CONCURRENTLY IF EXISTS " + "organizations_source_record_export_year_idx" + ), + ), + migrations.RunSQL( + sql=( + "CREATE INDEX CONCURRENTLY IF NOT EXISTS " + "organizations_source_record_created_at_idx " + "ON organizations_source_record (created_at)" + ), + reverse_sql=( + "DROP INDEX CONCURRENTLY IF EXISTS " + "organizations_source_record_created_at_idx" + ), + ), + ] diff --git a/src/organizations/source_record_export.py b/src/organizations/source_record_export.py index 2807510..100491a 100644 --- a/src/organizations/source_record_export.py +++ b/src/organizations/source_record_export.py @@ -21,18 +21,24 @@ from uuid import uuid4 from django.conf import settings from django.core.cache import cache -from django.db.models import QuerySet, prefetch_related_objects +from django.db import connection +from django.db.models import Prefetch, Q, QuerySet, prefetch_related_objects +from django.db.models.expressions import RawSQL from django.utils import timezone from openpyxl import Workbook -from organizations.models import OrganizationSourceRecord, SourceGroup +from organizations.models import ( + OrganizationSourceFinancialLine, + OrganizationSourceRecord, + SourceGroup, +) EXPORT_FORMAT_CSV = "csv" EXPORT_FORMAT_XLSX = "xlsx" EXPORT_FORMAT_JSON = "json" EXPORT_FORMATS = (EXPORT_FORMAT_CSV, EXPORT_FORMAT_XLSX, EXPORT_FORMAT_JSON) FINANCIAL_SOURCE_GROUP = SourceGroup.FINANCIAL_INDICATORS.value -EXPORT_MANIFEST_VERSION = 1 +EXPORT_MANIFEST_VERSION = 2 CURRENT_EXPORT_MANIFEST_FILE_NAME = "current.json" GENERATION_MANIFEST_FILE_NAME = "manifest.json" GENERATION_DIRECTORY_NAME = "generations" @@ -120,6 +126,7 @@ class SourceRecordExportGeneration: generation_id: str generated_at: str + export_year: int artifacts: tuple[SourceRecordExportArtifact, ...] records_count: int @@ -198,6 +205,7 @@ def build_source_record_export_artifacts( generations_directory.mkdir(parents=True, exist_ok=True) generated_at_datetime = _normalize_generation_datetime(now or timezone.now()) + export_year = _export_year(generated_at_datetime) generation_id = ( f"{generated_at_datetime.strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}" ) @@ -214,6 +222,7 @@ def build_source_record_export_artifacts( headers, records_count = _spool_source_group_rows( source_group=source_group, output_path=row_spool_path, + export_year=export_year, ) source_record_counts[source_group] = records_count @@ -253,6 +262,7 @@ def build_source_record_export_artifacts( generation = SourceRecordExportGeneration( generation_id=generation_id, generated_at=generated_at_datetime.isoformat(), + export_year=export_year, artifacts=tuple(artifacts), records_count=sum(source_record_counts.values()), ) @@ -306,6 +316,7 @@ def build_source_records_export_archive( source_groups: Sequence[str], export_format: str, export_directory: str | Path | None = None, + requested_at: datetime | None = None, ) -> SourceRecordExportArchive: """Package selected prepared files without querying source-record tables.""" @@ -313,6 +324,13 @@ def build_source_records_export_archive( generation = load_current_source_record_export_generation( export_directory=root_directory, ) + requested_at_datetime = _normalize_generation_datetime( + requested_at or timezone.now() + ) + if generation.export_year != _export_year(requested_at_datetime): + raise SourceRecordExportArtifactsUnavailable( + "Prepared source-record export belongs to a different calendar year." + ) artifacts_by_key: dict[ tuple[str, str], list[SourceRecordExportArtifact], @@ -338,10 +356,11 @@ def build_source_records_export_archive( sorted(artifacts, key=lambda artifact: artifact.part_number) ) - generated_at = datetime.fromisoformat(generation.generated_at) - timestamp = generated_at.strftime("%Y%m%d_%H%M%S") return SourceRecordExportArchive( - archive_name=f"organization_source_records_export_{timestamp}.zip", + archive_name=_build_source_records_archive_name( + source_groups=source_groups, + requested_at=requested_at_datetime, + ), archive_chunks=_stream_zip_archive(selected_artifacts), files_count=len(selected_artifacts), generated_at=generation.generated_at, @@ -355,9 +374,11 @@ def create_source_record_export_download_ticket( ) -> SourceRecordExportDownloadTicket: """Validate prepared files and cache a short-lived download capability.""" + requested_at = _normalize_generation_datetime(timezone.now()) package = build_source_records_export_archive( source_groups=source_groups, export_format=export_format, + requested_at=requested_at, ) expires_in = max( 1, @@ -372,6 +393,7 @@ def create_source_record_export_download_ticket( payload = { "sources": list(source_groups), "format": export_format, + "requested_at": requested_at.isoformat(), } for _attempt in range(3): ticket = secrets.token_urlsafe(32) @@ -405,6 +427,7 @@ def consume_source_record_export_download_ticket( try: source_groups = payload["sources"] export_format = payload["format"] + requested_at_value = payload["requested_at"] if ( not isinstance(source_groups, list) or not source_groups @@ -415,14 +438,19 @@ def consume_source_record_export_download_ticket( ) or len(source_groups) != len(set(source_groups)) or export_format not in EXPORT_FORMATS + or not isinstance(requested_at_value, str) ): raise ValueError + requested_at = datetime.fromisoformat(requested_at_value) + if timezone.is_naive(requested_at): + raise ValueError except (KeyError, TypeError, ValueError): raise SourceRecordExportTicketInvalid from None return build_source_records_export_archive( source_groups=source_groups, export_format=export_format, + requested_at=requested_at, ) @@ -475,6 +503,23 @@ def _normalize_generation_datetime(value: datetime) -> datetime: return value.astimezone(UTC) +def _export_year(value: datetime) -> int: + """Return the calendar year in the configured service timezone.""" + return timezone.localtime(value).year + + +def _build_source_records_archive_name( + *, + source_groups: Sequence[str], + requested_at: datetime, +) -> str: + source_name = "__".join( + SOURCE_GROUP_EXPORT_FILE_STEMS[source_group] for source_group in source_groups + ) + timestamp = timezone.localtime(requested_at).strftime("%Y%m%d_%H%M%S") + return f"{source_name}_{timestamp}.zip" + + def _source_group_export_formats(source_group: str) -> tuple[str, ...]: if source_group == FINANCIAL_SOURCE_GROUP: return (EXPORT_FORMAT_JSON,) @@ -495,8 +540,12 @@ def _build_source_group_file_name(*, source_group: str, file_format: str) -> str return f"{SOURCE_GROUP_EXPORT_FILE_STEMS[source_group]}.{file_format}" -def _source_group_queryset(source_group: str) -> QuerySet[OrganizationSourceRecord]: - return ( +def _source_group_queryset( + source_group: str, + *, + export_year: int, +) -> QuerySet[OrganizationSourceRecord]: + queryset = ( OrganizationSourceRecord.objects.filter(extension__source_group=source_group) .select_related("extension", "extension__organization") # Export order is not part of the file contract. Clearing the model's @@ -504,22 +553,52 @@ def _source_group_queryset(source_group: str) -> QuerySet[OrganizationSourceReco # source groups with millions of rows. .order_by() ) + if source_group == FINANCIAL_SOURCE_GROUP: + return queryset.filter(financial_lines__year=export_year).distinct() + + if connection.vendor == "postgresql": + queryset = queryset.annotate( + export_record_year=RawSQL( + "substring(record_date FROM '([0-9]{4})')::integer", + (), + ) + ) + return queryset.filter( + Q(export_record_year=export_year) + | Q(record_date="", created_at__year=export_year) + ) + + year_pattern = rf"(^|[^0-9]){export_year}([^0-9]|$)" + return queryset.filter( + Q(record_date__regex=year_pattern) + | Q(record_date="", created_at__year=export_year) + ) def _iter_source_records( *, source_group: str, include_financial_lines: bool, + export_year: int, ) -> Iterator[OrganizationSourceRecord]: - iterator = _source_group_queryset(source_group).iterator( - chunk_size=SOURCE_RECORD_EXPORT_ITERATOR_CHUNK_SIZE - ) + iterator = _source_group_queryset( + source_group, + export_year=export_year, + ).iterator(chunk_size=SOURCE_RECORD_EXPORT_ITERATOR_CHUNK_SIZE) while True: batch = list(islice(iterator, SOURCE_RECORD_EXPORT_ITERATOR_CHUNK_SIZE)) if not batch: return if include_financial_lines: - prefetch_related_objects(batch, "financial_lines") + prefetch_related_objects( + batch, + Prefetch( + "financial_lines", + queryset=OrganizationSourceFinancialLine.objects.filter( + year=export_year + ).order_by(), + ), + ) yield from batch @@ -527,6 +606,7 @@ def _spool_source_group_rows( *, source_group: str, output_path: Path, + export_year: int, ) -> tuple[list[str], int]: include_financial_lines = source_group == FINANCIAL_SOURCE_GROUP payload_headers: set[str] = set() @@ -538,6 +618,7 @@ def _spool_source_group_rows( for record in _iter_source_records( source_group=source_group, include_financial_lines=include_financial_lines, + export_year=export_year, ): row = _build_record_row( record, @@ -799,6 +880,7 @@ def _generation_manifest_payload( "version": EXPORT_MANIFEST_VERSION, "generation_id": generation.generation_id, "generated_at": generation.generated_at, + "export_year": generation.export_year, "records_count": generation.records_count, "artifacts_count": generation.artifacts_count, "files_count": generation.files_count, @@ -830,6 +912,9 @@ def _generation_from_manifest( generation_id = str(payload["generation_id"]) generated_at = str(payload["generated_at"]) datetime.fromisoformat(generated_at) + export_year = int(payload["export_year"]) + if not 1 <= export_year <= 9999: + raise ValueError("Source-record export year is invalid.") records_count = int(payload["records_count"]) artifact_payloads = payload["artifacts"] if not isinstance(artifact_payloads, list): @@ -884,6 +969,7 @@ def _generation_from_manifest( return SourceRecordExportGeneration( generation_id=generation_id, generated_at=generated_at, + export_year=export_year, artifacts=tuple(artifacts), records_count=records_count, ) diff --git a/src/organizations/tasks.py b/src/organizations/tasks.py index 3bf76c2..73efa83 100644 --- a/src/organizations/tasks.py +++ b/src/organizations/tasks.py @@ -38,6 +38,7 @@ def refresh_source_record_export_artifacts(self) -> dict: # noqa: ARG001 "status": "success", "generation_id": generation.generation_id, "generated_at": generation.generated_at, + "export_year": generation.export_year, "artifacts_count": generation.artifacts_count, "files_count": generation.files_count, "records_count": generation.records_count, diff --git a/tests/apps/organizations/test_source_record_export.py b/tests/apps/organizations/test_source_record_export.py index abecdb4..71b8c54 100644 --- a/tests/apps/organizations/test_source_record_export.py +++ b/tests/apps/organizations/test_source_record_export.py @@ -4,6 +4,7 @@ import csv import json import os import zipfile +from datetime import UTC, datetime from io import BytesIO, StringIO from pathlib import Path from tempfile import TemporaryDirectory @@ -12,6 +13,7 @@ from unittest.mock import patch from django.core.management import call_command from django.test import override_settings from django.urls import reverse +from django.utils import timezone from openpyxl import load_workbook from organizations.models import ( FinancialIndicatorsExtension, @@ -22,6 +24,7 @@ from organizations.models import ( SourceGroup, ) from organizations.source_record_export import ( + SourceRecordExportArtifactsUnavailable, _render_source_group_artifact, _source_group_queryset, _spool_source_group_rows, @@ -89,8 +92,120 @@ class OrganizationSourceRecordExportApiV2Test(APITestCase): generation = load_current_source_record_export_generation() self.assertEqual(generation.artifacts_count, 25) + self.assertEqual(generation.export_year, timezone.localdate().year) self.assertIn('"artifacts_count": 25', command_output.getvalue()) + def test_generation_contains_only_records_from_its_calendar_year(self): + export_year = 2026 + generated_at = datetime(export_year, 8, 4, 6, 0, tzinfo=UTC) + organization = Organization.objects.create( + name='ООО "Годовая выгрузка"', + inn="7707083899", + ) + inspection_extension = PlannedInspectionExtension.objects.create( + organization=organization, + title="Плановые проверки Генпрокуратуры России", + ) + current_record = OrganizationSourceRecord.objects.create( + extension=inspection_extension, + record_type="inspection", + source="inspections", + external_id="INSP-2026", + title="Текущая проверка", + record_date="15.02.2026", + ) + OrganizationSourceRecord.objects.create( + extension=inspection_extension, + record_type="inspection", + source="inspections", + external_id="INSP-2025", + title="Прошлогодняя проверка", + record_date="15.02.2025", + ) + dateless_current_record = OrganizationSourceRecord.objects.create( + extension=inspection_extension, + record_type="inspection", + source="inspections", + external_id="INSP-DATELESS-2026", + title="Запись без предметной даты", + ) + OrganizationSourceRecord.objects.filter(pk=dateless_current_record.pk).update( + created_at=generated_at + ) + + financial_extension = FinancialIndicatorsExtension.objects.create( + organization=organization, + title="Финансово-экономические показатели", + ) + current_financial_record = OrganizationSourceRecord.objects.create( + extension=financial_extension, + record_type="financial_report", + source="fns_reports", + external_id="FIN-CURRENT", + title="Отчёт с текущим годом", + ) + old_financial_record = OrganizationSourceRecord.objects.create( + extension=financial_extension, + record_type="financial_report", + source="fns_reports", + external_id="FIN-OLD", + title="Старый отчёт", + ) + for source_record, year in ( + (current_financial_record, 2025), + (current_financial_record, 2026), + (old_financial_record, 2025), + ): + OrganizationSourceFinancialLine.objects.create( + source_record=source_record, + form_code="1", + line_code=str(year), + line_name=f"Строка {year}", + year=year, + period_end=year, + ) + + generation = build_source_record_export_artifacts(now=generated_at) + + self.assertEqual(generation.export_year, export_year) + inspection_path = next( + artifact.path + for artifact in generation.artifacts + if artifact.source_group == SourceGroup.PLANNED_INSPECTIONS.value + and artifact.file_format == "json" + ) + inspection_rows = json.loads(inspection_path.read_text(encoding="utf-8")) + self.assertEqual( + {row["uid"] for row in inspection_rows}, + {str(current_record.pk), str(dateless_current_record.pk)}, + ) + + financial_path = next( + artifact.path + for artifact in generation.artifacts + if artifact.source_group == SourceGroup.FINANCIAL_INDICATORS.value + ) + financial_rows = json.loads(financial_path.read_text(encoding="utf-8")) + self.assertEqual( + [row["uid"] for row in financial_rows], + [str(current_financial_record.pk)], + ) + self.assertEqual( + {line["year"] for line in financial_rows[0]["financial_lines"]}, + {export_year}, + ) + + def test_new_calendar_year_requires_a_new_prepared_generation(self): + generated_at = datetime(2026, 12, 31, 23, 59, tzinfo=UTC) + build_source_record_export_artifacts(now=generated_at) + + with self.assertRaises(SourceRecordExportArtifactsUnavailable): + build_source_records_export_archive( + source_groups=[SourceGroup.PLANNED_INSPECTIONS.value], + export_format="json", + requested_at=datetime(2027, 1, 1, tzinfo=UTC), + ) + def test_admin_exports_selected_sources_to_zip(self): self.client.force_authenticate(UserFactory.create_superuser()) organization = Organization.objects.create( @@ -133,7 +248,7 @@ class OrganizationSourceRecordExportApiV2Test(APITestCase): form_code="1", line_code="1600", line_name="Баланс", - year=2025, + year=timezone.localdate().year, period_start=100, period_end=200, ) @@ -160,7 +275,7 @@ class OrganizationSourceRecordExportApiV2Test(APITestCase): ) self.assertEqual(generation.artifacts_count, 25) self.assertIn( - 'filename="organization_source_records_export_', + 'filename="planned-inspections__financial-indicators_', response["Content-Disposition"], ) @@ -204,9 +319,16 @@ class OrganizationSourceRecordExportApiV2Test(APITestCase): def test_admin_uses_one_time_ticket_for_native_zero_sql_download(self): self.client.force_authenticate(UserFactory.create_superuser()) - build_source_record_export_artifacts() + requested_at = datetime(2026, 8, 4, 12, 34, 56, tzinfo=UTC) + build_source_record_export_artifacts(now=requested_at) - with self.assertNumQueries(0): + with ( + patch( + "organizations.source_record_export.timezone.now", + return_value=requested_at, + ), + self.assertNumQueries(0), + ): ticket_response = self.client.post( self.ticket_url, { @@ -220,6 +342,10 @@ class OrganizationSourceRecordExportApiV2Test(APITestCase): self.assertEqual(ticket_response.data["expires_in"], 300) self.assertRegex(ticket_response.data["ticket"], r"^[A-Za-z0-9_-]{43}$") self.assertNotIn("download_url", ticket_response.data) + self.assertEqual( + ticket_response.data["file_name"], + "planned-inspections_20260804_123456.zip", + ) self.client.force_authenticate(user=None) with self.assertNumQueries(0): @@ -233,6 +359,10 @@ class OrganizationSourceRecordExportApiV2Test(APITestCase): self.assertTrue(download_response.streaming) self.assertEqual(download_response["Content-Type"], "application/zip") self.assertNotIn("Content-Length", download_response) + self.assertEqual( + download_response["Content-Disposition"], + 'attachment; filename="planned-inspections_20260804_123456.zip"', + ) with zipfile.ZipFile( BytesIO(self._response_body(download_response)) ) as archive: @@ -430,6 +560,7 @@ class OrganizationSourceRecordExportApiV2Test(APITestCase): headers, records_count = _spool_source_group_rows( source_group=SourceGroup.PLANNED_INSPECTIONS.value, output_path=spool_path, + export_year=timezone.localdate().year, ) self.assertEqual( @@ -484,7 +615,10 @@ class OrganizationSourceRecordExportApiV2Test(APITestCase): self.assertEqual(source_record.payload["provider"], "Checko") def test_nightly_export_clears_model_ordering_to_avoid_multi_million_row_sort(self): - queryset = _source_group_queryset(SourceGroup.GOVERNMENT_PROCUREMENTS.value) + queryset = _source_group_queryset( + SourceGroup.GOVERNMENT_PROCUREMENTS.value, + export_year=timezone.localdate().year, + ) self.assertFalse(queryset.ordered) self.assertFalse(queryset.query.default_ordering) @@ -514,6 +648,7 @@ class OrganizationSourceRecordExportApiV2Test(APITestCase): headers, records_count = _spool_source_group_rows( source_group=SourceGroup.PLANNED_INSPECTIONS.value, output_path=spool_path, + export_year=timezone.localdate().year, ) _render_source_group_artifact( diff --git a/tests/apps/organizations/test_tasks.py b/tests/apps/organizations/test_tasks.py index e3542a2..a156a67 100644 --- a/tests/apps/organizations/test_tasks.py +++ b/tests/apps/organizations/test_tasks.py @@ -146,6 +146,7 @@ class SourceRecordExportArtifactsTaskTest(TestCase): self.assertEqual(result["status"], "success") self.assertEqual(result["artifacts_count"], 25) + self.assertEqual(result["export_year"], timezone.localdate().year) self.assertIsNone(cache.get(settings.SOURCE_RECORD_EXPORT_LOCK_KEY)) def test_refresh_task_skips_when_another_generation_holds_lock(self):