feat: expand registry ingestion and demo exchange
All checks were successful
CI/CD Pipeline / Quality Gate (push) Successful in 36s
CI/CD Pipeline / Build and Push Images (push) Successful in 15s
CI/CD Pipeline / Internal Notify (push) Successful in 0s
CI/CD Pipeline / Deploy Dev via Compose (push) Successful in 24s

This commit is contained in:
2026-07-16 12:59:23 +02:00
parent cc5aa84556
commit 007cecc8d5
43 changed files with 3723 additions and 352 deletions

View File

@@ -7,6 +7,7 @@ import time
from django.core.cache import cache
ORGANIZATION_API_CACHE_PREFIX = "api:v2:organizations"
ORGANIZATION_API_CACHE_CONTRACT_VERSION = 2
ORGANIZATION_API_CACHE_VERSION_KEY = f"{ORGANIZATION_API_CACHE_PREFIX}:version"
DEFAULT_ORGANIZATION_API_CACHE_VERSION = 1
DEFAULT_ORGANIZATION_API_CACHE_TIMEOUT_SECONDS = 24 * 60 * 60

View File

@@ -45,7 +45,6 @@ class OrganizationFilter(filters.FilterSet):
)
registry = filters.CharFilter(method="filter_registry")
registry_name = filters.CharFilter(method="filter_registry_name")
has_registry = filters.BooleanFilter(method="filter_has_registry")
source_group = filters.CharFilter(method="filter_source_group")
has_financial_indicators = filters.BooleanFilter(method="filter_source_presence")
@@ -83,7 +82,6 @@ class OrganizationFilter(filters.FilterSet):
"identity_status",
"registry",
"registry_name",
"has_registry",
"source_group",
]
@@ -93,9 +91,6 @@ class OrganizationFilter(filters.FilterSet):
def filter_registry_name(self, queryset, _name, value):
return self._filter_by_registry_membership(queryset, registry_name=value)
def filter_has_registry(self, queryset, _name, value):
return self._filter_by_registry_membership(queryset, has_registry=value)
def filter_source_group(self, queryset, _name, value):
source_group = SOURCE_FILTER_ALIASES.get(str(value), str(value))
return self._filter_by_source_group(queryset, source_group, True)
@@ -123,15 +118,12 @@ class OrganizationFilter(filters.FilterSet):
*,
registry_id: str | None = None,
registry_name: str | None = None,
has_registry: bool = True,
):
query = cls._registry_directory_query(
registry_id=registry_id,
registry_name=registry_name,
)
if has_registry:
return queryset.filter(query)
return queryset.exclude(query)
return queryset.filter(query)
@staticmethod
def _registry_directory_query(
@@ -139,11 +131,7 @@ class OrganizationFilter(filters.FilterSet):
registry_id: str | None = None,
registry_name: str | None = None,
) -> Q:
query = (
Q(opk_registry_membership=True)
| Q(goz_participation=True)
| ~Q(ropk_num="")
)
query = Q(opk_registry_membership=True)
if registry_id:
query &= Q(ropk_razdel_num=str(registry_id)) | Q(gk_code=str(registry_id))
if registry_name:
@@ -153,20 +141,3 @@ class OrganizationFilter(filters.FilterSet):
| Q(business_activity__icontains=registry_name)
)
return query
@staticmethod
def _registry_identity_value_querysets(
*,
registry_id: str | None = None,
registry_name: str | None = None,
):
organizations = Organization.objects.filter(
OrganizationFilter._registry_directory_query(
registry_id=registry_id,
registry_name=registry_name,
)
)
return (
organizations.exclude(inn="").values_list("inn", flat=True),
organizations.exclude(ogrn="").values_list("ogrn", flat=True),
)

View File

@@ -0,0 +1,29 @@
"""Create or refresh the fixed frontend test-company dataset."""
import json
from apps.core.management.commands.base import BaseAppCommand
from organizations.test_companies import TestCompanyDatasetService
class Command(BaseAppCommand):
"""Create twenty deterministic organizations with every source dataset."""
help = "Создает или обновляет 20 тестовых компаний со всеми наборами данных"
use_transaction = True
def execute_command(self, *args, **options) -> str:
result = TestCompanyDatasetService.create_or_update()
rendered = json.dumps(
{
"organizations_created": result.organizations_created,
"organizations_updated": result.organizations_updated,
"extensions": result.extensions,
"records": result.records,
},
ensure_ascii=False,
sort_keys=True,
)
self.log_success(rendered)
return rendered

View File

@@ -0,0 +1,24 @@
"""Delete the fixed frontend test-company dataset."""
import json
from apps.core.management.commands.base import BaseAppCommand
from organizations.test_companies import TestCompanyDatasetService
class Command(BaseAppCommand):
"""Delete only the twenty deterministic test organizations."""
help = "Удаляет 20 фиксированных тестовых компаний и связанные данные"
use_transaction = True
def execute_command(self, *args, **options) -> str:
result = TestCompanyDatasetService.delete()
rendered = json.dumps(
{"organizations_deleted": result.organizations_deleted},
ensure_ascii=False,
sort_keys=True,
)
self.log_success(rendered)
return rendered

View File

@@ -0,0 +1,539 @@
"""Deterministic, realistic test-company dataset for frontend demonstrations."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from hashlib import sha256
from uuid import UUID, uuid5
from apps.parsers.models import ParserLoadLog
from django.db.models import Count, Max, Min
from django.utils import timezone
from organizations.cache import invalidate_organization_api_cache
from organizations.models import (
Organization,
OrganizationSourceFinancialLine,
OrganizationSourceRecord,
SourceExtensionStatus,
)
from organizations.source_cache import invalidate_source_data_cache
from organizations.source_groups import SOURCE_GROUP_DESCRIPTORS
TEST_COMPANY_COUNT = 20
TEST_COMPANY_NAMESPACE = UUID("59b36ae9-bcf8-4b7c-b77a-77578f485a01")
TEST_RECORD_PREFIX = "mostovik-test-company"
@dataclass(frozen=True)
class TestCompanyDatasetResult:
"""Counters returned by create and delete operations."""
organizations_created: int = 0
organizations_updated: int = 0
organizations_deleted: int = 0
extensions: int = 0
records: int = 0
class TestCompanyDatasetService:
"""Create, refresh, and remove the fixed frontend demonstration dataset."""
@classmethod
def company_uids(cls) -> list[UUID]:
return [
uuid5(TEST_COMPANY_NAMESPACE, f"company:{index}")
for index in range(1, TEST_COMPANY_COUNT + 1)
]
@classmethod
def create_or_update(cls) -> TestCompanyDatasetResult:
now = timezone.now()
created_count = 0
updated_count = 0
total_extensions = 0
total_records = 0
descriptors_by_group = cls._descriptors_by_group()
for index, uid in enumerate(cls.company_uids(), start=1):
organization, created = Organization.objects.update_or_create(
uid=uid,
defaults=cls._organization_defaults(index=index, now=now),
)
created_count += int(created)
updated_count += int(not created)
extensions = {}
for source_group, descriptors in descriptors_by_group.items():
descriptor = descriptors[0]
extension, _ = descriptor.extension_model.objects.update_or_create(
organization=organization,
defaults={
"title": descriptor.title,
"status": SourceExtensionStatus.ACTIVE,
"first_seen_at": now,
"last_seen_at": now,
"last_load_batch": 9_000_000 + index,
"metadata": {
"dataset": TEST_RECORD_PREFIX,
"sources": [item.source for item in descriptors],
},
},
)
extensions[source_group] = extension
expected_external_ids = []
for source, descriptor in SOURCE_GROUP_DESCRIPTORS.items():
if source not in ParserLoadLog.Source.values:
continue
external_id = f"{TEST_RECORD_PREFIX}:{index:02d}:{source}"
expected_external_ids.append(external_id)
record_defaults = cls._record_defaults(
source=source,
index=index,
organization=organization,
)
record, _ = OrganizationSourceRecord.objects.update_or_create(
source=source,
external_id=external_id,
defaults={
"extension": extensions[descriptor.source_group],
"record_type": descriptor.record_type,
"load_batch": 9_000_000 + index,
**record_defaults,
},
)
if source == ParserLoadLog.Source.FNS_REPORTS:
cls._refresh_financial_lines(record=record, index=index)
OrganizationSourceRecord.objects.filter(
extension__organization=organization,
external_id__startswith=f"{TEST_RECORD_PREFIX}:",
).exclude(external_id__in=expected_external_ids).delete()
cls._refresh_extension_counters(organization=organization)
total_extensions += len(extensions)
total_records += len(expected_external_ids)
cls._invalidate_caches()
return TestCompanyDatasetResult(
organizations_created=created_count,
organizations_updated=updated_count,
extensions=total_extensions,
records=total_records,
)
@classmethod
def delete(cls) -> TestCompanyDatasetResult:
company_uids = cls.company_uids()
queryset = Organization.objects.filter(uid__in=company_uids)
deleted_count = queryset.count()
extension_models = {
descriptor.extension_model
for source, descriptor in SOURCE_GROUP_DESCRIPTORS.items()
if source in ParserLoadLog.Source.values
}
for extension_model in extension_models:
extension_model.objects.filter(organization_id__in=company_uids).delete()
queryset.delete()
cls._invalidate_caches()
return TestCompanyDatasetResult(organizations_deleted=deleted_count)
@staticmethod
def _descriptors_by_group():
descriptors = {}
for source, descriptor in SOURCE_GROUP_DESCRIPTORS.items():
if source in ParserLoadLog.Source.values:
descriptors.setdefault(descriptor.source_group, []).append(descriptor)
return descriptors
@classmethod
def _organization_defaults(cls, *, index: int, now):
is_rosatom = index <= 10
corporation_name = (
'Госкорпорация "Росатом"' if is_rosatom else 'Госкорпорация "Роскосмос"'
)
corporation_ministry = (
'Государственная корпорация по атомной энергии "Росатом"'
if is_rosatom
else 'Государственная корпорация по космической деятельности "Роскосмос"'
)
industry = (
"Атомная промышленность"
if is_rosatom
else "Ракетно-космическая промышленность"
)
name = f"Тестовая компания {index}"
inn = cls._legal_entity_inn(index)
ogrn = cls._legal_entity_ogrn(index)
return {
"rn": 9_900_000 + index,
"gk_code": "2" if is_rosatom else "1",
"gk_name": corporation_name,
"in_korp_code": "1",
"in_korp_name": "Входит в состав",
"name": name,
"full_name": f'Акционерное общество "{name}"',
"short_name": name,
"pn_name": name,
"pn_name_en": f"Test Company {index}",
"inn": inn,
"kpp": f"7709{index:02d}001",
"ogrn": ogrn,
"okpo": f"90{index:06d}",
"filial": ".F.",
"is_branch": False,
"registration_date": date(2010 + index % 10, (index - 1) % 12 + 1, 15),
"create_date": str(2010 + index % 10),
"organizational_legal_form": "12267",
"organizational_legal_form1": "Непубличные акционерные общества",
"ownership_form": "61" if is_rosatom else "16",
"ownership_form1": (
"Собственность государственных корпораций"
if is_rosatom
else "Частная собственность"
),
"authorized_capital": Decimal(1_000_000 + index * 100_000),
"legal_address": (
f"{101000 + index}, г. Москва, Тестовый проезд, д. {index}"
),
"business_act_cod": "4" if is_rosatom else "2",
"business_activity": "Прочая" if is_rosatom else "Производственная",
"general_director": f"Иванов Иван Иванович {index}",
"general_director_tax_id": f"770100{index:06d}",
"appointment_date": date(2024, 1, min(index, 28)),
"akc_fs": "0",
"akc_sf": "0",
"re_za": False,
"re_zasf": False,
"goz_participation": True,
"opk_registry_membership": True,
"ropk_num": f"ТЕСТ-ОПК-{index:04d}",
"ropk_razdel_num": "3" if is_rosatom else "2",
"ropk_razdel_name": (
"Организации, подведомственные и находящиеся в сфере деятельности "
f"{corporation_name}"
),
"min": corporation_ministry,
"dep": "Департамент тестовых данных",
"otr": industry,
"integrated_structure": f"Тестовая интегрированная структура {index}",
"state_sector_code": "2",
"state_sector_name": (
"контролируются РФ косвенно или совместно с организациями госсектора"
),
"directory_source_file_hash": sha256(
f"{TEST_RECORD_PREFIX}:{index}".encode()
).hexdigest(),
"directory_source_row_number": index,
"directory_imported_at": now,
}
@classmethod
def _record_defaults(cls, *, source: str, index: int, organization):
common = {
"inn": organization.inn,
"ogrn": organization.ogrn,
"organisation_name": organization.name,
"source": source,
"load_batch": 9_000_000 + index,
}
url = f"https://example.test/{source}/{index}"
values = {
ParserLoadLog.Source.FNS_REPORTS: {
"title": f"Бухгалтерская отчетность за 2025 год — {organization.name}",
"record_date": "2025",
"status": "processed",
"payload": {
**common,
"file_name": f"fin_test_{organization.ogrn}.xlsx",
"file_hash": sha256(
f"financial:{organization.ogrn}".encode()
).hexdigest(),
"lines_count": 4,
},
},
ParserLoadLog.Source.PROCUREMENTS: cls._procurement_values(
common, index, organization, law="44-ФЗ/223-ФЗ", source=source
),
ParserLoadLog.Source.PROCUREMENTS_44FZ: cls._procurement_values(
common, index, organization, law="44-ФЗ", source=source
),
ParserLoadLog.Source.PROCUREMENTS_223FZ: cls._procurement_values(
common, index, organization, law="223-ФЗ", source=source
),
ParserLoadLog.Source.CONTRACTS: {
"title": f"Контракт на поставку оборудования № {index}",
"record_date": "15.03.2026",
"amount": Decimal(3_000_000 + index * 25_000),
"status": "Исполнение",
"url": url,
"payload": {
**common,
"contract_number": f"КОНТРАКТ-{index:05d}",
"subject": "Поставка испытательного оборудования",
"customer": organization.name,
"price": str(3_000_000 + index * 25_000),
"law": "44-ФЗ",
"status": "Исполнение",
"url": url,
},
},
ParserLoadLog.Source.INDUSTRIAL: {
"title": f"Сертификат промышленного производства {index:05d}/26",
"record_date": "15.01.2026",
"url": url,
"payload": {
**common,
"certificate_number": f"{index:05d}/26",
"issue_date": "15.01.2026",
"issue_date_normalized": "2026-01-15",
"expiry_date": "15.01.2029",
"expiry_date_normalized": "2029-01-15",
"certificate_file_url": url,
},
},
ParserLoadLog.Source.INDUSTRIAL_PRODUCTS: {
"title": f"Испытательный комплекс ТК-{index}",
"payload": {
**common,
"full_organisation_name": organization.full_name,
"registry_number": f"ПРОД-{index:06d}",
"product_name": f"Испытательный комплекс ТК-{index}",
"product_model": f"ТК-{index}.2026",
"okpd2_code": "28.99.39.190",
"tnved_code": "8479899707",
"regulatory_document": "ТУ 28.99.39-001-2026",
},
},
ParserLoadLog.Source.MANUFACTURES: {
"title": organization.full_name,
"payload": {
**common,
"full_legal_name": organization.full_name,
"address": organization.legal_address,
},
},
ParserLoadLog.Source.INSPECTIONS: {
"title": f"Плановая выездная проверка {organization.name}",
"record_date": "01.06.2026",
"status": "Запланирована",
"payload": {
**common,
"registration_number": f"24800000{index:04d}",
"control_authority": "Ростехнадзор",
"inspection_type": "scheduled",
"inspection_form": "documentary_and_on_site",
"legal_basis": "Федеральный закон № 248-ФЗ",
"start_date": "01.06.2026",
"start_date_normalized": "2026-06-01",
"end_date": "15.06.2026",
"end_date_normalized": "2026-06-15",
"data_year": 2026,
"data_month": 6,
"status": "Запланирована",
},
},
ParserLoadLog.Source.FEDRESURS_BANKRUPTCY: {
"title": f"Сообщение по делу А40-{10000 + index}/2026",
"record_date": "01.05.2026",
"status": "Наблюдение",
"url": url,
"payload": {
**common,
"case_number": f"А40-{10000 + index}/2026",
"message_type": "Введение наблюдения",
"message_date": "01.05.2026",
"messages_count": 3,
"messages": [
{
"type": "Введение наблюдения",
"date": "01.05.2026",
}
],
"url": url,
},
},
ParserLoadLog.Source.UNFAIR_SUPPLIERS: {
"title": f"Реестровая запись РНП-{index:05d}",
"record_date": "20.01.2026",
"status": "Включен",
"url": url,
"payload": {
**common,
"supplier": organization.name,
"registry_number": f"РНП-{index:05d}",
"included_at": "20.01.2026",
"reason": "Нарушение условий закупки",
"url": url,
},
},
ParserLoadLog.Source.FAS_GOZ: {
"title": f"Постановление ФАС по ГОЗ № {index}",
"record_date": "20.01.2026",
"status": "Исполнено",
"payload": {
**common,
"registry_number": str(index),
"authority": "ФАС России",
"decision": "Постановление по ГОЗ",
"decision_date": "20.01.2026",
"execution_status": "Исполнено",
},
},
ParserLoadLog.Source.ARBITRATION: {
"title": f"Дело А40-{20000 + index}/2026",
"record_date": "10.02.2026",
"amount": Decimal(500_000 + index * 10_000),
"status": "В производстве",
"url": url,
"payload": {
**common,
"case_number": f"А40-{20000 + index}/2026",
"court": "Арбитражный суд города Москвы",
"role": "ответчик",
"claim_amount": str(500_000 + index * 10_000),
"status": "В производстве",
"url": url,
},
},
ParserLoadLog.Source.FSTEC: {
"title": f"Сертификат ФСТЭК № ТЕСТ-{index:04d}",
"record_date": "15.01.2026",
"status": "Действует",
"payload": {
**common,
"licensee": organization.name,
"registry_number": f"ТЕСТ-{index:04d}",
"issued_at": "15.01.2026",
"status": "Действует",
"№ сертификата": f"ТЕСТ-{index:04d}",
"Заявитель": organization.name,
"Дата внесения в реестр": "15.01.2026",
"Срок действия сертификата": "15.01.2029",
},
},
ParserLoadLog.Source.TRUDVSEM: {
"title": f"Инженер-испытатель {index} категории",
"record_date": "20.05.2026",
"status": "Открыта",
"url": url,
"payload": {
**common,
"id": f"VAC-{index:05d}",
"company": organization.name,
"job-name": f"Инженер-испытатель {index} категории",
"creation-date": "2026-05-20",
"salary": "120000.00",
"salary_min": 110000,
"salary_max": 140000,
"currency": "RUB",
"employment": "Полная занятость",
"schedule": "Полный день",
"region": "Москва",
"status": "Открыта",
"vacancy_source": "trudvsem",
"vac_url": url,
},
},
}
return values[source]
@staticmethod
def _procurement_values(common, index, organization, *, law: str, source: str):
purchase_number = f"03732000000{index:08d}"[:19]
amount = Decimal(1_250_000 + index * 50_000)
url = f"https://example.test/{source}/{index}"
return {
"title": f"Поставка оборудования для {organization.name}",
"record_date": "01.02.2026",
"amount": amount,
"status": "Размещено",
"url": url,
"payload": {
**common,
"purchase_number": purchase_number,
"subject": "Поставка испытательного оборудования",
"customer": organization.name,
"customer_inn": organization.inn,
"price": str(amount),
"law": law,
"published_at": "01.02.2026",
"status": "Размещено",
"region_code": "77",
"data_year": 2026,
"data_month": 2,
"url": url,
},
}
@staticmethod
def _refresh_financial_lines(*, record, index: int) -> None:
expected = {
("1", "1600", "Баланс (актив)", 10_000_000 + index * 100_000),
("1", "1300", "Капитал и резервы", 4_000_000 + index * 50_000),
("2", "2110", "Выручка", 20_000_000 + index * 200_000),
("2", "2400", "Чистая прибыль", 2_000_000 + index * 25_000),
}
expected_keys = []
for form_code, line_code, line_name, period_end in expected:
expected_keys.append((form_code, line_code, 2025))
OrganizationSourceFinancialLine.objects.update_or_create(
source_record=record,
form_code=form_code,
line_code=line_code,
year=2025,
defaults={
"line_name": line_name,
"period_start": period_end - 100_000,
"period_end": period_end,
},
)
lines = OrganizationSourceFinancialLine.objects.filter(source_record=record)
for line in lines:
key = (line.form_code, line.line_code, line.year)
if key not in expected_keys:
line.delete()
@staticmethod
def _refresh_extension_counters(*, organization) -> None:
aggregates = {
item["extension_id"]: item
for item in OrganizationSourceRecord.objects.filter(
extension__organization=organization
)
.values("extension_id")
.annotate(
records_count=Count("uid"),
first_seen_at=Min("created_at"),
last_seen_at=Max("updated_at"),
)
}
for extension in organization.source_extensions.all():
aggregate = aggregates.get(extension.uid, {})
extension.records_count = aggregate.get("records_count", 0)
extension.first_seen_at = aggregate.get("first_seen_at")
extension.last_seen_at = aggregate.get("last_seen_at")
extension.save(
update_fields=["records_count", "first_seen_at", "last_seen_at"]
)
@staticmethod
def _legal_entity_inn(index: int) -> str:
base = f"770900{index:03d}"
weights = (2, 4, 10, 3, 5, 9, 4, 6, 8)
checksum = sum(
int(digit) * weight for digit, weight in zip(base, weights, strict=True)
)
return f"{base}{checksum % 11 % 10}"
@staticmethod
def _legal_entity_ogrn(index: int) -> str:
base = f"1267700{index:05d}"
return f"{base}{int(base) % 11 % 10}"
@staticmethod
def _invalidate_caches() -> None:
invalidate_organization_api_cache()
invalidate_source_data_cache()

View File

@@ -29,6 +29,7 @@ from rest_framework.viewsets import ReadOnlyModelViewSet
from organizations.cache import (
DEFAULT_ORGANIZATION_API_CACHE_TIMEOUT_SECONDS,
ORGANIZATION_API_CACHE_CONTRACT_VERSION,
ORGANIZATION_API_CACHE_PREFIX,
get_organization_api_cache_version,
invalidate_organization_api_cache,
@@ -56,7 +57,6 @@ from organizations.serializers import (
from organizations.source_record_export import build_source_records_export_archive
ORGANIZATIONS_TAG = swagger_tag("Организации", "Organizations")
FALSE_QUERY_VALUES = {"0", "false", "no", "off"}
def _query_parameter(
@@ -80,10 +80,6 @@ def _query_parameter(
)
def _is_truthy_query_value(value: str) -> bool:
return value.strip().lower() not in FALSE_QUERY_VALUES
SOURCE_GROUP_VALUES = [choice.value for choice in SourceGroup]
ORGANIZATION_LIST_PARAMS = [
@@ -129,15 +125,6 @@ ORGANIZATION_LIST_PARAMS = [
"registry_name",
description="Фильтр по части наименования реестра.",
),
_query_parameter(
"has_registry",
description=(
"Фильтр наличия активного участия в любом реестре; по умолчанию true "
"для list endpoint, если параметр не передан."
),
param_type=openapi.TYPE_BOOLEAN,
default=True,
),
_query_parameter(
"source_group",
description="Фильтр по группе источников организации.",
@@ -180,11 +167,6 @@ SOURCE_RECORD_LIST_PARAMS = [
),
_query_parameter("source", description="Фильтр по legacy source внутри группы."),
_query_parameter("record_type", description="Фильтр по типу записи."),
_query_parameter(
"has_registry",
description="Фильтр наличия активного участия организации записи в любом реестре.",
param_type=openapi.TYPE_BOOLEAN,
),
_query_parameter(
"organization",
description="UID организации.",
@@ -228,6 +210,7 @@ class CachedReadOnlyMixin:
cache_version = get_organization_api_cache_version()
raw_key = (
f"c{ORGANIZATION_API_CACHE_CONTRACT_VERSION}:"
f"v{cache_version}:{request.method}:"
f"{request.get_full_path()}:{user_marker}"
)
@@ -287,18 +270,12 @@ class OrganizationViewSet(CachedReadOnlyMixin, ReadOnlyModelViewSet):
return super().get_permissions()
def get_queryset(self):
queryset = super().get_queryset().prefetch_related("source_extensions")
if self.action != "list" or "has_registry" in self.request.query_params:
return queryset
filterset = OrganizationFilter(
data={"has_registry": "true"},
queryset=queryset,
request=self.request,
return (
super()
.get_queryset()
.filter(opk_registry_membership=True)
.prefetch_related("source_extensions")
)
if filterset.is_valid():
return filterset.qs
return queryset
@swagger_auto_schema(
tags=[ORGANIZATIONS_TAG],
@@ -306,8 +283,8 @@ class OrganizationViewSet(CachedReadOnlyMixin, ReadOnlyModelViewSet):
operation_summary="Список организаций",
operation_description=(
"Возвращает канонический справочник организаций API v2. "
"По умолчанию показывает только организации с активным участием "
"в реестрах; передайте has_registry=false, чтобы снять это ограничение. "
"Контур frontend всегда ограничен организациями, для которых "
"opk_registry_membership=true; снять это ограничение query-параметром нельзя. "
"Данные источников возвращаются компактным списком sources; детальные "
"записи доступны через endpoints расширений источников."
),
@@ -435,11 +412,13 @@ class OrganizationViewSet(CachedReadOnlyMixin, ReadOnlyModelViewSet):
class OrganizationSourceExtensionViewSet(ReadOnlyModelViewSet):
"""Read-only API for source extensions and their records."""
queryset = OrganizationSourceExtension.objects.select_related(
"organization"
).order_by(
"organization__name",
"source_group",
queryset = (
OrganizationSourceExtension.objects.select_related("organization")
.filter(organization__opk_registry_membership=True)
.order_by(
"organization__name",
"source_group",
)
)
serializer_class = OrganizationSourceExtensionSerializer
permission_classes = [IsAuthenticated]
@@ -488,6 +467,7 @@ class OrganizationSourceRecordViewSet(ReadOnlyModelViewSet):
"extension",
"extension__organization",
)
.filter(extension__organization__opk_registry_membership=True)
.prefetch_related("financial_lines")
.order_by("-created_at", "-uid")
)
@@ -542,7 +522,6 @@ class OrganizationSourceRecordViewSet(ReadOnlyModelViewSet):
source = params.get("source")
record_type = params.get("record_type")
organization = params.get("organization")
has_registry = params.get("has_registry")
search_terms = SearchFilter().get_search_terms(self.request)
if source_group:
@@ -553,30 +532,11 @@ class OrganizationSourceRecordViewSet(ReadOnlyModelViewSet):
queryset = queryset.filter(record_type=record_type)
if organization:
queryset = queryset.filter(extension__organization_id=organization)
if has_registry is not None:
registry_query = self._registry_membership_query()
if _is_truthy_query_value(has_registry):
queryset = queryset.filter(registry_query)
else:
queryset = queryset.exclude(registry_query)
if search_terms:
queryset = self._filter_search_queryset(queryset, search_terms)
return queryset
@staticmethod
def _registry_membership_query():
(
inn_values,
ogrn_values,
) = OrganizationFilter._registry_identity_value_querysets()
return (
Q(extension__organization__inn__in=inn_values)
| Q(extension__organization__ogrn__in=ogrn_values)
| Q(extension__organization__ogrip__in=ogrn_values)
)
@classmethod
def _filter_search_queryset(cls, queryset, search_terms: list[str]):
queryset = queryset.annotate(