feat: export organizations to state corp v3
All checks were successful
CI/CD Pipeline / Quality Gate (push) Successful in 30s
CI/CD Pipeline / Build and Push Images (push) Successful in 41s
CI/CD Pipeline / Internal Notify (push) Successful in 0s
CI/CD Pipeline / Deploy Dev via Compose (push) Successful in 19s

This commit is contained in:
2026-06-13 14:07:40 +02:00
parent 90d6bb90bd
commit f95ba3f56a
11 changed files with 513 additions and 218 deletions

View File

@@ -148,6 +148,28 @@ class OrganizationSourceRecordExportRequestSerializer(serializers.Serializer):
return value
class OrganizationDirectoryImportUploadSerializer(serializers.Serializer):
"""Request for uploading the canonical organization directory XLSX."""
file = serializers.FileField()
def validate_file(self, value):
if not value.name.lower().endswith(".xlsx"):
raise serializers.ValidationError("Поддерживаются только файлы .xlsx")
return value
class OrganizationDirectoryImportResponseSerializer(serializers.Serializer):
"""Counters returned after canonical organization directory upload."""
success = serializers.BooleanField(read_only=True)
message = serializers.CharField(read_only=True)
scanned = serializers.IntegerField(read_only=True)
created = serializers.IntegerField(read_only=True)
updated = serializers.IntegerField(read_only=True)
skipped = serializers.IntegerField(read_only=True)
class OrganizationSourceExtensionSerializer(serializers.ModelSerializer):
"""Compact source extension representation."""

View File

@@ -4,6 +4,9 @@ from __future__ import annotations
import hashlib
import json
import os
from contextlib import suppress
from tempfile import NamedTemporaryFile
from typing import Any
from apps.core.openapi import swagger_tag
@@ -17,7 +20,9 @@ from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated
from rest_framework.response import Response
from rest_framework.viewsets import ReadOnlyModelViewSet
@@ -26,6 +31,11 @@ from organizations.cache import (
DEFAULT_ORGANIZATION_API_CACHE_TIMEOUT_SECONDS,
ORGANIZATION_API_CACHE_PREFIX,
get_organization_api_cache_version,
invalidate_organization_api_cache,
)
from organizations.directory_import import (
OrganizationDirectoryImportError,
OrganizationDirectoryImportService,
)
from organizations.filters import OrganizationFilter
from organizations.models import (
@@ -35,6 +45,8 @@ from organizations.models import (
SourceGroup,
)
from organizations.serializers import (
OrganizationDirectoryImportResponseSerializer,
OrganizationDirectoryImportUploadSerializer,
OrganizationSerializer,
OrganizationSourceExtensionSerializer,
OrganizationSourceRecordExportRequestSerializer,
@@ -355,6 +367,70 @@ class OrganizationViewSet(CachedReadOnlyMixin, ReadOnlyModelViewSet):
)
return Response(serializer.data)
@swagger_auto_schema(
tags=[ORGANIZATIONS_TAG],
operation_id="v2_organizations_import_directory",
operation_summary="Загрузка справочника организаций",
operation_description=(
"Загружает XLSX сводного реестра организаций ОПК в каноническую "
"таблицу organizations.Organization."
),
manual_parameters=[
openapi.Parameter(
name="file",
in_=openapi.IN_FORM,
type=openapi.TYPE_FILE,
required=True,
description="XLSX файл сводного реестра организаций",
),
],
consumes=["multipart/form-data"],
responses={
201: OrganizationDirectoryImportResponseSerializer,
400: "Некорректный XLSX файл",
403: "Доступ только для администратора",
},
)
@action(
detail=False,
methods=["post"],
url_path="import-directory",
parser_classes=[MultiPartParser],
permission_classes=[IsAdminUser],
)
def import_directory(self, request, *args: Any, **kwargs: Any) -> Response:
serializer = OrganizationDirectoryImportUploadSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
uploaded_file = serializer.validated_data["file"]
temp_path = ""
try:
with NamedTemporaryFile(suffix=".xlsx", delete=False) as temp_file:
temp_path = temp_file.name
for chunk in uploaded_file.chunks():
temp_file.write(chunk)
result = OrganizationDirectoryImportService.import_xlsx(temp_path)
except OrganizationDirectoryImportError as exc:
raise ValidationError({"file": str(exc)}) from exc
finally:
if temp_path:
with suppress(FileNotFoundError):
os.unlink(temp_path)
invalidate_organization_api_cache()
return Response(
{
"success": True,
"message": "Справочник организаций успешно загружен",
"scanned": result.scanned,
"created": result.created,
"updated": result.updated,
"skipped": result.skipped,
},
status=status.HTTP_201_CREATED,
)
class OrganizationSourceExtensionViewSet(ReadOnlyModelViewSet):
"""Read-only API for source extensions and their records."""