feat(external-data): add information security registry entries endpoint

This commit is contained in:
2026-04-14 11:00:24 +02:00
parent f0c4f501a6
commit 148c4862d7
9 changed files with 168 additions and 4 deletions

View File

@@ -23,7 +23,7 @@
- [x] **Pass 2 — Пользователи и аутентификация:** доработка `users/me` + user-management. - [x] **Pass 2 — Пользователи и аутентификация:** доработка `users/me` + user-management.
- [x] **Pass 3 — Формы:** выравнивание upload F-2…F-6. - [x] **Pass 3 — Формы:** выравнивание upload F-2…F-6.
- [x] **Pass 4 — Аналитика:** financial-summary / economics / personnel / equipment / products / risk / forecast. - [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. - [ ] **Pass 6 — Финализация:** OpenAPI + массовое тестирование + smoke.
### Журнал выполненных шагов ### Журнал выполненных шагов
@@ -49,12 +49,14 @@
- добавлены contract checks для всех analytics endpointов: - добавлены contract checks для всех analytics endpointов:
- `financial-summary`, `economics`, `personnel`, `equipment`, `products`, `forecast`, `risk-profile`, `dashboard`. - `financial-summary`, `economics`, `personnel`, `equipment`, `products`, `forecast`, `risk-profile`, `dashboard`.
- дополнена проверка query-валидации для invalid `economics`-запроса. - дополнена проверка 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`). - Некоторые поля периодов требуют согласования формата валидации (`report_period_display`/`report_half_year`).
- Для `/information-security-registry-entries/` в кодовой базе нет модели и данных — потребуется новая модель/миграция или адаптер.
- Отдельное решение по async upload: порог `1MB` определяет фоновую обработку. - Отдельное решение по async upload: порог `1MB` определяет фоновую обработку.
- Нужно подтвердить, где `organization`/`profile` поля могут быть `null`. - Нужно подтвердить, где `organization`/`profile` поля могут быть `null`.
@@ -245,8 +247,8 @@
- [x] Добавить dashboard фильтрацию и стабильные `cluster` метрики. (2026-04-14) - [x] Добавить dashboard фильтрацию и стабильные `cluster` метрики. (2026-04-14)
### Pass 5. Внешние данные ### Pass 5. Внешние данные
- [ ] Довести внешние реестры к единообразным фильтрам/ответам. - [x] Довести внешние реестры к единообразным фильтрам/ответам. (2026-04-14)
- [ ] Добавить `information-security-registry-entries`. - [x] Добавить `information-security-registry-entries`. (2026-04-14)
### Pass 6. Финализация ### Pass 6. Финализация
- [ ] Обновить OpenAPI по всем контрактам. - [ ] Обновить OpenAPI по всем контрактам.

View File

@@ -131,6 +131,7 @@ OPENAPI_TAG_BY_PATH_PREFIX = OrderedDict(
("/api/v1/prosecutor-checks/", "Внешние данные"), ("/api/v1/prosecutor-checks/", "Внешние данные"),
("/api/v1/public-procurements/", "Внешние данные"), ("/api/v1/public-procurements/", "Внешние данные"),
("/api/v1/arbitration-cases/", "Внешние данные"), ("/api/v1/arbitration-cases/", "Внешние данные"),
("/api/v1/information-security-registry-entries/", "Внешние данные"),
("/api/v1/registers/", "Реестры"), ("/api/v1/registers/", "Реестры"),
("/api/v1/forms/f1/", "Форма Ф-1"), ("/api/v1/forms/f1/", "Форма Ф-1"),
("/api/v1/forms/f2/", "Форма Ф-2"), ("/api/v1/forms/f2/", "Форма Ф-2"),

View File

@@ -6,12 +6,14 @@ from apps.external_data.models import (
IndustrialProduct, IndustrialProduct,
ProsecutorCheck, ProsecutorCheck,
PublicProcurement, PublicProcurement,
InformationSecurityRegistryEntry,
) )
from apps.external_data.serializers import ( from apps.external_data.serializers import (
ArbitrationCaseSerializer, ArbitrationCaseSerializer,
IndustrialProductSerializer, IndustrialProductSerializer,
ProsecutorCheckSerializer, ProsecutorCheckSerializer,
PublicProcurementSerializer, PublicProcurementSerializer,
InformationSecurityRegistryEntrySerializer,
) )
from django_filters import rest_framework as filters from django_filters import rest_framework as filters
from rest_framework.permissions import IsAuthenticated 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]): class IndustrialProductViewSet(ClassicReadOnlyViewSet[IndustrialProduct]):
queryset = IndustrialProduct.objects.select_related("organization").all() queryset = IndustrialProduct.objects.select_related("organization").all()
serializer_class = IndustrialProductSerializer serializer_class = IndustrialProductSerializer
@@ -106,3 +117,15 @@ class ArbitrationCaseViewSet(ClassicReadOnlyViewSet[ArbitrationCase]):
search_fields = ["case_number", "court_name"] search_fields = ["case_number", "court_name"]
ordering_fields = ["decision_date", "created_at"] ordering_fields = ["decision_date", "created_at"]
ordering = ["-decision_date"] 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"]

View File

@@ -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'],
},
),
]

View File

@@ -110,3 +110,41 @@ class ArbitrationCase(UUIDPrimaryKeyMixin, TimestampMixin, models.Model):
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.case_number} ({self.organization_id})" 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})"

View File

@@ -5,6 +5,7 @@ from apps.external_data.models import (
IndustrialProduct, IndustrialProduct,
ProsecutorCheck, ProsecutorCheck,
PublicProcurement, PublicProcurement,
InformationSecurityRegistryEntry,
) )
from rest_framework import serializers from rest_framework import serializers
@@ -75,3 +76,19 @@ class ArbitrationCaseSerializer(serializers.ModelSerializer):
"status", "status",
"decision_date", "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",
]

View File

@@ -5,6 +5,7 @@ from apps.external_data.api import (
IndustrialProductViewSet, IndustrialProductViewSet,
ProsecutorCheckViewSet, ProsecutorCheckViewSet,
PublicProcurementViewSet, PublicProcurementViewSet,
InformationSecurityRegistryEntryViewSet,
) )
from django.urls import include, path from django.urls import include, path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
@@ -24,6 +25,11 @@ router.register(
router.register( router.register(
"arbitration-cases", ArbitrationCaseViewSet, basename="arbitration-cases" "arbitration-cases", ArbitrationCaseViewSet, basename="arbitration-cases"
) )
router.register(
"information-security-registry-entries",
InformationSecurityRegistryEntryViewSet,
basename="information-security-registry-entries",
)
urlpatterns = [ urlpatterns = [
path("", include(router.urls)), path("", include(router.urls)),

View File

@@ -6,6 +6,7 @@ from apps.external_data.models import (
IndustrialProduct, IndustrialProduct,
ProsecutorCheck, ProsecutorCheck,
PublicProcurement, PublicProcurement,
InformationSecurityRegistryEntry,
) )
from faker import Faker from faker import Faker
@@ -68,3 +69,17 @@ class ArbitrationCaseFactory(factory.django.DjangoModelFactory):
party_role = "defendant" party_role = "defendant"
status = "hearing_scheduled" status = "hearing_scheduled"
decision_date = factory.LazyAttribute(lambda _: fake.date_this_year()) 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())

View File

@@ -13,6 +13,7 @@ from tests.apps.external_data.factories import (
IndustrialProductFactory, IndustrialProductFactory,
ProsecutorCheckFactory, ProsecutorCheckFactory,
PublicProcurementFactory, PublicProcurementFactory,
InformationSecurityRegistryEntryFactory,
) )
from tests.apps.organization.factories import OrganizationFactory from tests.apps.organization.factories import OrganizationFactory
from tests.apps.user.factories import UserFactory from tests.apps.user.factories import UserFactory
@@ -89,3 +90,29 @@ class ExternalDataApiTest(APITestCase):
self.assertEqual(procurement_response.data["count"], 1) self.assertEqual(procurement_response.data["count"], 1)
self.assertEqual(arbitration_response.status_code, status.HTTP_200_OK) self.assertEqual(arbitration_response.status_code, status.HTTP_200_OK)
self.assertEqual(arbitration_response.data["count"], 1) 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)