feat: limit source exports to current year
This commit is contained in:
@@ -11,6 +11,11 @@ Frontend отправляет администраторский
|
||||
JavaScript. Ticket передаётся в теле формы, не попадает в URL и после первого
|
||||
запроса становится недействительным.
|
||||
|
||||
Имя скачиваемого архива формируется по выбранным источникам и времени создания
|
||||
запроса: `<source-stem>_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.
|
||||
|
||||
## Хранение и первый запуск
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user