"""API views для обмена данными с внешней БД.""" from contextlib import suppress from apps.core.openapi import CommonResponses, ErrorResponses, swagger_tag from apps.core.response import api_created_response, api_response from apps.core.services import BackgroundJobService from apps.exchange.models import ExchangeConnection from apps.exchange.serializers import ( ExchangeConnectionCreateSerializer, ExchangeConnectionSerializer, ExchangeCopyRequestSerializer, ExchangePeriodicTaskSerializer, ExchangePeriodicTaskUpsertSerializer, ) from apps.exchange.services import ( ExchangeConnectionService, ExchangePeriodicTaskService, ExchangeServiceError, ) from apps.exchange.tasks import copy_parsers_data_async from django.db import IntegrityError from django.shortcuts import get_object_or_404 from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema from rest_framework import status from rest_framework.exceptions import ValidationError from rest_framework.permissions import IsAdminUser from rest_framework.views import APIView EXCHANGE_TAG = swagger_tag("Обмен данными", "exchange") class ExchangeConnectionListCreateView(APIView): """API списка и создания подключений обмена.""" permission_classes = [IsAdminUser] @swagger_auto_schema( tags=[EXCHANGE_TAG], operation_summary="Список подключений", operation_description=( "Возвращает список всех сохранённых подключений для обмена.\n" "Пароль в ответ не возвращается." ), responses={ 200: ExchangeConnectionSerializer(many=True), **ErrorResponses.ADMIN, }, ) def get(self, request): queryset = ExchangeConnection.objects.all().order_by( "-is_active", "-created_at" ) serializer = ExchangeConnectionSerializer(queryset, many=True) return api_response(serializer.data, status_code=status.HTTP_200_OK) @swagger_auto_schema( tags=[EXCHANGE_TAG], operation_summary="Создать активное подключение", operation_description=( "Создаёт новое подключение к целевой БД как активное.\n" "Перед созданием деактивирует текущее активное подключение.\n" "После сохранения проверяет соединение и валидирует структуру целевой БД." ), request_body=ExchangeConnectionCreateSerializer, responses={ 201: ExchangeConnectionSerializer, 400: CommonResponses.BAD_REQUEST, **ErrorResponses.ADMIN, }, ) def post(self, request): serializer = ExchangeConnectionCreateSerializer(data=request.data) serializer.is_valid(raise_exception=True) try: connection = ExchangeConnectionService.create_active_connection_and_prepare( **serializer.validated_data ) except ExchangeServiceError as exc: raise ValidationError({"connection": str(exc)}) from exc output = ExchangeConnectionSerializer(connection) return api_created_response(output.data) class ExchangeConnectionTestView(APIView): """API проверки подключения к внешней БД без сохранения.""" permission_classes = [IsAdminUser] @swagger_auto_schema( tags=[EXCHANGE_TAG], operation_summary="Проверить подключение", operation_description=( "Проверяет подключение и структуру целевой БД без сохранения " "настроек подключения." ), request_body=ExchangeConnectionCreateSerializer, responses={ 200: openapi.Response( description="Проверка успешна", schema=openapi.Schema( type=openapi.TYPE_OBJECT, properties={ "status": openapi.Schema(type=openapi.TYPE_STRING), "message": openapi.Schema(type=openapi.TYPE_STRING), }, ), ), 400: CommonResponses.BAD_REQUEST, **ErrorResponses.ADMIN, }, ) def post(self, request): serializer = ExchangeConnectionCreateSerializer(data=request.data) serializer.is_valid(raise_exception=True) try: result = ExchangeConnectionService.test_connection_payload( **serializer.validated_data ) except ExchangeServiceError as exc: raise ValidationError({"connection": str(exc)}) from exc return api_response(result, status_code=status.HTTP_200_OK) class ExchangeCopyDataView(APIView): """API запуска копирования данных в целевую БД.""" permission_classes = [IsAdminUser] @swagger_auto_schema( tags=[EXCHANGE_TAG], operation_summary="Копировать данные parsers в target DB", operation_description=( "Асинхронно запускает копирование данных из локальной БД " "в активную целевую БД.\n" "Перед копированием выполняется только проверка структуры " "(без изменения схемы/миграций).\n" "Поддерживает режимы: all / single / selected." ), request_body=ExchangeCopyRequestSerializer, responses={ 202: openapi.Response( description="Копирование поставлено в очередь", schema=openapi.Schema( type=openapi.TYPE_OBJECT, properties={ "status": openapi.Schema(type=openapi.TYPE_STRING), "message": openapi.Schema(type=openapi.TYPE_STRING), "task_id": openapi.Schema(type=openapi.TYPE_STRING), "connection_id": openapi.Schema(type=openapi.TYPE_INTEGER), "mode": openapi.Schema(type=openapi.TYPE_STRING), "truncate_before_copy": openapi.Schema( type=openapi.TYPE_BOOLEAN ), }, ), ), 400: CommonResponses.BAD_REQUEST, **ErrorResponses.ADMIN, }, ) def post(self, request): serializer = ExchangeCopyRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) try: active_connection = ExchangeConnectionService.get_active_connection() task = copy_parsers_data_async.delay( connection_id=active_connection.id, payload=serializer.validated_data, requested_by_id=request.user.id if request.user.is_authenticated else None, ) # Предсоздаём запись для мгновенного отслеживания в /api/v1/jobs/{task_id}/ with suppress(IntegrityError): BackgroundJobService.create_job( task_id=task.id, task_name="apps.exchange.tasks.copy_parsers_data_async", user_id=request.user.id if request.user.is_authenticated else None, meta={ "connection_id": active_connection.id, "mode": serializer.validated_data["mode"], "table": serializer.validated_data.get("table"), "tables": serializer.validated_data.get("tables"), "truncate_before_copy": serializer.validated_data.get( "truncate_before_copy" ), }, ) except ExchangeServiceError as exc: raise ValidationError({"copy": str(exc)}) from exc return api_response( { "status": "started", "message": "Копирование запущено в фоне.", "task_id": task.id, "connection_id": active_connection.id, "mode": serializer.validated_data["mode"], "truncate_before_copy": serializer.validated_data[ "truncate_before_copy" ], }, status_code=status.HTTP_202_ACCEPTED, ) class ExchangePeriodicTaskListCreateView(APIView): """API списка и создания периодических задач обмена.""" permission_classes = [IsAdminUser] @swagger_auto_schema( tags=[EXCHANGE_TAG], operation_summary="Список периодических задач обмена", operation_description=( "Возвращает периодические задачи exchange, созданные через " "django_celery_beat." ), responses={ 200: ExchangePeriodicTaskSerializer(many=True), **ErrorResponses.ADMIN, }, ) def get(self, request): queryset = ExchangePeriodicTaskService.get_queryset() serializer = ExchangePeriodicTaskSerializer(queryset, many=True) return api_response(serializer.data, status_code=status.HTTP_200_OK) @swagger_auto_schema( tags=[EXCHANGE_TAG], operation_summary="Создать периодическую задачу обмена", operation_description=( "Создаёт периодическую задачу exchange с interval или crontab " "расписанием. При выполнении задача использует текущее активное " "подключение exchange." ), request_body=ExchangePeriodicTaskUpsertSerializer, responses={ 201: ExchangePeriodicTaskSerializer, 400: CommonResponses.BAD_REQUEST, **ErrorResponses.ADMIN, }, ) def post(self, request): serializer = ExchangePeriodicTaskUpsertSerializer(data=request.data) serializer.is_valid(raise_exception=True) try: task = ExchangePeriodicTaskService.create_periodic_task( name=serializer.validated_data["name"], description=serializer.validated_data.get("description", ""), enabled=serializer.validated_data.get("enabled", True), payload=serializer.validated_data["payload"], schedule=serializer.validated_data["schedule"], ) except ExchangeServiceError as exc: raise ValidationError({"periodic_task": str(exc)}) from exc output = ExchangePeriodicTaskSerializer(task) return api_created_response(output.data) class ExchangePeriodicTaskDetailView(APIView): """API чтения и изменения периодической задачи обмена.""" permission_classes = [IsAdminUser] @swagger_auto_schema( tags=[EXCHANGE_TAG], operation_summary="Детали периодической задачи обмена", responses={ 200: ExchangePeriodicTaskSerializer, 404: CommonResponses.NOT_FOUND, **ErrorResponses.ADMIN, }, ) def get(self, request, task_id: int): task = get_object_or_404( ExchangePeriodicTaskService.get_queryset(), id=task_id, ) output = ExchangePeriodicTaskSerializer(task) return api_response(output.data, status_code=status.HTTP_200_OK) @swagger_auto_schema( tags=[EXCHANGE_TAG], operation_summary="Изменить периодическую задачу обмена", operation_description=( "Обновляет расписание, payload и состояние exchange-задачи. " "PATCH допускает частичное обновление." ), request_body=ExchangePeriodicTaskUpsertSerializer, responses={ 200: ExchangePeriodicTaskSerializer, 400: CommonResponses.BAD_REQUEST, 404: CommonResponses.NOT_FOUND, **ErrorResponses.ADMIN, }, ) def patch(self, request, task_id: int): task = get_object_or_404( ExchangePeriodicTaskService.get_queryset(), id=task_id, ) serializer = ExchangePeriodicTaskUpsertSerializer( task, data=request.data, partial=True, ) serializer.is_valid(raise_exception=True) try: task = ExchangePeriodicTaskService.update_periodic_task( task=task, name=serializer.validated_data.get("name"), description=serializer.validated_data.get("description"), enabled=serializer.validated_data.get("enabled"), payload=serializer.validated_data["payload"], schedule=serializer.validated_data["schedule"], ) except ExchangeServiceError as exc: raise ValidationError({"periodic_task": str(exc)}) from exc output = ExchangePeriodicTaskSerializer(task) return api_response(output.data, status_code=status.HTTP_200_OK)