feat(external-data): add information security registry entries endpoint
This commit is contained in:
@@ -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 по всем контрактам.
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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})"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user