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
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:
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
140
tests/apps/organizations/test_canonical_search.py
Normal file
140
tests/apps/organizations/test_canonical_search.py
Normal 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)
|
||||
@@ -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"] == []
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user