""" Views для приложения парсеров. Все ViewSets только для чтения (GET, GET list). Добавление и удаление данных - через парсеры и админку. """ import hashlib import time from pathlib import Path from apps.core.openapi import CommonResponses, ErrorResponses, swagger_tag from apps.parsers.models import ( FinancialReport, IndustrialCertificateRecord, InspectionRecord, ManufacturerRecord, ParserLoadLog, ProcurementRecord, Proxy, ) from apps.parsers.serializers import ( FinancialReportDetailSerializer, FinancialReportSerializer, FNSFileUploadSerializer, IndustrialCertificateSerializer, InspectionSerializer, ManufacturerSerializer, ParserLoadLogSerializer, ProcurementSerializer, ProxySerializer, ) from apps.parsers.tasks import process_fns_file from django.conf import settings from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema from rest_framework import status from rest_framework.parsers import MultiPartParser from rest_framework.permissions import IsAdminUser, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.viewsets import ReadOnlyModelViewSet # ============================================================================= # Swagger Tags (для группировки в документации) # ============================================================================= MINPROMTORG_TAG = swagger_tag("Минпромторг", "minpromtorg") PROVERKI_TAG = swagger_tag("Единый реестр проверок", "inspections_registry") ZAKUPKI_TAG = swagger_tag("Государственные закупки", "public_procurements") FNS_TAG = swagger_tag("ФНС - Бухгалтерская отчетность", "fns_financial_reports") SYSTEM_TAG = swagger_tag("Системные", "system") # ============================================================================= # Минпромторг - Сертификаты промышленного производства # ============================================================================= class IndustrialCertificateViewSet(ReadOnlyModelViewSet): """ API для просмотра сертификатов промышленного производства. Данные загружаются из Минпромторга. Только чтение - добавление через парсер/админку. """ queryset = IndustrialCertificateRecord.objects.all().order_by("-created_at") serializer_class = IndustrialCertificateSerializer permission_classes = [IsAuthenticated] filterset_fields = ["inn", "ogrn", "certificate_number", "load_batch"] search_fields = ["organisation_name", "certificate_number", "inn", "ogrn"] @swagger_auto_schema( tags=[MINPROMTORG_TAG], operation_summary="Список сертификатов", operation_description=( "Возвращает список сертификатов промышленного производства.\n" "Поддерживает фильтрацию по: inn, ogrn, certificate_number, load_batch.\n" "Поддерживает поиск по: organisation_name, certificate_number, inn, ogrn." ), responses={ 200: IndustrialCertificateSerializer(many=True), **ErrorResponses.AUTHENTICATED, }, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) @swagger_auto_schema( tags=[MINPROMTORG_TAG], operation_summary="Детали сертификата", operation_description="Возвращает информацию о конкретном сертификате.", responses={ 200: IndustrialCertificateSerializer, **ErrorResponses.AUTHENTICATED_NOT_FOUND, }, ) def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) # ============================================================================= # Минпромторг - Реестр производителей # ============================================================================= class ManufacturerViewSet(ReadOnlyModelViewSet): """ API для просмотра реестра производителей. Данные загружаются из Минпромторга. Только чтение - добавление через парсер/админку. """ queryset = ManufacturerRecord.objects.all().order_by("-created_at") serializer_class = ManufacturerSerializer permission_classes = [IsAuthenticated] filterset_fields = ["inn", "ogrn", "load_batch"] search_fields = ["full_legal_name", "inn", "ogrn", "address"] @swagger_auto_schema( tags=[MINPROMTORG_TAG], operation_summary="Список производителей", operation_description=( "Возвращает список производителей из реестра Минпромторга.\n" "Поддерживает фильтрацию по: inn, ogrn, load_batch.\n" "Поддерживает поиск по: full_legal_name, inn, ogrn, address." ), responses={ 200: ManufacturerSerializer(many=True), **ErrorResponses.AUTHENTICATED, }, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) @swagger_auto_schema( tags=[MINPROMTORG_TAG], operation_summary="Детали производителя", operation_description="Возвращает информацию о конкретном производителе.", responses={ 200: ManufacturerSerializer, **ErrorResponses.AUTHENTICATED_NOT_FOUND, }, ) def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) # ============================================================================= # Единый реестр проверок (proverki.gov.ru) # ============================================================================= class InspectionViewSet(ReadOnlyModelViewSet): """ API для просмотра проверок из Единого реестра проверок. Данные из ФГИС "Единый реестр проверок" (Генпрокуратура РФ). Поддерживает ФЗ-294 (традиционные) и ФЗ-248 (новые с 2021). Только чтение - добавление через парсер/админку. """ queryset = InspectionRecord.objects.all().order_by("-created_at") serializer_class = InspectionSerializer permission_classes = [IsAuthenticated] filterset_fields = [ "inn", "ogrn", "registration_number", "is_federal_law_248", "data_year", "data_month", "load_batch", ] search_fields = [ "organisation_name", "registration_number", "inn", "ogrn", "control_authority", ] @swagger_auto_schema( tags=[PROVERKI_TAG], operation_summary="Список проверок", operation_description=( "Возвращает список проверок из Единого реестра.\n" "Поддерживает фильтрацию по: inn, ogrn, registration_number, " "is_federal_law_248, data_year, data_month, load_batch.\n" "Поддерживает поиск по: organisation_name, registration_number, " "inn, ogrn, control_authority." ), responses={ 200: InspectionSerializer(many=True), **ErrorResponses.AUTHENTICATED, }, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) @swagger_auto_schema( tags=[PROVERKI_TAG], operation_summary="Детали проверки", operation_description="Возвращает информацию о конкретной проверке.", responses={ 200: InspectionSerializer, **ErrorResponses.AUTHENTICATED_NOT_FOUND, }, ) def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) # ============================================================================= # Государственные закупки (zakupki.gov.ru) # ============================================================================= class ProcurementViewSet(ReadOnlyModelViewSet): """ API для просмотра государственных закупок. Данные из ЕИС zakupki.gov.ru. Поддерживает 44-ФЗ и 223-ФЗ. Только чтение - добавление через парсер/админку. """ queryset = ProcurementRecord.objects.all().order_by("-created_at") serializer_class = ProcurementSerializer permission_classes = [IsAuthenticated] filterset_fields = [ "customer_inn", "customer_ogrn", "purchase_number", "law_type", "status", "region_code", "data_year", "data_month", "load_batch", ] search_fields = [ "purchase_name", "purchase_number", "customer_name", "customer_inn", "customer_ogrn", ] @swagger_auto_schema( tags=[ZAKUPKI_TAG], operation_summary="Список закупок", operation_description=( "Возвращает список государственных закупок.\n" "Поддерживает фильтрацию по: customer_inn, customer_ogrn, " "purchase_number, law_type, status, region_code, " "data_year, data_month, load_batch.\n" "Поддерживает поиск по: purchase_name, purchase_number, " "customer_name, customer_inn, customer_ogrn." ), responses={ 200: ProcurementSerializer(many=True), **ErrorResponses.AUTHENTICATED, }, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) @swagger_auto_schema( tags=[ZAKUPKI_TAG], operation_summary="Детали закупки", operation_description="Возвращает информацию о конкретной закупке.", responses={ 200: ProcurementSerializer, **ErrorResponses.AUTHENTICATED_NOT_FOUND, }, ) def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) # ============================================================================= # ФНС - Бухгалтерская отчетность # ============================================================================= class FinancialReportViewSet(ReadOnlyModelViewSet): """ API для просмотра финансовых отчетов ФНС. Данные загружаются из Excel файлов (fin_{id}_{ogrn}.xlsx). Только чтение - добавление через загрузку файлов. """ queryset = FinancialReport.objects.all().order_by("-created_at") permission_classes = [IsAuthenticated] filterset_fields = ["ogrn", "external_id", "status", "source", "load_batch"] search_fields = ["ogrn", "external_id", "file_name"] def get_serializer_class(self): if self.action == "retrieve": return FinancialReportDetailSerializer return FinancialReportSerializer @swagger_auto_schema( tags=[FNS_TAG], operation_summary="Список отчетов", operation_description=( "Возвращает список финансовых отчетов ФНС.\n" "Поддерживает фильтрацию по: ogrn, external_id, status, " "source, load_batch.\n" "Поддерживает поиск по: ogrn, external_id, file_name." ), responses={ 200: FinancialReportSerializer(many=True), **ErrorResponses.AUTHENTICATED, }, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) @swagger_auto_schema( tags=[FNS_TAG], operation_summary="Детали отчета", operation_description=( "Возвращает детальную информацию об отчете, " "включая все строки бухгалтерской отчетности." ), responses={ 200: FinancialReportDetailSerializer, **ErrorResponses.AUTHENTICATED_NOT_FOUND, }, ) def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) class FNSReportUploadView(APIView): """ API для загрузки файлов бухгалтерской отчетности ФНС. Файлы сохраняются во временную директорию и ставятся в очередь на обработку через Celery. """ parser_classes = [MultiPartParser] permission_classes = [IsAuthenticated] @swagger_auto_schema( tags=[FNS_TAG], operation_summary="Загрузка файлов", operation_description=( "Пакетная загрузка файлов бухгалтерской отчетности.\n\n" "**Формат файла:** fin_{id}_{ogrn}.xlsx\n\n" "**Ответ:**\n" "- queued: количество файлов в очереди\n" "- skipped: количество пропущенных (дубликаты)\n" "- task_ids: ID задач для отслеживания статуса" ), manual_parameters=[ openapi.Parameter( name="files", in_=openapi.IN_FORM, type=openapi.TYPE_FILE, required=True, description="Файл(ы) для загрузки (fin_*.xlsx)", ), ], consumes=["multipart/form-data"], responses={ 202: openapi.Response( description="Файлы приняты в обработку", schema=openapi.Schema( type=openapi.TYPE_OBJECT, properties={ "queued": openapi.Schema( type=openapi.TYPE_INTEGER, description="Количество файлов в очереди", ), "skipped": openapi.Schema( type=openapi.TYPE_INTEGER, description="Количество пропущенных", ), "task_ids": openapi.Schema( type=openapi.TYPE_ARRAY, items=openapi.Schema(type=openapi.TYPE_STRING), description="ID задач Celery", ), }, ), ), 400: CommonResponses.BAD_REQUEST, **ErrorResponses.AUTHENTICATED, }, ) def post(self, request): # noqa serializer = FNSFileUploadSerializer(data=request.data) serializer.is_valid(raise_exception=True) files = serializer.validated_data["files"] task_ids = [] queued = 0 skipped = 0 # Создаём директорию для загрузки upload_dir = Path(settings.FNS_WATCH_DIRECTORY) upload_dir.mkdir(parents=True, exist_ok=True) from apps.parsers.services import FNSReportService def _try_create_fns_lock(file_path: Path) -> bool: lock_path = Path(f"{file_path}.lock") if lock_path.exists(): try: age_seconds = time.time() - lock_path.stat().st_mtime ttl_seconds = getattr(settings, "FNS_LOCK_TTL_SECONDS", 3600) if age_seconds > ttl_seconds: lock_path.unlink() else: return False except FileNotFoundError: pass try: lock_path.touch(exist_ok=False) except FileExistsError: return False return True for file in files: # Вычисляем хеш файла file_content = file.read() file_hash = hashlib.sha256(file_content).hexdigest() file.seek(0) # Проверяем дубликат if FNSReportService.exists_by_hash(file_hash): skipped += 1 continue # Сохраняем файл file_path = upload_dir / file.name if not _try_create_fns_lock(file_path): skipped += 1 continue lock_path = Path(f"{file_path}.lock") if file_path.exists(): lock_path.unlink(missing_ok=True) skipped += 1 continue try: with open(file_path, "wb") as f: for chunk in file.chunks(): f.write(chunk) except Exception: lock_path.unlink(missing_ok=True) raise # Ставим в очередь try: task = process_fns_file.delay(str(file_path)) except Exception: lock_path.unlink(missing_ok=True) raise task_ids.append(task.id) queued += 1 return Response( { "queued": queued, "skipped": skipped, "task_ids": task_ids, }, status=status.HTTP_202_ACCEPTED, ) # ============================================================================= # Системные (логи загрузок, прокси) # ============================================================================= class ParserLoadLogViewSet(ReadOnlyModelViewSet): """ API для просмотра логов загрузок парсеров. Информация о каждой загрузке данных из внешних источников. Только для администраторов. """ queryset = ParserLoadLog.objects.all().order_by("-created_at") serializer_class = ParserLoadLogSerializer permission_classes = [IsAdminUser] filterset_fields = ["source", "status", "batch_id"] @swagger_auto_schema( tags=[SYSTEM_TAG], operation_summary="Список логов загрузок", operation_description=( "Возвращает историю загрузок данных парсерами.\n" "Доступно только администраторам.\n" "Поддерживает фильтрацию по: source, status, batch_id." ), responses={ 200: ParserLoadLogSerializer(many=True), **ErrorResponses.ADMIN, }, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) @swagger_auto_schema( tags=[SYSTEM_TAG], operation_summary="Детали загрузки", operation_description="Возвращает информацию о конкретной загрузке.", responses={ 200: ParserLoadLogSerializer, **ErrorResponses.ADMIN_NOT_FOUND, }, ) def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) class ProxyViewSet(ReadOnlyModelViewSet): """ API для просмотра списка прокси-серверов. Используется для отладки и мониторинга парсеров. Только для администраторов. """ queryset = Proxy.objects.all().order_by("-last_used_at") serializer_class = ProxySerializer permission_classes = [IsAdminUser] filterset_fields = ["is_active"] @swagger_auto_schema( tags=[SYSTEM_TAG], operation_summary="Список прокси", operation_description=( "Возвращает список прокси-серверов для парсеров.\n" "Доступно только администраторам.\n" "Поддерживает фильтрацию по: is_active." ), responses={ 200: ProxySerializer(many=True), **ErrorResponses.ADMIN, }, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) @swagger_auto_schema( tags=[SYSTEM_TAG], operation_summary="Детали прокси", operation_description="Возвращает информацию о конкретном прокси.", responses={ 200: ProxySerializer, **ErrorResponses.ADMIN_NOT_FOUND, }, ) def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs)