Files
mostovik-backend/src/apps/exchange/views.py
Aleksandr Meshchriakov 3de66cc25c
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
Add periodic exchange task management API
2026-03-19 17:03:47 +01:00

334 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)