Some checks failed
CI/CD Pipeline / Code Quality Checks (push) Successful in 3m16s
CI/CD Pipeline / Run Tests (push) Successful in 3m26s
CI/CD Pipeline / Telegram Notify Success (push) Failing after 1m29s
CI/CD Pipeline / Run Tests (pull_request) Successful in 1m44s
CI/CD Pipeline / Code Quality Checks (pull_request) Successful in 20m19s
CI/CD Pipeline / Telegram Notify Success (pull_request) Failing after 1m34s
334 lines
14 KiB
Python
334 lines
14 KiB
Python
"""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)
|