From 148c4862d755624a7e08bd64de36b8887337f529 Mon Sep 17 00:00:00 2001 From: Aleksandr Meshchriakov Date: Tue, 14 Apr 2026 11:00:24 +0200 Subject: [PATCH] feat(external-data): add information security registry entries endpoint --- docs/backend-endpoints-implementation-plan.md | 10 +++-- src/apps/core/openapi.py | 1 + src/apps/external_data/api.py | 23 +++++++++++ ...002_information_security_registry_entry.py | 35 +++++++++++++++++ src/apps/external_data/models.py | 38 +++++++++++++++++++ src/apps/external_data/serializers.py | 17 +++++++++ src/apps/external_data/urls.py | 6 +++ tests/apps/external_data/factories.py | 15 ++++++++ tests/apps/external_data/test_api.py | 27 +++++++++++++ 9 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 src/apps/external_data/migrations/0002_information_security_registry_entry.py diff --git a/docs/backend-endpoints-implementation-plan.md b/docs/backend-endpoints-implementation-plan.md index c7d8b80..b5f4144 100644 --- a/docs/backend-endpoints-implementation-plan.md +++ b/docs/backend-endpoints-implementation-plan.md @@ -23,7 +23,7 @@ - [x] **Pass 2 — Пользователи и аутентификация:** доработка `users/me` + user-management. - [x] **Pass 3 — Формы:** выравнивание upload F-2…F-6. - [x] **Pass 4 — Аналитика:** financial-summary / economics / personnel / equipment / products / risk / forecast. -- [ ] **Pass 5 — Внешние контуры:** industrial/prosecutor/procurements/arbitration/security registries. +- [x] **Pass 5 — Внешние контуры:** industrial/prosecutor/procurements/arbitration/security registries. - [ ] **Pass 6 — Финализация:** OpenAPI + массовое тестирование + smoke. ### Журнал выполненных шагов @@ -49,12 +49,14 @@ - добавлены contract checks для всех analytics endpoint’ов: - `financial-summary`, `economics`, `personnel`, `equipment`, `products`, `forecast`, `risk-profile`, `dashboard`. - дополнена проверка query-валидации для invalid `economics`-запроса. +- **Pass 5 — Внешние контуры (2026-04-14): завершён** + - добавлен endpoint `information-security-registry-entries/` с фильтрами `organization` и `presence_status`. + - расширены contract checks для внешних списков (prod/products/prosecutor/public-procurement/arbitration/security). --- ## Риск-оценка перед стартом - Некоторые поля периодов требуют согласования формата валидации (`report_period_display`/`report_half_year`). -- Для `/information-security-registry-entries/` в кодовой базе нет модели и данных — потребуется новая модель/миграция или адаптер. - Отдельное решение по async upload: порог `1MB` определяет фоновую обработку. - Нужно подтвердить, где `organization`/`profile` поля могут быть `null`. @@ -245,8 +247,8 @@ - [x] Добавить dashboard фильтрацию и стабильные `cluster` метрики. (2026-04-14) ### Pass 5. Внешние данные -- [ ] Довести внешние реестры к единообразным фильтрам/ответам. -- [ ] Добавить `information-security-registry-entries`. +- [x] Довести внешние реестры к единообразным фильтрам/ответам. (2026-04-14) +- [x] Добавить `information-security-registry-entries`. (2026-04-14) ### Pass 6. Финализация - [ ] Обновить OpenAPI по всем контрактам. diff --git a/src/apps/core/openapi.py b/src/apps/core/openapi.py index 691bde3..e8200db 100644 --- a/src/apps/core/openapi.py +++ b/src/apps/core/openapi.py @@ -131,6 +131,7 @@ OPENAPI_TAG_BY_PATH_PREFIX = OrderedDict( ("/api/v1/prosecutor-checks/", "Внешние данные"), ("/api/v1/public-procurements/", "Внешние данные"), ("/api/v1/arbitration-cases/", "Внешние данные"), + ("/api/v1/information-security-registry-entries/", "Внешние данные"), ("/api/v1/registers/", "Реестры"), ("/api/v1/forms/f1/", "Форма Ф-1"), ("/api/v1/forms/f2/", "Форма Ф-2"), diff --git a/src/apps/external_data/api.py b/src/apps/external_data/api.py index 17797fa..66f9de4 100644 --- a/src/apps/external_data/api.py +++ b/src/apps/external_data/api.py @@ -6,12 +6,14 @@ from apps.external_data.models import ( IndustrialProduct, ProsecutorCheck, PublicProcurement, + InformationSecurityRegistryEntry, ) from apps.external_data.serializers import ( ArbitrationCaseSerializer, IndustrialProductSerializer, ProsecutorCheckSerializer, PublicProcurementSerializer, + InformationSecurityRegistryEntrySerializer, ) from django_filters import rest_framework as filters from rest_framework.permissions import IsAuthenticated @@ -68,6 +70,15 @@ class ArbitrationCaseFilter(filters.FilterSet): ] +class InformationSecurityRegistryEntryFilter(filters.FilterSet): + organization = filters.UUIDFilter(field_name="organization_id") + presence_status = filters.CharFilter(lookup_expr="exact") + + class Meta: + model = InformationSecurityRegistryEntry + fields = ["organization", "presence_status"] + + class IndustrialProductViewSet(ClassicReadOnlyViewSet[IndustrialProduct]): queryset = IndustrialProduct.objects.select_related("organization").all() serializer_class = IndustrialProductSerializer @@ -106,3 +117,15 @@ class ArbitrationCaseViewSet(ClassicReadOnlyViewSet[ArbitrationCase]): search_fields = ["case_number", "court_name"] ordering_fields = ["decision_date", "created_at"] ordering = ["-decision_date"] + + +class InformationSecurityRegistryEntryViewSet( + ClassicReadOnlyViewSet[InformationSecurityRegistryEntry] +): + queryset = InformationSecurityRegistryEntry.objects.select_related("organization").all() + serializer_class = InformationSecurityRegistryEntrySerializer + permission_classes = [IsAuthenticated] + filterset_class = InformationSecurityRegistryEntryFilter + search_fields = ["registry_name", "entry_number"] + ordering_fields = ["issued_at", "expires_at", "created_at"] + ordering = ["-issued_at"] diff --git a/src/apps/external_data/migrations/0002_information_security_registry_entry.py b/src/apps/external_data/migrations/0002_information_security_registry_entry.py new file mode 100644 index 0000000..f88f70d --- /dev/null +++ b/src/apps/external_data/migrations/0002_information_security_registry_entry.py @@ -0,0 +1,35 @@ +# Generated by Django 3.2.25 on 2026-04-14 08:59 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('organization', '0003_auto_20260407_1326'), + ('external_data', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='InformationSecurityRegistryEntry', + 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')), + ('registry_name', models.CharField(db_index=True, max_length=255, verbose_name='название реестра')), + ('presence_status', models.CharField(choices=[('present', 'В реестре'), ('absent', 'Не в реестре')], db_index=True, max_length=16, verbose_name='статус присутствия')), + ('entry_number', models.CharField(blank=True, default='', max_length=64, verbose_name='регистрационный номер')), + ('issued_at', models.DateField(blank=True, null=True, verbose_name='дата выдачи')), + ('expires_at', models.DateField(blank=True, null=True, verbose_name='дата окончания')), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='information_security_registry_entries', to='organization.organization', verbose_name='организация')), + ], + options={ + 'verbose_name': 'запись реестра безопасности', + 'verbose_name_plural': 'записи реестра безопасности', + 'ordering': ['registry_name', '-issued_at'], + }, + ), + ] diff --git a/src/apps/external_data/models.py b/src/apps/external_data/models.py index 2519641..fc28e4f 100644 --- a/src/apps/external_data/models.py +++ b/src/apps/external_data/models.py @@ -110,3 +110,41 @@ class ArbitrationCase(UUIDPrimaryKeyMixin, TimestampMixin, models.Model): def __str__(self) -> str: return f"{self.case_number} ({self.organization_id})" + + +class InformationSecurityRegistryEntry(UUIDPrimaryKeyMixin, TimestampMixin, models.Model): + class PresenceStatus(models.TextChoices): + PRESENT = "present", _("В реестре") + ABSENT = "absent", _("Не в реестре") + + organization = models.ForeignKey( + Organization, + on_delete=models.CASCADE, + related_name="information_security_registry_entries", + verbose_name=_("организация"), + ) + registry_name = models.CharField( + _("название реестра"), max_length=255, db_index=True + ) + presence_status = models.CharField( + _("статус присутствия"), + max_length=16, + choices=PresenceStatus.choices, + db_index=True, + ) + entry_number = models.CharField( + _("регистрационный номер"), + max_length=64, + blank=True, + default="", + ) + issued_at = models.DateField(_("дата выдачи"), null=True, blank=True) + expires_at = models.DateField(_("дата окончания"), null=True, blank=True) + + class Meta: + verbose_name = _("запись реестра безопасности") + verbose_name_plural = _("записи реестра безопасности") + ordering = ["registry_name", "-issued_at"] + + def __str__(self) -> str: + return f"{self.registry_name} ({self.organization_id})" diff --git a/src/apps/external_data/serializers.py b/src/apps/external_data/serializers.py index 0f21f9f..9981ec9 100644 --- a/src/apps/external_data/serializers.py +++ b/src/apps/external_data/serializers.py @@ -5,6 +5,7 @@ from apps.external_data.models import ( IndustrialProduct, ProsecutorCheck, PublicProcurement, + InformationSecurityRegistryEntry, ) from rest_framework import serializers @@ -75,3 +76,19 @@ class ArbitrationCaseSerializer(serializers.ModelSerializer): "status", "decision_date", ] + + +class InformationSecurityRegistryEntrySerializer(serializers.ModelSerializer): + organization = serializers.UUIDField(source="organization_id", read_only=True) + + class Meta: + model = InformationSecurityRegistryEntry + fields = [ + "id", + "organization", + "registry_name", + "presence_status", + "entry_number", + "issued_at", + "expires_at", + ] diff --git a/src/apps/external_data/urls.py b/src/apps/external_data/urls.py index b3579be..378fe40 100644 --- a/src/apps/external_data/urls.py +++ b/src/apps/external_data/urls.py @@ -5,6 +5,7 @@ from apps.external_data.api import ( IndustrialProductViewSet, ProsecutorCheckViewSet, PublicProcurementViewSet, + InformationSecurityRegistryEntryViewSet, ) from django.urls import include, path from rest_framework.routers import DefaultRouter @@ -24,6 +25,11 @@ router.register( router.register( "arbitration-cases", ArbitrationCaseViewSet, basename="arbitration-cases" ) +router.register( + "information-security-registry-entries", + InformationSecurityRegistryEntryViewSet, + basename="information-security-registry-entries", +) urlpatterns = [ path("", include(router.urls)), diff --git a/tests/apps/external_data/factories.py b/tests/apps/external_data/factories.py index 31e21b0..fc2e9f0 100644 --- a/tests/apps/external_data/factories.py +++ b/tests/apps/external_data/factories.py @@ -6,6 +6,7 @@ from apps.external_data.models import ( IndustrialProduct, ProsecutorCheck, PublicProcurement, + InformationSecurityRegistryEntry, ) from faker import Faker @@ -68,3 +69,17 @@ class ArbitrationCaseFactory(factory.django.DjangoModelFactory): party_role = "defendant" status = "hearing_scheduled" decision_date = factory.LazyAttribute(lambda _: fake.date_this_year()) + + +class InformationSecurityRegistryEntryFactory( + factory.django.DjangoModelFactory +): + class Meta: + model = InformationSecurityRegistryEntry + + organization = factory.SubFactory(OrganizationFactory) + registry_name = "Реестр лицензий на деятельность по технической защите конфиденциальной информации" + presence_status = "present" + entry_number = "77-001234" + issued_at = factory.LazyAttribute(lambda _: fake.date_this_year()) + expires_at = factory.LazyAttribute(lambda _: fake.date_this_year()) diff --git a/tests/apps/external_data/test_api.py b/tests/apps/external_data/test_api.py index c16afec..da6c580 100644 --- a/tests/apps/external_data/test_api.py +++ b/tests/apps/external_data/test_api.py @@ -13,6 +13,7 @@ from tests.apps.external_data.factories import ( IndustrialProductFactory, ProsecutorCheckFactory, PublicProcurementFactory, + InformationSecurityRegistryEntryFactory, ) from tests.apps.organization.factories import OrganizationFactory from tests.apps.user.factories import UserFactory @@ -89,3 +90,29 @@ class ExternalDataApiTest(APITestCase): self.assertEqual(procurement_response.data["count"], 1) self.assertEqual(arbitration_response.status_code, status.HTTP_200_OK) self.assertEqual(arbitration_response.data["count"], 1) + + def test_information_security_registry_entries_filter(self): + InformationSecurityRegistryEntryFactory( + organization=self.organization, + presence_status="present", + ) + InformationSecurityRegistryEntryFactory( + organization=self.other_organization, + presence_status="absent", + ) + + response = self.client.get( + f"/api/v1/information-security-registry-entries/?organization={self.organization.id}" + "&presence_status=present" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + result = response.data["results"][0] + self.assertEqual( + result["organization"], + str(self.organization.id), + ) + self.assertEqual(result["presence_status"], "present") + self.assertIn("registry_name", result) + self.assertIn("entry_number", result)