feat(registries): add SME and budget imports and fix source API workflows
All checks were successful
Mostovik Backend CI/CD / Tests and lint (push) Successful in 9m37s
Mostovik Backend CI/CD / Build linux/amd64 release images (push) Successful in 4m18s
Mostovik Backend CI/CD / Deploy and verify internal main (push) Has been skipped
Mostovik Backend CI/CD / Deploy customer main (push) Has been skipped
Mostovik Backend CI/CD / Deploy dev (push) Successful in 1m48s

This commit is contained in:
Aleksandr Meshchryakov
2026-09-13 23:13:47 +02:00
parent c863563851
commit 49cbfd265c
50 changed files with 5021 additions and 452 deletions

View File

@@ -207,21 +207,28 @@ class OrganizationsApiV2Test(APITestCase):
)
self.assertIn("/api/v2/organizations/{uid}/sources/", paths)
self.assertIn("/api/v2/organization-sources/{uid}/records/", paths)
source_record_list_operation = paths[
"/api/v2/organization-source-records/"
]["get"]
source_record_list_operation = paths["/api/v2/organization-source-records/"][
"get"
]
source_record_parameters = {
parameter["name"]: parameter
for parameter in source_record_list_operation["parameters"]
}
source_record_ordering_values = source_record_parameters["ordering"]["enum"]
for ordering_value in (
source_record_ordering = source_record_parameters["ordering"]
self.assertEqual(source_record_ordering["type"], "string")
self.assertNotIn("enum", source_record_ordering)
ordering_description = source_record_ordering["description"]
for ordering_field in (
"record_date",
"external_id",
"organization__name",
"payload__sentiment",
"-payload__sentiment",
"payload__news_source",
"-payload__news_source",
):
self.assertIn(ordering_value, source_record_ordering_values)
self.assertIn(ordering_field, ordering_description)
self.assertIn("префикс -", ordering_description)
self.assertIn("через запятую без пробелов", ordering_description)
self.assertIn("-record_date,-external_id", ordering_description)
def test_retrieve_returns_item_by_uid(self):
organization = create_frontend_organization(
@@ -425,7 +432,9 @@ class OrganizationsApiV2Test(APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertNotIn("data", response.data)
self.assertEqual(response.data["sources"][0]["source_group"], "financial_indicators")
self.assertEqual(
response.data["sources"][0]["source_group"], "financial_indicators"
)
def test_source_group_filter_limits_organizations_by_source_extension(self):
organization = create_frontend_organization(

View File

@@ -577,7 +577,7 @@ class OrganizationSourceExtensionsApiV2Test(APITestCase):
def test_flat_source_record_date_filter_precedes_pagination(self):
organization = create_frontend_organization(
name='ООО "Filtered pagination"',
name='ООО "Искомое"',
inn="7707083892",
ogrn="1027700132092",
)
@@ -755,7 +755,7 @@ class OrganizationSourceExtensionsApiV2Test(APITestCase):
self.assertEqual(response_organization["inn"], "0277106840")
self.assertEqual(response_organization["ogrn"], organization.ogrn)
def test_flat_source_records_searches_payload_values_displayed_in_tables(self):
def test_flat_source_records_does_not_search_payload_values(self):
target = create_frontend_organization(
name='ООО "Поиск по payload"',
inn="7707083817",
@@ -814,11 +814,8 @@ class OrganizationSourceExtensionsApiV2Test(APITestCase):
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["meta"]["pagination"]["total_count"], 1)
self.assertEqual(
response.data["data"][0]["external_id"],
"INSP-PAYLOAD",
)
self.assertEqual(response.data["meta"]["pagination"]["total_count"], 0)
self.assertEqual(response.data["data"], [])
def test_flat_source_records_searches_registry_identity_displayed_in_tables(self):
organization = create_frontend_organization(

View File

@@ -0,0 +1,140 @@
"""Organization search must use the same canonical identity in every table."""
from django.core.cache import cache
from django.urls import reverse
from organizations.models import (
Organization,
OrganizationSourceRecord,
PlannedInspectionExtension,
)
from rest_framework.test import APITestCase
from tests.apps.user.factories import UserFactory
class CanonicalOrganizationSearchTest(APITestCase):
def setUp(self):
cache.clear()
self.client.force_authenticate(UserFactory.create_user())
self.organization = Organization.objects.create(
name="Росатом",
full_name="Полное наименование Росатома",
short_name="Краткое",
inn="7707083881",
kpp="770701081",
ogrn="1027700132181",
okpo="00003881",
opk_registry_membership=True,
)
extension = PlannedInspectionExtension.objects.create(
organization=self.organization,
title="Проверки",
)
for index in range(2):
OrganizationSourceRecord.objects.create(
extension=extension,
source="inspections",
record_type="inspection",
external_id=f"target-{index}",
status="active",
)
other = Organization.objects.create(name="Другая", opk_registry_membership=True)
other_extension = PlannedInspectionExtension.objects.create(
organization=other,
title="Росатом",
)
OrganizationSourceRecord.objects.create(
extension=other_extension,
source="inspections",
record_type="inspection",
external_id="Росатом",
title="Росатом",
status="active",
url="https://example.test/Росатом",
payload={"raw_text": "Росатом"},
)
self.organizations_url = reverse("api_v2:organizations:organizations-list")
self.records_url = reverse(
"api_v2:organizations:organization-source-records-list"
)
def test_all_canonical_names_and_identifiers_are_searchable_in_both_endpoints(self):
for search in (
"Росатом",
"Полное",
"Краткое",
"7707083881",
"770701081",
"1027700132181",
"00003881",
):
with self.subTest(search=search):
organizations = self.client.get(
self.organizations_url, {"search": search}
)
records = self.client.get(self.records_url, {"search": search})
self.assertEqual(organizations.status_code, 200)
self.assertEqual(records.status_code, 200)
self.assertEqual(
organizations.data["meta"]["pagination"]["total_count"], 1
)
self.assertEqual(records.data["meta"]["pagination"]["total_count"], 2)
self.assertEqual(
organizations.data["data"][0]["uid"], str(self.organization.uid)
)
def test_entrepreneur_identifier_is_searchable_in_both_endpoints(self):
entrepreneur = Organization.objects.create(
name="ИП Иванов",
inn="500100732259",
ogrip="304500116000181",
opk_registry_membership=True,
)
extension = PlannedInspectionExtension.objects.create(
organization=entrepreneur,
title="Проверки",
)
OrganizationSourceRecord.objects.create(
extension=extension,
source="inspections",
record_type="inspection",
external_id="entrepreneur-record",
)
for url in (self.organizations_url, self.records_url):
response = self.client.get(url, {"search": "304500116000181"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["meta"]["pagination"]["total_count"], 1)
def test_search_terms_and_source_filters_are_applied_before_pagination(self):
extension = self.organization.source_extensions.get()
OrganizationSourceRecord.objects.create(
extension=extension,
source="other-source",
record_type="inspection",
external_id="filtered-out",
status="active",
)
params = {
"search": "Росатом 7707083881",
"source_group": "planned_inspections",
"source": "inspections",
"record_type": "inspection",
"status": "active",
"ordering": "external_id",
"page_size": 1,
}
first = self.client.get(self.records_url, {**params, "page": 1})
second = self.client.get(self.records_url, {**params, "page": 2})
self.assertEqual(first.status_code, 200)
self.assertEqual(second.status_code, 200)
self.assertEqual(first.data["meta"]["pagination"]["total_count"], 2)
self.assertEqual(first.data["data"][0]["external_id"], "target-0")
self.assertEqual(second.data["data"][0]["external_id"], "target-1")
def test_technical_identity_is_not_a_search_field(self):
Organization.objects.filter(pk=self.organization.pk).update(
primary_identity="technical-only"
)
response = self.client.get(self.organizations_url, {"search": "technical-only"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["meta"]["pagination"]["total_count"], 0)

View File

@@ -0,0 +1,95 @@
"""API accepts frontend composite ordering without widening its field allowlist."""
import pytest
from django.urls import reverse
from organizations.models import (
GovernmentSupportExtension,
Organization,
OrganizationSourceRecord,
)
from rest_framework.test import APIClient
from tests.apps.user.factories import UserFactory
@pytest.fixture
def source_records_client(db):
client = APIClient()
client.force_authenticate(UserFactory.create_user())
organization = Organization.objects.create(
name="Alpha",
inn="7707083810",
ogrn="1027700132010",
opk_registry_membership=True,
)
extension = GovernmentSupportExtension.objects.create(organization=organization)
for external_id, record_date in (
("A", "2026-01-31"),
("Z", "2026-01-31"),
("M", "2026-01-01"),
("N", ""),
):
OrganizationSourceRecord.objects.create(
extension=extension,
record_type="sme_support_measure",
source="fns_sme_support_recipients",
external_id=external_id,
record_date=record_date,
)
return client
@pytest.mark.parametrize(
("ordering", "expected"),
[
("-record_date,-external_id", ["Z", "A", "M", "N"]),
("record_date,-external_id", ["M", "Z", "A", "N"]),
("organization__name,-external_id", ["Z", "N", "M", "A"]),
("organization__name", ["A", "M", "N", "Z"]),
],
)
def test_source_record_composite_ordering_and_null_dates(
source_records_client, ordering, expected
):
response = source_records_client.get(
reverse("api_v2:organizations:organization-source-records-list"),
{"source": "fns_sme_support_recipients", "ordering": ordering},
)
assert response.status_code == 200
assert [record["external_id"] for record in response.data["data"]] == expected
def test_composite_ordering_precedes_pagination(source_records_client):
response = source_records_client.get(
reverse("api_v2:organizations:organization-source-records-list"),
{"ordering": "-record_date,-external_id", "page_size": 1, "page": 2},
)
assert response.status_code == 200
assert response.data["data"][0]["external_id"] == "A"
assert response.data["meta"]["pagination"]["total_count"] == 4
@pytest.mark.parametrize(
"ordering",
[
"-record_date,password",
"-record_date,payload__support_form__name",
"-record_date,",
",external_id",
"record_date,,external_id",
"record_date, external_id",
],
)
def test_composite_ordering_rejects_any_non_allowlisted_part(
source_records_client, ordering
):
response = source_records_client.get(
reverse("api_v2:organizations:organization-source-records-list"),
{"ordering": ordering},
)
assert response.status_code == 400
assert response.data["errors"][0]["code"] == "invalid_ordering"
assert response.data["data"] == []

View File

@@ -93,9 +93,9 @@ class OrganizationSourceRecordExportApiV2Test(APITestCase):
call_command("build_source_record_exports", stdout=command_output)
generation = load_current_source_record_export_generation()
self.assertEqual(generation.artifacts_count, 34)
self.assertEqual(generation.artifacts_count, 40)
self.assertEqual(generation.export_year, timezone.localdate().year)
self.assertIn('"artifacts_count": 34', command_output.getvalue())
self.assertIn('"artifacts_count": 40', command_output.getvalue())
def test_sanctions_xlsx_export_uses_source_contract_columns_and_boolean_flags(self):
organization = Organization.objects.create(
@@ -150,17 +150,17 @@ class OrganizationSourceRecordExportApiV2Test(APITestCase):
rows[0],
(
"Наименование",
"rn",
"ogrn",
"inn",
"okpo",
"Санкции - Великобритания HM Treasury",
"Санкции - Евросоюз",
"Санкции - США",
"Санкции - Швейцария",
"Санкции секторальные - США",
"Санкции - Великобритания UKSL",
"Санкции - Украина",
"Регистрационный номер",
"ОГРН",
"ИНН",
"ОКПО",
"Санкции Великобритания HM Treasury",
"Санкции Евросоюз",
"Санкции США",
"Санкции Швейцария",
"Санкции секторальные США",
"Санкции Великобритания UKSL",
"Санкции Украина",
),
)
self.assertEqual(
@@ -181,6 +181,74 @@ class OrganizationSourceRecordExportApiV2Test(APITestCase):
),
)
def test_sanctions_localized_headers_apply_to_all_xlsx_parts_only(self):
self.client.force_authenticate(UserFactory.create_superuser())
organization = Organization.objects.create(
name="АО Части выгрузки",
inn="0001234567",
ogrn="1027700132195",
okpo="00001234",
)
extension = SanctionsExtension.objects.create(
organization=organization, title="Санкции"
)
for index in range(2):
OrganizationSourceRecord.objects.create(
extension=extension,
source="ropk_sanctions",
record_type="organization_sanctions",
external_id=f"000{index}",
payload={"rn": f"000{index}", "ukraine": True},
)
with self.settings(SOURCE_RECORD_EXPORT_XLSX_ROWS_PER_FILE=1):
build_source_record_export_artifacts()
ticket = self.client.post(
self.ticket_url, {"sources": ["sanctions"], "format": "xlsx"}, format="json"
)
self.assertEqual(ticket.status_code, 201)
response = self.client.post(
self.download_url, {"ticket": ticket.data["ticket"]}
)
self.assertEqual(response.status_code, 200)
with zipfile.ZipFile(BytesIO(self._response_body(response))) as archive:
self.assertEqual(len(archive.namelist()), 2)
for name in archive.namelist():
workbook = load_workbook(BytesIO(archive.read(name)), read_only=True)
cells = list(workbook["data"].iter_rows())
self.assertEqual(
[cell.value for cell in cells[0]][:5],
[
"Наименование",
"Регистрационный номер",
"ОГРН",
"ИНН",
"ОКПО",
],
)
self.assertEqual([cell.data_type for cell in cells[1]][1:5], ["s"] * 4)
self.assertEqual(cells[1][3].value, "0001234567")
workbook.close()
for file_format in ("csv", "json"):
response = self.client.post(
self.url,
{"sources": ["sanctions"], "format": file_format},
format="json",
)
self.assertEqual(response.status_code, 200)
with zipfile.ZipFile(BytesIO(self._response_body(response))) as archive:
content = archive.read(archive.namelist()[0]).decode("utf-8-sig")
rows = (
list(csv.DictReader(StringIO(content)))
if file_format == "csv"
else json.loads(content)
)
self.assertEqual(len(rows), 2)
self.assertEqual({row["rn"] for row in rows}, {"0000", "0001"})
self.assertEqual(rows[0]["inn"], "0001234567")
self.assertNotIn("Регистрационный номер", rows[0])
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)
@@ -398,7 +466,7 @@ class OrganizationSourceRecordExportApiV2Test(APITestCase):
self.assertEqual(
response["X-Source-Export-Generated-At"], generation.generated_at
)
self.assertEqual(generation.artifacts_count, 34)
self.assertEqual(generation.artifacts_count, 40)
self.assertIn(
'filename="planned-inspections__financial-indicators_',
response["Content-Disposition"],
@@ -593,8 +661,8 @@ class OrganizationSourceRecordExportApiV2Test(APITestCase):
key=lambda item: item.file_name,
)
self.assertEqual(generation.artifacts_count, 34)
self.assertEqual(generation.files_count, 35)
self.assertEqual(generation.artifacts_count, 40)
self.assertEqual(generation.files_count, 41)
self.assertEqual(
[item.file_name for item in artifacts],
[
@@ -792,7 +860,7 @@ class OrganizationSourceRecordExportApiV2Test(APITestCase):
first_generation = build_source_record_export_artifacts()
current_generation = load_current_source_record_export_generation()
self.assertEqual(first_generation.artifacts_count, 34)
self.assertEqual(first_generation.artifacts_count, 40)
self.assertEqual(
current_generation.generation_id, first_generation.generation_id
)

View File

@@ -145,7 +145,7 @@ class SourceRecordExportArtifactsTaskTest(TestCase):
result = refresh_source_record_export_artifacts()
self.assertEqual(result["status"], "success")
self.assertEqual(result["artifacts_count"], 34)
self.assertEqual(result["artifacts_count"], 40)
self.assertEqual(result["export_year"], timezone.localdate().year)
self.assertIsNone(cache.get(settings.SOURCE_RECORD_EXPORT_LOCK_KEY))

View File

@@ -138,6 +138,33 @@ class TestCompaniesCommandsTest(TestCase):
expected_financial_years,
)
self.assertEqual(GenericParserRecord.objects.count(), 20 * 9)
snapshot_sources = (
ParserLoadLog.Source.FNS_SME_SUPPORT_RECIPIENTS,
ParserLoadLog.Source.BUDGET_UBPANDNUBP,
)
snapshot_records = OrganizationSourceRecord.objects.filter(
source__in=snapshot_sources,
)
self.assertEqual(snapshot_records.count(), 40)
self.assertEqual(
snapshot_records.filter(legacy_model="", legacy_pk="").count(), 40
)
self.assertFalse(
GenericParserRecord.objects.filter(source__in=snapshot_sources).exists()
)
support = snapshot_records.filter(source=snapshot_sources[0]).first()
self.assertEqual(support.record_type, "sme_support_measure")
self.assertEqual(len(support.payload["support_sizes"]), 2)
self.assertEqual(
support.payload["support_sizes"][0]["value"], str(support.amount)
)
budget = snapshot_records.filter(source=snapshot_sources[1]).first()
self.assertEqual(budget.record_type, "budget_registry_organization")
self.assertEqual(budget.status, "inactive")
self.assertEqual(budget.payload["registry"]["status_code"], "2")
self.assertEqual(
budget.payload["upstream"]["info"]["inn"], budget.extension.organization.inn
)
def test_create_updates_the_fixed_dataset_without_duplicates(self):
call_command("create_test_companies", stdout=StringIO())
@@ -218,7 +245,7 @@ class TestCompaniesCommandsTest(TestCase):
year=stale_year,
).exists()
)
self.assertEqual(OrganizationSourceRecord.objects.count(), 20 * 17)
self.assertEqual(OrganizationSourceRecord.objects.count(), 20 * 19)
def test_create_replaces_stale_source_record_for_same_legacy_row(self):
call_command("create_test_companies", stdout=StringIO())