From 46d09713201d48bcf7ac1f63c534beb6a2684d63 Mon Sep 17 00:00:00 2001 From: Aleksandr Meshchriakov Date: Thu, 13 Aug 2026 14:12:53 +0200 Subject: [PATCH] feat: import gosedo and media news sources --- src/apps/exchange/services.py | 281 +++++++++++++++++- src/apps/external_data/api.py | 104 +++++++ .../0011_gosedo_and_media_mentions.py | 77 +++++ src/apps/external_data/models.py | 117 ++++++++ src/apps/external_data/serializers.py | 70 +++++ .../external_data/source_record_export.py | 89 +++++- src/apps/external_data/urls.py | 8 + tests/apps/exchange/test_api.py | 121 +++++++- tests/apps/external_data/test_api.py | 45 +++ tests/apps/external_data/test_export_tasks.py | 2 +- .../test_source_record_export.py | 41 ++- 11 files changed, 929 insertions(+), 26 deletions(-) create mode 100644 src/apps/external_data/migrations/0011_gosedo_and_media_mentions.py diff --git a/src/apps/exchange/services.py b/src/apps/exchange/services.py index 444cdfb..34e3e83 100644 --- a/src/apps/exchange/services.py +++ b/src/apps/exchange/services.py @@ -25,6 +25,7 @@ from apps.external_data.models import ( ArbitrationCase, BankruptcyProcedure, DefenseUnreliableSupplier, + ElectronicDocumentExchangeEntry, FinancialReport, FinancialReportLine, IndustrialCertificate, @@ -32,6 +33,7 @@ from apps.external_data.models import ( InformationSecurityRegistryEntry, LaborVacancy, ManufacturerRegistryEntry, + MediaMention, ProsecutorCheck, PublicProcurement, ) @@ -68,7 +70,7 @@ class ExchangePackageImportService: AAD = b"state-corp-exchange-v1" PAYLOAD_FORMAT = "state-corp-exchange-payload" BIN_FORMAT = "state-corp-exchange-bin" - SUPPORTED_SCHEMA_VERSION = 3 + SUPPORTED_SCHEMA_VERSION = 4 ORGANIZATION_STR_FIELDS = { "full_name": ("full_name",), "short_name": ("short_name",), @@ -196,6 +198,8 @@ class ExchangePackageImportService: "defense_unreliable_suppliers", "information_security_registries", "labor_vacancies", + "electronic_document_exchange", + "media_mentions", ) @classmethod @@ -212,7 +216,10 @@ class ExchangePackageImportService: package_id = cls._read_required_str(manifest, "package_id") schema_version = cls._read_schema_version(decoded.payload, manifest) source_system = str(manifest.get("source_system") or "").strip() - key_id = str(decoded.header.get("key_id") or settings.EXCHANGE_KEY_ID).strip() + key_id = str( + decoded.header.get("key_id") + or getattr(settings, "EXCHANGE_KEY_ID", "default") + ).strip() duplicate = cls._find_duplicate( package_id=package_id, @@ -412,7 +419,7 @@ class ExchangePackageImportService: header: dict[str, Any], encrypted_payload: bytes, ) -> dict[str, Any]: - token = str(settings.EXCHANGE_SHARED_TOKEN or "").strip() + token = str(getattr(settings, "EXCHANGE_SHARED_TOKEN", "") or "").strip() if not token: raise ExchangeImportError("EXCHANGE_SHARED_TOKEN не настроен") @@ -423,7 +430,10 @@ class ExchangePackageImportService: try: compressed_payload = AESGCM(raw_key).decrypt(nonce, encrypted_payload, aad) payload_bytes = zlib.decompress(compressed_payload) - return json.loads(payload_bytes.decode("utf-8")) + payload = json.loads(payload_bytes.decode("utf-8")) + if not isinstance(payload, dict): + raise ExchangeImportError("Payload пакета должен быть объектом") + return payload except ExchangeImportError: raise except Exception as exc: # noqa: BLE001 @@ -466,10 +476,40 @@ class ExchangePackageImportService: ) if "registry_memberships" in data: raise ExchangeImportError( - "Раздел registry_memberships не поддерживается в schema_version 3" + "Раздел registry_memberships не поддерживается в schema_version 4" ) + cls._validate_manifest_sections(manifest, data) cls._validate_organization_rows_schema(data) + @classmethod + def _validate_manifest_sections( + cls, + manifest: dict[str, Any], + data: dict[str, Any], + ) -> None: + sections = manifest.get("sections") + if not isinstance(sections, list) or any( + not isinstance(section, str) for section in sections + ): + raise ExchangeImportError("manifest.sections должен быть списком строк") + if len(sections) != len(set(sections)): + raise ExchangeImportError("manifest.sections содержит дубликаты") + if set(sections) != set(cls.SECTION_KEYS): + raise ExchangeImportError( + "manifest.sections должен перечислять все разделы schema_version 4" + ) + missing_data_sections = [section for section in sections if section not in data] + if missing_data_sections: + raise ExchangeImportError( + "В data отсутствуют разделы: " + ", ".join(missing_data_sections) + ) + unexpected_data_sections = sorted(set(data) - set(sections)) + if unexpected_data_sections: + raise ExchangeImportError( + "В data присутствуют неизвестные разделы: " + + ", ".join(unexpected_data_sections) + ) + @classmethod def _validate_organization_rows_schema(cls, data: dict[str, Any]) -> None: organization_rows = cls._extract_rows(data, "organizations") @@ -482,7 +522,7 @@ class ExchangePackageImportService: if missing_fields: raise ExchangeImportError( "Строка organizations " - f"#{index} не соответствует schema_version 3; " + f"#{index} не соответствует schema_version 4; " "отсутствуют поля: " f"{', '.join(missing_fields)}" ) @@ -662,6 +702,14 @@ class ExchangePackageImportService: cls._extract_rows(data, "labor_vacancies"), allowed_organization_inns=allowed_organization_inns, ) + electronic_document_exchange_summary = cls._upsert_electronic_document_exchange( + cls._extract_rows(data, "electronic_document_exchange"), + allowed_organization_inns=allowed_organization_inns, + ) + media_mentions_summary = cls._upsert_media_mentions( + cls._extract_rows(data, "media_mentions"), + allowed_organization_inns=allowed_organization_inns, + ) return { "organizations": organization_summary, @@ -676,6 +724,8 @@ class ExchangePackageImportService: "defense_unreliable_suppliers": defense_supplier_summary, "information_security_registries": information_security_summary, "labor_vacancies": labor_vacancy_summary, + "electronic_document_exchange": electronic_document_exchange_summary, + "media_mentions": media_mentions_summary, } @classmethod @@ -796,11 +846,11 @@ class ExchangePackageImportService: value, present = cls._get_present_value(row, aliases) if not present: continue - parsed_value = cls._parse_date_value(value, field_name=field_name) + parsed_date_value = cls._parse_date_value(value, field_name=field_name) cls._set_organization_field( organization=organization, field_name=field_name, - value=parsed_value, + value=parsed_date_value, update_fields=update_fields, ) @@ -808,14 +858,14 @@ class ExchangePackageImportService: value, present = cls._get_present_value(row, aliases) if not present: continue - parsed_value = cls._parse_optional_bool_value( + parsed_bool_value = cls._parse_optional_bool_value( value, field_name=field_name, ) cls._set_organization_field( organization=organization, field_name=field_name, - value=parsed_value, + value=parsed_bool_value, update_fields=update_fields, ) @@ -823,11 +873,14 @@ class ExchangePackageImportService: value, present = cls._get_present_value(row, aliases) if not present: continue - parsed_value = cls._parse_optional_int_value(value, field_name=field_name) + parsed_int_value = cls._parse_optional_int_value( + value, + field_name=field_name, + ) cls._set_organization_field( organization=organization, field_name=field_name, - value=parsed_value, + value=parsed_int_value, update_fields=update_fields, ) @@ -835,7 +888,7 @@ class ExchangePackageImportService: value, present = cls._get_present_value(row, aliases) if not present: continue - parsed_value = cls._parse_decimal_value( + parsed_decimal_value = cls._parse_decimal_value( value, field_name=field_name, allow_null=True, @@ -843,7 +896,7 @@ class ExchangePackageImportService: cls._set_organization_field( organization=organization, field_name=field_name, - value=parsed_value, + value=parsed_decimal_value, update_fields=update_fields, ) @@ -1583,6 +1636,198 @@ class ExchangePackageImportService: "skipped": skipped_count, } + @classmethod + def _upsert_electronic_document_exchange( + cls, + rows: list[dict[str, Any]], + *, + allowed_organization_inns: set[str], + ) -> dict[str, int]: + created_count = 0 + updated_count = 0 + expected_keys: set[tuple[uuid.UUID, str, str]] = set() + for row in rows: + organization = cls._resolve_organization( + row, + allowed_organization_inns=allowed_organization_inns, + ) + external_id = cls._clean_string(row.get("external_id")) + record_type = cls._normalize_choice( + row.get("record_type"), + field_name="record_type", + allowed_values=ElectronicDocumentExchangeEntry.RecordType.values, + ) + if not external_id: + raise ExchangeImportError( + "В строке electronic_document_exchange отсутствует external_id" + ) + source_version = cls._clean_string(row.get("source_version")) + if not source_version: + raise ExchangeImportError( + "В строке electronic_document_exchange отсутствует source_version" + ) + source_published_at = cls._parse_date_value( + row.get("source_published_at"), + field_name="source_published_at", + ) + extra_fields = row.get("extra_fields") or {} + if not isinstance(extra_fields, dict): + raise ExchangeImportError("Поле extra_fields должно быть объектом") + lookup = { + "organization": organization, + "record_type": record_type, + "external_id": external_id, + } + defaults = { + "title": cls._clean_string(row.get("title")), + "status": cls._clean_string(row.get("status")), + "operator_uid": cls._clean_string(row.get("operator_uid")), + "medo_address": cls._clean_string(row.get("medo_address")), + "short_name": cls._clean_string(row.get("short_name")), + "full_name": cls._clean_string(row.get("full_name")), + "responsible_person": cls._clean_string(row.get("responsible_person")), + "responsible_phone": cls._clean_string(row.get("responsible_phone")), + "responsible_email": cls._clean_string(row.get("responsible_email")), + "responsible_phones": cls._clean_string_list( + row.get("responsible_phones"), + field_name="responsible_phones", + ), + "responsible_emails": cls._clean_string_list( + row.get("responsible_emails"), + field_name="responsible_emails", + ), + "responsible_contacts_raw": cls._clean_string( + row.get("responsible_contacts_raw") + ), + "participant_status": cls._clean_string(row.get("participant_status")), + "participant_status_raw": cls._clean_string( + row.get("participant_status_raw") + ), + "attestation_status": cls._clean_string(row.get("attestation_status")), + "attestation_status_raw": cls._clean_string( + row.get("attestation_status_raw") + ), + "registration_type": cls._clean_string(row.get("registration_type")), + "registration_number": cls._clean_string( + row.get("registration_number") + ), + "legal_address_raw": cls._clean_string(row.get("legal_address_raw")), + "organization_contacts": cls._clean_string( + row.get("organization_contacts") + ), + "source_version": source_version, + "source_published_at": source_published_at, + "source_row_class": cls._clean_string(row.get("source_row_class")), + "extra_fields": extra_fields, + "source_url": cls._clean_string(row.get("source_url")), + "load_batch": cls._parse_optional_int( + row.get("load_batch"), + field_name="load_batch", + ), + } + state = cls._upsert_external_row( + model=ElectronicDocumentExchangeEntry, + lookup=lookup, + defaults=defaults, + ) + expected_keys.add((organization.id, record_type, external_id)) + if state == "created": + created_count += 1 + elif state == "updated": + updated_count += 1 + + scope = ElectronicDocumentExchangeEntry.objects.filter( + organization__inn__in=allowed_organization_inns + ) + stale_ids = [ + item.id + for item in scope.only( + "id", "organization_id", "record_type", "external_id" + ) + if (item.organization_id, item.record_type, item.external_id) + not in expected_keys + ] + deleted_count = 0 + if stale_ids: + deleted_count = ElectronicDocumentExchangeEntry.objects.filter( + id__in=stale_ids + ).delete()[0] + return { + "created": created_count, + "updated": updated_count, + "deleted": deleted_count, + "skipped": 0, + } + + @classmethod + def _upsert_media_mentions( + cls, + rows: list[dict[str, Any]], + *, + allowed_organization_inns: set[str], + ) -> dict[str, int]: + created_count = 0 + updated_count = 0 + for row in rows: + organization = cls._resolve_organization( + row, + allowed_organization_inns=allowed_organization_inns, + ) + external_id = cls._clean_string(row.get("external_id")) + news_source = cls._clean_string(row.get("news_source")) + full_text = cls._clean_string(row.get("full_text")) + if not external_id or not news_source or not full_text: + raise ExchangeImportError( + "В строке media_mentions отсутствуют обязательные поля" + ) + sentiment = cls._normalize_choice( + row.get("sentiment"), + field_name="sentiment", + allowed_values=MediaMention.Sentiment.values, + ) + excerpt_lines = cls._clean_string_list( + row.get("excerpt_lines"), + field_name="excerpt_lines", + ) + if len(excerpt_lines) > 4: + raise ExchangeImportError( + "Поле excerpt_lines должно содержать не более четырех строк" + ) + defaults = { + "news_source": news_source, + "title": cls._clean_string(row.get("title")), + "sentiment": sentiment, + "published_at": cls._parse_date_value( + row.get("published_at"), + field_name="published_at", + ), + "source_url": cls._clean_string(row.get("source_url")), + "excerpt_lines": excerpt_lines, + "full_text": full_text, + "okpo": cls._clean_digits(row.get("okpo")), + "load_batch": cls._parse_optional_int( + row.get("load_batch"), + field_name="load_batch", + ), + } + state = cls._upsert_external_row( + model=MediaMention, + lookup={ + "organization": organization, + "external_id": external_id, + }, + defaults=defaults, + ) + if state == "created": + created_count += 1 + elif state == "updated": + updated_count += 1 + return { + "created": created_count, + "updated": updated_count, + "skipped": 0, + } + @classmethod def _upsert_external_row( cls, @@ -1640,6 +1885,14 @@ class ExchangePackageImportService: return cls._clean_digits(row.get(key)) return "" + @classmethod + def _clean_string_list(cls, value: Any, *, field_name: str) -> list[str]: + if value in (None, ""): + return [] + if not isinstance(value, list): + raise ExchangeImportError(f"Поле {field_name} должно быть списком") + return [cls._clean_string(item) for item in value if cls._clean_string(item)] + @staticmethod def _get_first_value(row: dict[str, Any], aliases: tuple[str, ...]) -> Any: for alias in aliases: diff --git a/src/apps/external_data/api.py b/src/apps/external_data/api.py index 27dfb22..bd87f9b 100644 --- a/src/apps/external_data/api.py +++ b/src/apps/external_data/api.py @@ -5,12 +5,14 @@ from apps.external_data.models import ( ArbitrationCase, BankruptcyProcedure, DefenseUnreliableSupplier, + ElectronicDocumentExchangeEntry, FinancialReport, IndustrialCertificate, IndustrialProduct, InformationSecurityRegistryEntry, LaborVacancy, ManufacturerRegistryEntry, + MediaMention, ProsecutorCheck, PublicProcurement, ) @@ -18,12 +20,15 @@ from apps.external_data.serializers import ( ArbitrationCaseSerializer, BankruptcyProcedureSerializer, DefenseUnreliableSupplierSerializer, + ElectronicDocumentExchangeEntrySerializer, FinancialReportSerializer, IndustrialCertificateSerializer, IndustrialProductSerializer, InformationSecurityRegistryEntrySerializer, LaborVacancySerializer, ManufacturerRegistryEntrySerializer, + MediaMentionDetailSerializer, + MediaMentionListSerializer, ProsecutorCheckSerializer, PublicProcurementSerializer, ) @@ -160,6 +165,52 @@ class FinancialReportFilter(filters.FilterSet): fields = ["organization", "status"] +class ElectronicDocumentExchangeEntryFilter(filters.FilterSet): + organization = filters.UUIDFilter(field_name="organization_id") + record_type = filters.CharFilter(lookup_expr="exact") + status = filters.CharFilter(lookup_expr="exact") + attestation_status = filters.CharFilter(lookup_expr="exact") + source_version = filters.CharFilter(lookup_expr="exact") + source_published_at_from = filters.DateFilter( + field_name="source_published_at", lookup_expr="gte" + ) + source_published_at_to = filters.DateFilter( + field_name="source_published_at", lookup_expr="lte" + ) + + class Meta: + model = ElectronicDocumentExchangeEntry + fields = [ + "organization", + "record_type", + "status", + "attestation_status", + "source_version", + "source_published_at_from", + "source_published_at_to", + ] + + +class MediaMentionFilter(filters.FilterSet): + organization = filters.UUIDFilter(field_name="organization_id") + source = filters.CharFilter(field_name="news_source", lookup_expr="exact") + news_source = filters.CharFilter(lookup_expr="exact") + sentiment = filters.CharFilter(lookup_expr="exact") + published_at_from = filters.DateFilter(field_name="published_at", lookup_expr="gte") + published_at_to = filters.DateFilter(field_name="published_at", lookup_expr="lte") + + class Meta: + model = MediaMention + fields = [ + "organization", + "source", + "news_source", + "sentiment", + "published_at_from", + "published_at_to", + ] + + class IndustrialProductViewSet(ClassicReadOnlyViewSet[IndustrialProduct]): queryset = IndustrialProduct.objects.select_related("organization").all() serializer_class = IndustrialProductSerializer @@ -282,3 +333,56 @@ class FinancialReportViewSet(ClassicReadOnlyViewSet[FinancialReport]): search_fields = ["external_id", "file_name", "ogrn", "status"] ordering_fields = ["created_at", "updated_at"] ordering = ["-created_at"] + + +class ElectronicDocumentExchangeEntryViewSet( + ClassicReadOnlyViewSet[ElectronicDocumentExchangeEntry] +): + queryset = ElectronicDocumentExchangeEntry.objects.select_related( + "organization" + ).all() + serializer_class = ElectronicDocumentExchangeEntrySerializer + permission_classes = [IsAuthenticated] + filterset_class = ElectronicDocumentExchangeEntryFilter + search_fields = [ + "external_id", + "title", + "short_name", + "full_name", + "registration_number", + "organization__name", + "organization__inn", + "organization__ogrn", + "organization__okpo", + ] + ordering_fields = [ + "source_published_at", + "source_version", + "record_type", + "status", + "organization__name", + "created_at", + ] + ordering = ["organization__name", "record_type", "external_id"] + + +class MediaMentionViewSet(ClassicReadOnlyViewSet[MediaMention]): + queryset = MediaMention.objects.select_related("organization").all() + serializer_class = MediaMentionListSerializer + permission_classes = [IsAuthenticated] + filterset_class = MediaMentionFilter + search_fields = [ + "news_source", + "title", + "full_text", + "organization__name", + "organization__inn", + "okpo", + ] + ordering_fields = ["published_at", "news_source", "sentiment", "created_at"] + ordering = ["-published_at", "news_source", "external_id"] + + def get_serializer_class(self): + if self.action == "retrieve": + return MediaMentionDetailSerializer + return MediaMentionListSerializer diff --git a/src/apps/external_data/migrations/0011_gosedo_and_media_mentions.py b/src/apps/external_data/migrations/0011_gosedo_and_media_mentions.py new file mode 100644 index 0000000..9ef8d09 --- /dev/null +++ b/src/apps/external_data/migrations/0011_gosedo_and_media_mentions.py @@ -0,0 +1,77 @@ +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("organization", "0004_organization_directory_fields"), + ("external_data", "0010_alter_prosecutor_check_control_authority"), + ] + + operations = [ + migrations.CreateModel( + name="MediaMention", + fields=[ + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True, help_text="Дата и время создания записи", verbose_name="создано")), + ("updated_at", models.DateTimeField(auto_now=True, help_text="Дата и время последнего обновления", verbose_name="обновлено")), + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name="ID")), + ("external_id", models.CharField(db_index=True, max_length=64, verbose_name="внешний ID")), + ("news_source", models.CharField(db_index=True, max_length=500, verbose_name="источник СМИ")), + ("title", models.TextField(blank=True, default="", verbose_name="заголовок")), + ("sentiment", models.CharField(choices=[("positive", "Положительная"), ("negative", "Отрицательная")], db_index=True, max_length=16, verbose_name="тональность")), + ("published_at", models.DateField(db_index=True, verbose_name="дата публикации")), + ("source_url", models.TextField(blank=True, default="", verbose_name="ссылка на источник")), + ("excerpt_lines", models.JSONField(blank=True, default=list, verbose_name="первые строки")), + ("full_text", models.TextField(verbose_name="полный текст")), + ("okpo", models.CharField(blank=True, default="", max_length=32, verbose_name="ОКПО")), + ("load_batch", models.PositiveIntegerField(blank=True, db_index=True, null=True)), + ("organization", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="media_mentions", to="organization.organization", verbose_name="организация")), + ], + options={"ordering": ["-published_at", "news_source", "external_id"]}, + ), + migrations.CreateModel( + name="ElectronicDocumentExchangeEntry", + fields=[ + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True, help_text="Дата и время создания записи", verbose_name="создано")), + ("updated_at", models.DateTimeField(auto_now=True, help_text="Дата и время последнего обновления", verbose_name="обновлено")), + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name="ID")), + ("external_id", models.CharField(db_index=True, max_length=255, verbose_name="внешний ID")), + ("record_type", models.CharField(choices=[("participant", "Участник"), ("operator", "Оператор"), ("organizer", "Организатор")], db_index=True, max_length=16, verbose_name="тип записи")), + ("title", models.TextField(blank=True, default="", verbose_name="наименование")), + ("status", models.CharField(blank=True, db_index=True, default="", max_length=64, verbose_name="статус")), + ("operator_uid", models.CharField(blank=True, default="", max_length=255)), + ("medo_address", models.TextField(blank=True, default="")), + ("short_name", models.TextField(blank=True, default="")), + ("full_name", models.TextField(blank=True, default="")), + ("responsible_person", models.TextField(blank=True, default="")), + ("responsible_phone", models.CharField(blank=True, default="", max_length=255)), + ("responsible_email", models.CharField(blank=True, default="", max_length=320)), + ("responsible_phones", models.JSONField(blank=True, default=list)), + ("responsible_emails", models.JSONField(blank=True, default=list)), + ("responsible_contacts_raw", models.TextField(blank=True, default="")), + ("participant_status", models.CharField(blank=True, db_index=True, default="", max_length=16)), + ("participant_status_raw", models.TextField(blank=True, default="")), + ("attestation_status", models.CharField(blank=True, db_index=True, default="", max_length=32)), + ("attestation_status_raw", models.TextField(blank=True, default="")), + ("registration_type", models.CharField(blank=True, default="", max_length=32)), + ("registration_number", models.CharField(blank=True, db_index=True, default="", max_length=64)), + ("legal_address_raw", models.TextField(blank=True, default="")), + ("organization_contacts", models.TextField(blank=True, default="")), + ("source_version", models.CharField(db_index=True, max_length=255)), + ("source_published_at", models.DateField(db_index=True)), + ("source_row_class", models.CharField(blank=True, default="", max_length=16)), + ("extra_fields", models.JSONField(blank=True, default=dict)), + ("source_url", models.TextField(blank=True, default="")), + ("load_batch", models.PositiveIntegerField(blank=True, db_index=True, null=True)), + ("organization", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="electronic_document_exchange_entries", to="organization.organization", verbose_name="организация")), + ], + options={"ordering": ["organization__name", "record_type", "external_id"]}, + ), + migrations.AddIndex(model_name="mediamention", index=models.Index(fields=["news_source", "sentiment"], name="external_da_news_so_50b9de_idx")), + migrations.AddIndex(model_name="mediamention", index=models.Index(fields=["organization", "published_at"], name="external_da_organiz_10abc6_idx")), + migrations.AddConstraint(model_name="mediamention", constraint=models.UniqueConstraint(fields=("organization", "external_id"), name="unique_external_media_mention")), + migrations.AddIndex(model_name="electronicdocumentexchangeentry", index=models.Index(fields=["record_type", "status"], name="external_da_record__489685_idx")), + migrations.AddIndex(model_name="electronicdocumentexchangeentry", index=models.Index(fields=["source_version", "source_published_at"], name="external_da_source__9465ff_idx")), + migrations.AddConstraint(model_name="electronicdocumentexchangeentry", constraint=models.UniqueConstraint(fields=("organization", "record_type", "external_id"), name="unique_external_gosedo_entry")), + ] diff --git a/src/apps/external_data/models.py b/src/apps/external_data/models.py index 090dafb..e1c4d4d 100644 --- a/src/apps/external_data/models.py +++ b/src/apps/external_data/models.py @@ -381,3 +381,120 @@ class FinancialReportLine(UUIDPrimaryKeyMixin, TimestampMixin, models.Model): def __str__(self) -> str: return f"{self.line_code} ({self.line_name[:30]}) - {self.year}" + + +class ElectronicDocumentExchangeEntry( + UUIDPrimaryKeyMixin, + TimestampMixin, + models.Model, +): + """Published Gosedo directory entry imported from Mostovik.""" + + class RecordType(models.TextChoices): + PARTICIPANT = "participant", _("Участник") + OPERATOR = "operator", _("Оператор") + ORGANIZER = "organizer", _("Организатор") + + organization = models.ForeignKey( + Organization, + on_delete=models.CASCADE, + related_name="electronic_document_exchange_entries", + verbose_name=_("организация"), + ) + external_id = models.CharField(_("внешний ID"), max_length=255, db_index=True) + record_type = models.CharField( + _("тип записи"), max_length=16, choices=RecordType.choices, db_index=True + ) + title = models.TextField(_("наименование"), blank=True, default="") + status = models.CharField( + _("статус"), max_length=64, blank=True, default="", db_index=True + ) + operator_uid = models.CharField(max_length=255, blank=True, default="") + medo_address = models.TextField(blank=True, default="") + short_name = models.TextField(blank=True, default="") + full_name = models.TextField(blank=True, default="") + responsible_person = models.TextField(blank=True, default="") + responsible_phone = models.CharField(max_length=255, blank=True, default="") + responsible_email = models.CharField(max_length=320, blank=True, default="") + responsible_phones = models.JSONField(default=list, blank=True) + responsible_emails = models.JSONField(default=list, blank=True) + responsible_contacts_raw = models.TextField(blank=True, default="") + participant_status = models.CharField( + max_length=16, blank=True, default="", db_index=True + ) + participant_status_raw = models.TextField(blank=True, default="") + attestation_status = models.CharField( + max_length=32, blank=True, default="", db_index=True + ) + attestation_status_raw = models.TextField(blank=True, default="") + registration_type = models.CharField(max_length=32, blank=True, default="") + registration_number = models.CharField( + max_length=64, blank=True, default="", db_index=True + ) + legal_address_raw = models.TextField(blank=True, default="") + organization_contacts = models.TextField(blank=True, default="") + source_version = models.CharField(max_length=255, db_index=True) + source_published_at = models.DateField(db_index=True) + source_row_class = models.CharField(max_length=16, blank=True, default="") + extra_fields = models.JSONField(default=dict, blank=True) + source_url = models.TextField(blank=True, default="") + load_batch = models.PositiveIntegerField(null=True, blank=True, db_index=True) + + class Meta: + ordering = ["organization__name", "record_type", "external_id"] + constraints = [ + models.UniqueConstraint( + fields=["organization", "record_type", "external_id"], + name="unique_external_gosedo_entry", + ) + ] + indexes = [ + models.Index(fields=["record_type", "status"]), + models.Index(fields=["source_version", "source_published_at"]), + ] + + def __str__(self) -> str: + return f"{self.organization_id}: {self.record_type} {self.external_id}" + + +class MediaMention(UUIDPrimaryKeyMixin, TimestampMixin, models.Model): + """Organization media mention imported from Mostovik.""" + + class Sentiment(models.TextChoices): + POSITIVE = "positive", _("Положительная") + NEGATIVE = "negative", _("Отрицательная") + + organization = models.ForeignKey( + Organization, + on_delete=models.CASCADE, + related_name="media_mentions", + verbose_name=_("организация"), + ) + external_id = models.CharField(_("внешний ID"), max_length=64, db_index=True) + news_source = models.CharField(_("источник СМИ"), max_length=500, db_index=True) + title = models.TextField(_("заголовок"), blank=True, default="") + sentiment = models.CharField( + _("тональность"), max_length=16, choices=Sentiment.choices, db_index=True + ) + published_at = models.DateField(_("дата публикации"), db_index=True) + source_url = models.TextField(_("ссылка на источник"), blank=True, default="") + excerpt_lines = models.JSONField(_("первые строки"), default=list, blank=True) + full_text = models.TextField(_("полный текст")) + okpo = models.CharField(_("ОКПО"), max_length=32, blank=True, default="") + load_batch = models.PositiveIntegerField(null=True, blank=True, db_index=True) + + class Meta: + ordering = ["-published_at", "news_source", "external_id"] + constraints = [ + models.UniqueConstraint( + fields=["organization", "external_id"], + name="unique_external_media_mention", + ) + ] + indexes = [ + models.Index(fields=["news_source", "sentiment"]), + models.Index(fields=["organization", "published_at"]), + ] + + def __str__(self) -> str: + return f"{self.organization_id}: {self.news_source} — {self.title}" diff --git a/src/apps/external_data/serializers.py b/src/apps/external_data/serializers.py index 4711261..0355d30 100644 --- a/src/apps/external_data/serializers.py +++ b/src/apps/external_data/serializers.py @@ -4,6 +4,7 @@ from apps.external_data.models import ( ArbitrationCase, BankruptcyProcedure, DefenseUnreliableSupplier, + ElectronicDocumentExchangeEntry, FinancialReport, FinancialReportLine, IndustrialCertificate, @@ -11,6 +12,7 @@ from apps.external_data.models import ( InformationSecurityRegistryEntry, LaborVacancy, ManufacturerRegistryEntry, + MediaMention, ProsecutorCheck, PublicProcurement, ) @@ -225,3 +227,71 @@ class FinancialReportSerializer(serializers.ModelSerializer): CorporationMembershipSerializer = InformationSecurityRegistryEntrySerializer + + +class ElectronicDocumentExchangeEntrySerializer(serializers.ModelSerializer): + organization = serializers.UUIDField(source="organization_id", read_only=True) + + class Meta: + model = ElectronicDocumentExchangeEntry + fields = [ + "id", + "organization", + "external_id", + "record_type", + "title", + "status", + "operator_uid", + "medo_address", + "short_name", + "full_name", + "responsible_person", + "responsible_phone", + "responsible_email", + "responsible_phones", + "responsible_emails", + "responsible_contacts_raw", + "participant_status", + "participant_status_raw", + "attestation_status", + "attestation_status_raw", + "registration_type", + "registration_number", + "legal_address_raw", + "organization_contacts", + "source_version", + "source_published_at", + "source_row_class", + "extra_fields", + "source_url", + "load_batch", + "created_at", + "updated_at", + ] + + +class MediaMentionListSerializer(serializers.ModelSerializer): + organization = serializers.UUIDField(source="organization_id", read_only=True) + + class Meta: + model = MediaMention + fields = [ + "id", + "organization", + "external_id", + "news_source", + "title", + "sentiment", + "published_at", + "source_url", + "excerpt_lines", + "okpo", + "load_batch", + "created_at", + "updated_at", + ] + + +class MediaMentionDetailSerializer(MediaMentionListSerializer): + class Meta(MediaMentionListSerializer.Meta): + fields = [*MediaMentionListSerializer.Meta.fields, "full_text"] diff --git a/src/apps/external_data/source_record_export.py b/src/apps/external_data/source_record_export.py index f9adc60..acd21d9 100644 --- a/src/apps/external_data/source_record_export.py +++ b/src/apps/external_data/source_record_export.py @@ -23,6 +23,7 @@ from apps.external_data.models import ( ArbitrationCase, BankruptcyProcedure, DefenseUnreliableSupplier, + ElectronicDocumentExchangeEntry, FinancialReport, FinancialReportLine, IndustrialCertificate, @@ -30,6 +31,7 @@ from apps.external_data.models import ( InformationSecurityRegistryEntry, LaborVacancy, ManufacturerRegistryEntry, + MediaMention, ProsecutorCheck, PublicProcurement, ) @@ -106,6 +108,7 @@ class SourceModelExportSpec: record_type: str fields: tuple[str, ...] source: str + record_type_field: str | None = None source_field: str | None = None external_id_field: str | None = None title_field: str | None = None @@ -119,6 +122,7 @@ class SourceModelExportSpec: payload_export_fields: tuple[str, ...] | None = None prefetch_related: tuple[str, ...] = () export_year_lookup: str | None = None + all_history: bool = False @dataclass(frozen=True) @@ -410,6 +414,84 @@ SOURCE_GROUP_EXPORT_SPECS: dict[str, SourceGroupExportSpec] = { ), ), ), + "electronic_document_exchange": SourceGroupExportSpec( + source_group="electronic_document_exchange", + file_stem="gosedo-address-directory", + models=( + SourceModelExportSpec( + model=ElectronicDocumentExchangeEntry, + record_type="electronic_document_exchange", + fields=( + "external_id", + "record_type", + "title", + "status", + "operator_uid", + "medo_address", + "short_name", + "full_name", + "responsible_person", + "responsible_phone", + "responsible_email", + "responsible_phones", + "responsible_emails", + "responsible_contacts_raw", + "participant_status", + "participant_status_raw", + "attestation_status", + "attestation_status_raw", + "registration_type", + "registration_number", + "legal_address_raw", + "organization_contacts", + "source_version", + "source_published_at", + "source_row_class", + "extra_fields", + "source_url", + "load_batch", + ), + source="gosedo_address_directory", + record_type_field="record_type", + external_id_field="external_id", + title_field="title", + record_date_field="source_published_at", + status_field="status", + url_field="source_url", + load_batch_field="load_batch", + ), + ), + ), + "media_mentions": SourceGroupExportSpec( + source_group="media_mentions", + file_stem="media-mentions", + models=( + SourceModelExportSpec( + model=MediaMention, + record_type="media_mention", + fields=( + "external_id", + "news_source", + "title", + "sentiment", + "published_at", + "source_url", + "excerpt_lines", + "full_text", + "okpo", + "load_batch", + ), + source="media_news", + external_id_field="external_id", + title_field="title", + record_date_field="published_at", + status_field="sentiment", + url_field="source_url", + load_batch_field="load_batch", + all_history=True, + ), + ), + ), } @@ -865,6 +947,8 @@ def _source_model_queryset( QuerySet, model_spec.model.objects.select_related("organization").order_by(), ) + if model_spec.all_history: + return queryset if model_spec.export_year_lookup: return queryset.filter( **{model_spec.export_year_lookup: export_year} @@ -896,7 +980,7 @@ def _iter_source_model_records( if not batch: return if model_spec.prefetch_related: - prefetches = [ + prefetches: list[str | Prefetch] = [ Prefetch( related_name, queryset=FinancialReportLine.objects.filter( @@ -1102,7 +1186,8 @@ def _build_record_row( "uid": record.id, "source_group": source_spec.source_group, "source": _source_value(record, model_spec=model_spec), - "record_type": model_spec.record_type, + "record_type": _record_field_value(record, model_spec.record_type_field) + or model_spec.record_type, "external_id": _record_field_value(record, model_spec.external_id_field), "title": _title_value(record, model_spec=model_spec), "record_date": _record_field_value(record, model_spec.record_date_field), diff --git a/src/apps/external_data/urls.py b/src/apps/external_data/urls.py index 1265596..94880c6 100644 --- a/src/apps/external_data/urls.py +++ b/src/apps/external_data/urls.py @@ -5,12 +5,14 @@ from apps.external_data.api import ( BankruptcyProcedureViewSet, CorporationMembershipViewSet, DefenseUnreliableSupplierViewSet, + ElectronicDocumentExchangeEntryViewSet, FinancialReportViewSet, IndustrialCertificateViewSet, IndustrialProductViewSet, InformationSecurityRegistryEntryViewSet, LaborVacancyViewSet, ManufacturerRegistryEntryViewSet, + MediaMentionViewSet, ProsecutorCheckViewSet, PublicProcurementViewSet, ) @@ -72,6 +74,12 @@ router.register( FinancialReportViewSet, basename="financial-reports", ) +router.register( + "electronic-document-exchange", + ElectronicDocumentExchangeEntryViewSet, + basename="electronic-document-exchange", +) +router.register("media-mentions", MediaMentionViewSet, basename="media-mentions") urlpatterns = [ path("", include(router.urls)), diff --git a/tests/apps/exchange/test_api.py b/tests/apps/exchange/test_api.py index d8676a3..f5fcfd4 100644 --- a/tests/apps/exchange/test_api.py +++ b/tests/apps/exchange/test_api.py @@ -20,6 +20,7 @@ from apps.external_data.models import ( ArbitrationCase, BankruptcyProcedure, DefenseUnreliableSupplier, + ElectronicDocumentExchangeEntry, FinancialReport, FinancialReportLine, IndustrialCertificate, @@ -27,6 +28,7 @@ from apps.external_data.models import ( InformationSecurityRegistryEntry, LaborVacancy, ManufacturerRegistryEntry, + MediaMention, ProsecutorCheck, PublicProcurement, ) @@ -58,6 +60,18 @@ def build_exchange_archive( schema_version: int = ExchangePackageImportService.SUPPORTED_SCHEMA_VERSION, ) -> SimpleUploadedFile: """Build encrypted exchange archive compatible with import service.""" + provided_data = data or {} + normalized_data = { + section: provided_data.get(section, []) + for section in ExchangePackageImportService.SECTION_KEYS + } + normalized_data.update( + { + section: rows + for section, rows in provided_data.items() + if section not in normalized_data + } + ) payload = { "format": ExchangePackageImportService.PAYLOAD_FORMAT, "schema_version": schema_version, @@ -66,9 +80,9 @@ def build_exchange_archive( "source_system": "mostovik-dev", "produced_at": "2026-04-07T12:00:00+00:00", "schema_version": schema_version, - "sections": list((data or {}).keys()), + "sections": list(ExchangePackageImportService.SECTION_KEYS), }, - "data": data or {}, + "data": normalized_data, } payload_bytes = json.dumps( payload, @@ -369,6 +383,41 @@ def build_exchange_payload() -> dict[str, list[dict[str, object]]]: "source_url": "https://trudvsem.ru/vacancy/001", } ], + "electronic_document_exchange": [ + { + "organization_inn": "7707083893", + "external_id": "gosedo:001", + "record_type": "participant", + "title": "АО Альфа", + "status": "active", + "participant_status": "active", + "attestation_status": "attested", + "registration_type": "ogrn", + "registration_number": "1027700132195", + "source_version": "633", + "source_published_at": "2026-08-10", + "source_row_class": "pt-on", + "responsible_phones": [], + "responsible_emails": [], + "extra_fields": {}, + "load_batch": 8, + } + ], + "media_mentions": [ + { + "organization_inn": "7707083893", + "external_id": "a" * 64, + "news_source": "Тестовое СМИ", + "title": "АО Альфа расширяет производство", + "sentiment": "positive", + "published_at": "2026-07-01", + "source_url": "https://example.test/news/1", + "excerpt_lines": ["СМИ", "Заголовок", "Лид", "Продолжение"], + "full_text": "СМИ\nЗаголовок\nЛид\nПродолжение\nПолный текст", + "okpo": "12345678", + "load_batch": 9, + } + ], } @@ -481,6 +530,8 @@ class ExchangePackageApiTest(APITestCase): self.assertEqual(DefenseUnreliableSupplier.objects.count(), 1) self.assertEqual(InformationSecurityRegistryEntry.objects.count(), 1) self.assertEqual(LaborVacancy.objects.count(), 1) + self.assertEqual(ElectronicDocumentExchangeEntry.objects.count(), 1) + self.assertEqual(MediaMention.objects.count(), 1) self.assertEqual( response.data["result"]["bankruptcy_procedures"]["created"], 1, @@ -499,6 +550,11 @@ class ExchangePackageApiTest(APITestCase): 1, ) self.assertEqual(response.data["result"]["labor_vacancies"]["created"], 1) + self.assertEqual( + response.data["result"]["electronic_document_exchange"]["created"], + 1, + ) + self.assertEqual(response.data["result"]["media_mentions"]["created"], 1) organization = Organization.objects.get(inn="7707083893") self.assertEqual(organization.name, "АО Альфа Обновленная") @@ -634,7 +690,7 @@ class ExchangePackageApiTest(APITestCase): def test_upload_rejects_legacy_schema_version(self): archive = build_exchange_archive( package_id="pkg-legacy-schema-version", - schema_version=2, + schema_version=3, data=build_exchange_payload(), ) @@ -646,7 +702,7 @@ class ExchangePackageApiTest(APITestCase): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn("schema_version 3", response.data["file"][0]) + self.assertIn("schema_version 4", response.data["file"][0]) self.assertEqual(Organization.objects.count(), 0) def test_upload_rejects_legacy_registry_memberships_section(self): @@ -697,7 +753,7 @@ class ExchangePackageApiTest(APITestCase): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn("schema_version 3", response.data["file"][0]) + self.assertIn("schema_version 4", response.data["file"][0]) self.assertEqual(Organization.objects.count(), 0) def test_upload_rejects_external_rows_for_organization_absent_from_package(self): @@ -769,6 +825,61 @@ class ExchangePackageApiTest(APITestCase): duplicate_import = ExchangePackageImport.objects.order_by("-created_at").first() self.assertIsNotNone(duplicate_import.duplicate_of) + def test_v4_gosedo_is_scoped_replacement_and_media_is_upsert_only(self): + first_archive = build_exchange_archive( + package_id="pkg-v4-first", + data=build_exchange_payload(), + ) + first_response = self.client.post( + self.url, + {"file": first_archive}, + format="multipart", + HTTP_X_EXCHANGE_TOKEN=TEST_TOKEN, + ) + self.assertEqual(first_response.status_code, status.HTTP_201_CREATED) + self.assertEqual(ElectronicDocumentExchangeEntry.objects.count(), 1) + self.assertEqual(MediaMention.objects.count(), 1) + outside_scope = Organization.objects.create( + inn="7707083999", + name="АО Вне пакета", + ) + ElectronicDocumentExchangeEntry.objects.create( + organization=outside_scope, + external_id="outside-scope", + record_type=ElectronicDocumentExchangeEntry.RecordType.PARTICIPANT, + title=outside_scope.name, + source_version="632", + source_published_at="2026-08-01", + ) + + second_payload = build_exchange_payload() + second_payload["electronic_document_exchange"] = [] + second_payload["media_mentions"] = [] + second_archive = build_exchange_archive( + package_id="pkg-v4-second", + data=second_payload, + ) + second_response = self.client.post( + self.url, + {"file": second_archive}, + format="multipart", + HTTP_X_EXCHANGE_TOKEN=TEST_TOKEN, + ) + + self.assertEqual(second_response.status_code, status.HTTP_201_CREATED) + self.assertEqual( + second_response.data["result"]["electronic_document_exchange"]["deleted"], + 1, + ) + self.assertEqual(ElectronicDocumentExchangeEntry.objects.count(), 1) + self.assertTrue( + ElectronicDocumentExchangeEntry.objects.filter( + organization=outside_scope, + external_id="outside-scope", + ).exists() + ) + self.assertEqual(MediaMention.objects.count(), 1) + def test_upload_replaces_stale_financial_report_lines(self): first_payload = build_exchange_payload() first_archive = build_exchange_archive( diff --git a/tests/apps/external_data/test_api.py b/tests/apps/external_data/test_api.py index 4202ccd..1f8eafe 100644 --- a/tests/apps/external_data/test_api.py +++ b/tests/apps/external_data/test_api.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import date +from apps.external_data.models import ElectronicDocumentExchangeEntry, MediaMention from django.test import override_settings from rest_framework import status from rest_framework.test import APITestCase @@ -212,3 +213,47 @@ class ExternalDataApiTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["count"], 1) self.assertEqual(response.data["results"][0]["lines"][0]["line_code"], "1600") + + def test_gosedo_and_media_endpoints_filter_and_separate_full_text(self): + ElectronicDocumentExchangeEntry.objects.create( + organization=self.organization, + external_id="gosedo-1", + record_type="participant", + title="АО Тест", + status="active", + attestation_status="attested", + source_version="633", + source_published_at=date(2026, 8, 10), + ) + mention = MediaMention.objects.create( + organization=self.organization, + external_id="a" * 64, + news_source="Тестовое СМИ", + title="Заголовок", + sentiment="positive", + published_at=date(2026, 7, 1), + excerpt_lines=["1", "2", "3", "4"], + full_text="1\n2\n3\n4\n5", + ) + + gosedo_response = self.client.get( + f"/api/v1/electronic-document-exchange/?organization={self.organization.id}" + "&record_type=participant&attestation_status=attested&source_version=633" + "&source_published_at_from=2026-08-01&source_published_at_to=2026-08-31" + ) + media_list_response = self.client.get( + f"/api/v1/media-mentions/?organization={self.organization.id}" + "&source=Тестовое СМИ&sentiment=positive" + ) + media_detail_response = self.client.get(f"/api/v1/media-mentions/{mention.id}/") + + self.assertEqual(gosedo_response.status_code, status.HTTP_200_OK) + self.assertEqual(gosedo_response.data["count"], 1) + self.assertEqual(media_list_response.status_code, status.HTTP_200_OK) + self.assertNotIn("full_text", media_list_response.data["results"][0]) + self.assertEqual( + media_list_response.data["results"][0]["excerpt_lines"], + ["1", "2", "3", "4"], + ) + self.assertEqual(media_detail_response.status_code, status.HTTP_200_OK) + self.assertEqual(media_detail_response.data["full_text"], "1\n2\n3\n4\n5") diff --git a/tests/apps/external_data/test_export_tasks.py b/tests/apps/external_data/test_export_tasks.py index 036d9e7..4931afa 100644 --- a/tests/apps/external_data/test_export_tasks.py +++ b/tests/apps/external_data/test_export_tasks.py @@ -36,7 +36,7 @@ class SourceRecordExportArtifactsTaskTest(TestCase): result = refresh_source_record_export_artifacts() self.assertEqual(result["status"], "success") - self.assertEqual(result["artifacts_count"], 25) + self.assertEqual(result["artifacts_count"], 31) self.assertEqual(result["export_year"], timezone.localdate().year) self.assertIsNone(cache.get(settings.SOURCE_RECORD_EXPORT_LOCK_KEY)) diff --git a/tests/apps/external_data/test_source_record_export.py b/tests/apps/external_data/test_source_record_export.py index c04aa32..876f1f7 100644 --- a/tests/apps/external_data/test_source_record_export.py +++ b/tests/apps/external_data/test_source_record_export.py @@ -7,6 +7,7 @@ from io import BytesIO, StringIO from tempfile import TemporaryDirectory from unittest.mock import patch +from apps.external_data.models import MediaMention from apps.external_data.source_record_export import ( ORGANIZATION_EXPORT_FIELDS, SOURCE_GROUP_EXPORT_SPECS, @@ -105,8 +106,8 @@ class SourceRecordExportApiTest(APITestCase): generation = build_source_record_export_artifacts() - self.assertEqual(generation.artifacts_count, 25) - self.assertEqual(generation.files_count, 25) + self.assertEqual(generation.artifacts_count, 31) + self.assertEqual(generation.files_count, 31) self.assertEqual(generation.records_count, 6) self.assertEqual(generation.export_year, current_date.year) expected_prefix = [*ORGANIZATION_EXPORT_FIELDS, *SOURCE_RECORD_EXPORT_FIELDS] @@ -193,6 +194,38 @@ class SourceRecordExportApiTest(APITestCase): ], ) + def test_media_mentions_export_keeps_all_history_and_full_text(self): + organization = OrganizationFactory.create(okpo="00123456") + mention = MediaMention.objects.create( + organization=organization, + external_id="a" * 64, + news_source="Архивное СМИ", + title="Старая публикация", + sentiment=MediaMention.Sentiment.POSITIVE, + published_at="2023-02-15", + excerpt_lines=["Строка 1", "Строка 2"], + full_text="Строка 1\nСтрока 2\nПолная история", + okpo=organization.okpo, + ) + + generation = build_source_record_export_artifacts( + now=datetime(2026, 8, 4, 6, 0, tzinfo=UTC) + ) + + media_path = next( + artifact.path + for artifact in generation.artifacts + if artifact.source_group == "media_mentions" + and artifact.file_format == "json" + ) + rows = json.loads(media_path.read_text(encoding="utf-8")) + exported = next(row for row in rows if row["uid"] == str(mention.id)) + self.assertEqual(exported["record_date"], "2023-02-15") + self.assertEqual( + exported["payload.full_text"], + "Строка 1\nСтрока 2\nПолная история", + ) + def test_generation_keeps_provider_records_but_hides_provider_mentions(self): organization = OrganizationFactory.create(okpo="11223344") excluded_record = BankruptcyProcedureFactory.create( @@ -257,9 +290,9 @@ class SourceRecordExportApiTest(APITestCase): call_command("build_source_record_exports", stdout=command_output) generation = load_current_source_record_export_generation() - self.assertEqual(generation.artifacts_count, 25) + self.assertEqual(generation.artifacts_count, 31) self.assertEqual(generation.export_year, timezone.localdate().year) - self.assertIn('"artifacts_count": 25', command_output.getvalue()) + self.assertIn('"artifacts_count": 31', command_output.getvalue()) def test_generation_contains_only_records_from_its_calendar_year(self): export_year = 2026