fix(api): align contracts with frontend md
Some checks failed
CI/CD Pipeline / Code Quality Checks (pull_request) Failing after 3m20s
CI/CD Pipeline / Run Tests (pull_request) Successful in 13m45s
CI/CD Pipeline / Run API Inventory E2E Tests (pull_request) Successful in 22s
CI/CD Pipeline / Telegram Notify Success (pull_request) Has been skipped

This commit is contained in:
2026-03-23 13:12:10 +01:00
parent d030c705ac
commit 4ca6b75393
12 changed files with 215 additions and 215 deletions

View File

@@ -63,3 +63,9 @@ class BackgroundJobListSerializer(serializers.Serializer):
def get_status(self, obj):
return _get_frontend_job_status(obj.status)
class BackgroundJobListResponseSerializer(serializers.Serializer):
"""Frontend-friendly wrapper for jobs list."""
results = BackgroundJobListSerializer(many=True, read_only=True)

View File

@@ -13,7 +13,11 @@ import time
from typing import Any
from apps.core.openapi import CommonResponses, ErrorResponses, swagger_tag
from apps.core.serializers import BackgroundJobListSerializer, BackgroundJobSerializer
from apps.core.serializers import (
BackgroundJobListResponseSerializer,
BackgroundJobListSerializer,
BackgroundJobSerializer,
)
from django.conf import settings
from django.db import connection
from django.http import StreamingHttpResponse
@@ -369,7 +373,7 @@ class BackgroundJobListView(APIView):
"Поддерживает фильтрацию по статусу (status) и лимит (limit)."
),
responses={
200: BackgroundJobListSerializer(many=True),
200: BackgroundJobListResponseSerializer,
**ErrorResponses.AUTHENTICATED,
},
)

View File

@@ -1,6 +1,7 @@
"""Сериализаторы приложения обмена данными."""
import json
import uuid
from typing import Any
from apps.exchange.models import ExchangeConnection
@@ -51,6 +52,8 @@ def get_periodic_task_payload(task: PeriodicTask) -> dict[str, Any]:
class ExchangeConnectionSerializer(serializers.ModelSerializer):
"""Сериализатор подключения без выдачи пароля в ответах."""
id = serializers.CharField(source="pk", read_only=True)
class Meta:
model = ExchangeConnection
fields = [
@@ -65,6 +68,19 @@ class ExchangeConnectionSerializer(serializers.ModelSerializer):
read_only_fields = fields
class ExchangeConnectionListResponseSerializer(serializers.Serializer):
"""Frontend-friendly wrapper for exchange connections list."""
results = ExchangeConnectionSerializer(many=True, read_only=True)
class ExchangeConnectionTestResponseSerializer(serializers.Serializer):
"""Ответ проверки подключения в формате frontend."""
success = serializers.BooleanField(read_only=True)
message = serializers.CharField(read_only=True)
class ExchangeConnectionCreateSerializer(serializers.Serializer):
"""Входные данные для создания активного подключения."""
@@ -107,14 +123,12 @@ class ExchangeCopyRequestSerializer(serializers.Serializer):
class ExchangePeriodicTaskSerializer(serializers.ModelSerializer):
"""Сериализатор периодической задачи обмена."""
id = serializers.CharField(source="pk", read_only=True)
schedule_type = serializers.SerializerMethodField()
interval_every = serializers.SerializerMethodField()
interval_period = serializers.SerializerMethodField()
crontab_minute = serializers.SerializerMethodField()
crontab_hour = serializers.SerializerMethodField()
crontab_day_of_week = serializers.SerializerMethodField()
crontab_day_of_month = serializers.SerializerMethodField()
crontab_month_of_year = serializers.SerializerMethodField()
notify_on_error = serializers.SerializerMethodField()
class Meta:
@@ -126,9 +140,6 @@ class ExchangePeriodicTaskSerializer(serializers.ModelSerializer):
"interval_period",
"crontab_minute",
"crontab_hour",
"crontab_day_of_week",
"crontab_day_of_month",
"crontab_month_of_year",
"notify_on_error",
]
read_only_fields = fields
@@ -137,7 +148,7 @@ class ExchangePeriodicTaskSerializer(serializers.ModelSerializer):
if obj.interval_id:
return "interval"
if obj.crontab_id:
return "crontab"
return "daily"
return None
def get_interval_every(self, obj: PeriodicTask) -> int | None:
@@ -146,64 +157,46 @@ class ExchangePeriodicTaskSerializer(serializers.ModelSerializer):
def get_interval_period(self, obj: PeriodicTask) -> str | None:
return obj.interval.period if obj.interval_id else None
def get_crontab_minute(self, obj: PeriodicTask) -> str | None:
return obj.crontab.minute if obj.crontab_id else None
@staticmethod
def _coerce_crontab_number(value: str | None) -> int | None:
if value is None:
return None
return int(value) if str(value).isdigit() else None
def get_crontab_hour(self, obj: PeriodicTask) -> str | None:
return obj.crontab.hour if obj.crontab_id else None
def get_crontab_minute(self, obj: PeriodicTask) -> int | None:
return self._coerce_crontab_number(obj.crontab.minute) if obj.crontab_id else None
def get_crontab_day_of_week(self, obj: PeriodicTask) -> str | None:
return obj.crontab.day_of_week if obj.crontab_id else None
def get_crontab_day_of_month(self, obj: PeriodicTask) -> str | None:
return obj.crontab.day_of_month if obj.crontab_id else None
def get_crontab_month_of_year(self, obj: PeriodicTask) -> str | None:
return obj.crontab.month_of_year if obj.crontab_id else None
def get_crontab_hour(self, obj: PeriodicTask) -> int | None:
return self._coerce_crontab_number(obj.crontab.hour) if obj.crontab_id else None
def get_notify_on_error(self, obj: PeriodicTask) -> bool:
return bool(get_periodic_task_payload(obj).get("notify_on_error", False))
def to_representation(self, instance):
data = super().to_representation(instance)
return {key: value for key, value in data.items() if value is not None}
class ExchangePeriodicTaskListResponseSerializer(serializers.Serializer):
"""Frontend-friendly wrapper for periodic tasks list."""
results = ExchangePeriodicTaskSerializer(many=True, read_only=True)
class ExchangePeriodicTaskUpsertSerializer(serializers.Serializer):
"""Входные данные для создания и изменения периодической задачи обмена."""
name = serializers.CharField(max_length=200, required=False)
description = serializers.CharField(required=False, allow_blank=True)
enabled = serializers.BooleanField(required=False)
schedule_type = serializers.ChoiceField(
choices=["interval", "crontab"],
required=False,
)
schedule_type = serializers.CharField(required=False)
interval_every = serializers.IntegerField(min_value=1, required=False)
interval_period = serializers.ChoiceField(
choices=[choice[0] for choice in IntervalSchedule.PERIOD_CHOICES],
required=False,
)
crontab_minute = serializers.CharField(max_length=64, required=False)
crontab_hour = serializers.CharField(max_length=64, required=False)
crontab_day_of_week = serializers.CharField(max_length=64, required=False)
crontab_day_of_month = serializers.CharField(max_length=64, required=False)
crontab_month_of_year = serializers.CharField(max_length=64, required=False)
mode = serializers.ChoiceField(
choices=["all", "single", "selected"],
required=False,
)
table = serializers.CharField(required=False)
tables = serializers.ListField(
child=serializers.CharField(),
required=False,
allow_empty=False,
)
truncate_before_copy = serializers.BooleanField(required=False)
crontab_minute = serializers.IntegerField(min_value=0, max_value=59, required=False)
crontab_hour = serializers.IntegerField(min_value=0, max_value=23, required=False)
notify_on_error = serializers.BooleanField(required=False)
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
if not self.instance and "name" not in attrs:
raise serializers.ValidationError({"name": "Обязательное поле."})
schedule_type = self._resolve_schedule_type(attrs)
if not schedule_type:
raise serializers.ValidationError(
@@ -213,27 +206,41 @@ class ExchangePeriodicTaskUpsertSerializer(serializers.Serializer):
payload = self._build_payload(attrs)
schedule = self._build_schedule(attrs, schedule_type)
attrs["name"] = self._build_name()
attrs["payload"] = validate_exchange_copy_payload(payload)
attrs["schedule"] = schedule
return attrs
def _resolve_schedule_type(self, attrs: dict[str, Any]) -> str | None:
if "schedule_type" in attrs:
return attrs["schedule_type"]
schedule_type = str(attrs["schedule_type"]).strip().lower()
if schedule_type not in {"interval", "daily"}:
raise serializers.ValidationError(
{
"schedule_type": (
"Допустимые значения: interval или daily."
)
}
)
return schedule_type
if self.instance and self.instance.interval_id:
return "interval"
if self.instance and self.instance.crontab_id:
return "crontab"
return "daily"
return None
def _build_name(self) -> str:
if self.instance:
return self.instance.name
return f"exchange-periodic-{uuid.uuid4()}"
def _build_payload(self, attrs: dict[str, Any]) -> dict[str, Any]:
current_payload = (
get_periodic_task_payload(self.instance) if self.instance else {}
)
mode = attrs.get("mode", current_payload.get("mode", "all"))
truncate_before_copy = attrs.get(
"truncate_before_copy",
current_payload.get("truncate_before_copy", True),
@@ -243,20 +250,10 @@ class ExchangePeriodicTaskUpsertSerializer(serializers.Serializer):
current_payload.get("notify_on_error", False),
)
if "mode" in attrs and attrs["mode"] != "single" and "table" not in attrs:
table = None
else:
table = attrs.get("table", current_payload.get("table"))
if "mode" in attrs and attrs["mode"] != "selected" and "tables" not in attrs:
tables = None
else:
tables = attrs.get("tables", current_payload.get("tables"))
return {
"mode": mode,
"table": table,
"tables": tables,
"mode": current_payload.get("mode", "all"),
"table": current_payload.get("table"),
"tables": current_payload.get("tables"),
"truncate_before_copy": truncate_before_copy,
"notify_on_error": notify_on_error,
}
@@ -268,7 +265,7 @@ class ExchangePeriodicTaskUpsertSerializer(serializers.Serializer):
) -> dict[str, Any]:
if schedule_type == "interval":
return self._build_interval_schedule(attrs)
return self._build_crontab_schedule(attrs)
return self._build_daily_schedule(attrs)
def _build_interval_schedule(self, attrs: dict[str, Any]) -> dict[str, Any]:
current_schedule = self.instance.interval if self.instance else None
@@ -295,40 +292,30 @@ class ExchangePeriodicTaskUpsertSerializer(serializers.Serializer):
"period": interval_period,
}
def _build_crontab_schedule(self, attrs: dict[str, Any]) -> dict[str, Any]:
def _build_daily_schedule(self, attrs: dict[str, Any]) -> dict[str, Any]:
current_schedule = self.instance.crontab if self.instance else None
fields = {
"minute": attrs.get(
"crontab_minute",
current_schedule.minute if current_schedule else None,
),
"hour": attrs.get(
"crontab_hour",
current_schedule.hour if current_schedule else None,
),
"day_of_week": attrs.get(
"crontab_day_of_week",
current_schedule.day_of_week if current_schedule else None,
),
"day_of_month": attrs.get(
"crontab_day_of_month",
current_schedule.day_of_month if current_schedule else None,
),
"month_of_year": attrs.get(
"crontab_month_of_year",
current_schedule.month_of_year if current_schedule else None,
),
}
minute = attrs.get(
"crontab_minute",
int(current_schedule.minute) if current_schedule else None,
)
hour = attrs.get(
"crontab_hour",
int(current_schedule.hour) if current_schedule else None,
)
errors = {
f"crontab_{field_name}": "Обязательное поле для crontab."
for field_name, value in fields.items()
if value is None
}
errors = {}
if minute is None:
errors["crontab_minute"] = "Обязательное поле для daily."
if hour is None:
errors["crontab_hour"] = "Обязательное поле для daily."
if errors:
raise serializers.ValidationError(errors)
return {
"type": "crontab",
**fields,
"minute": str(minute),
"hour": str(hour),
"day_of_week": "*",
"day_of_month": "*",
"month_of_year": "*",
}

View File

@@ -8,8 +8,11 @@ from apps.core.services import BackgroundJobService
from apps.exchange.models import ExchangeConnection
from apps.exchange.serializers import (
ExchangeConnectionCreateSerializer,
ExchangeConnectionListResponseSerializer,
ExchangeConnectionSerializer,
ExchangeConnectionTestResponseSerializer,
ExchangeCopyRequestSerializer,
ExchangePeriodicTaskListResponseSerializer,
ExchangePeriodicTaskSerializer,
ExchangePeriodicTaskUpsertSerializer,
)
@@ -45,7 +48,7 @@ class ExchangeConnectionListCreateView(APIView):
"Пароль в ответ не возвращается."
),
responses={
200: ExchangeConnectionSerializer(many=True),
200: ExchangeConnectionListResponseSerializer,
**ErrorResponses.ADMIN,
},
)
@@ -100,16 +103,7 @@ class ExchangeConnectionTestView(APIView):
),
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),
},
),
),
200: ExchangeConnectionTestResponseSerializer,
400: CommonResponses.BAD_REQUEST,
**ErrorResponses.ADMIN,
},
@@ -232,7 +226,7 @@ class ExchangePeriodicTaskListCreateView(APIView):
"django_celery_beat."
),
responses={
200: ExchangePeriodicTaskSerializer(many=True),
200: ExchangePeriodicTaskListResponseSerializer,
**ErrorResponses.ADMIN,
},
)
@@ -263,8 +257,8 @@ class ExchangePeriodicTaskListCreateView(APIView):
try:
task = ExchangePeriodicTaskService.create_periodic_task(
name=serializer.validated_data["name"],
description=serializer.validated_data.get("description", ""),
enabled=serializer.validated_data.get("enabled", True),
description="",
enabled=True,
payload=serializer.validated_data["payload"],
schedule=serializer.validated_data["schedule"],
)
@@ -327,9 +321,6 @@ class ExchangePeriodicTaskDetailView(APIView):
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"],
)

View File

@@ -314,6 +314,13 @@ class FNSZipUploadSerializer(serializers.Serializer):
return value
class FNSFileUploadSuccessSerializer(serializers.Serializer):
"""Ответ одиночной загрузки FNS в формате frontend."""
success = serializers.BooleanField(read_only=True)
message = serializers.CharField(read_only=True)
class ParsingSettingsSerializer(serializers.ModelSerializer):
"""Настройки периодичности обновления источников парсинга."""
@@ -429,6 +436,15 @@ class ParserLoadLogListSerializer(serializers.Serializer):
updated_at = serializers.DateTimeField(read_only=True)
class ParserLoadLogPageSerializer(serializers.Serializer):
"""Пагинированный ответ списка логов для frontend."""
count = serializers.IntegerField(read_only=True)
next = serializers.CharField(read_only=True, allow_null=True)
previous = serializers.CharField(read_only=True, allow_null=True)
results = ParserLoadLogListSerializer(many=True, read_only=True)
class SourceCardRefreshParamSerializer(serializers.Serializer):
"""Описание параметра ручного обновления карточки источника."""
@@ -548,3 +564,36 @@ class SourceCardRefreshResponseSerializer(serializers.Serializer):
status = serializers.CharField(read_only=True)
requested_at = serializers.DateTimeField(read_only=True)
tasks = SourceCardRefreshTaskSerializer(many=True, read_only=True)
class FrontendApiResponseSerializer(serializers.Serializer):
"""Общая форма success/data/errors/meta для frontend sources API."""
success = serializers.BooleanField(read_only=True)
errors = serializers.JSONField(read_only=True, allow_null=True)
meta = serializers.JSONField(read_only=True, allow_null=True)
class SourceCardListResponseSerializer(FrontendApiResponseSerializer):
"""Envelope для списка карточек источников."""
data = SourceCardSerializer(many=True, read_only=True)
class SourceTaskStatusListResponseSerializer(FrontendApiResponseSerializer):
"""Envelope для списка статусов источников."""
data = SourceTaskStatusSerializer(many=True, read_only=True)
class SourceCardDetailResponseSerializer(FrontendApiResponseSerializer):
"""Envelope для детальной карточки источника."""
data = SourceCardDetailSerializer(read_only=True)
class SourceCardRefreshFrontendResponseSerializer(serializers.Serializer):
"""Минимальный ответ запуска обновления карточки по md."""
task_id = serializers.CharField(read_only=True, allow_null=True)
status = serializers.CharField(read_only=True)

View File

@@ -24,18 +24,24 @@ from apps.parsers.serializers import (
FinancialReportDetailSerializer,
FinancialReportSerializer,
FNSFileUploadSerializer,
FNSFileUploadSuccessSerializer,
IndustrialCertificateSerializer,
IndustrialProductSerializer,
InspectionSerializer,
ManufacturerSerializer,
ParserLoadLogListSerializer,
ParserLoadLogPageSerializer,
ParserLoadLogSerializer,
ParsingSettingsSerializer,
ProcurementSerializer,
SourceCardDetailResponseSerializer,
SourceCardDetailSerializer,
SourceCardListResponseSerializer,
SourceCardRefreshFrontendResponseSerializer,
SourceCardRefreshRequestSerializer,
SourceCardRefreshResponseSerializer,
SourceCardSerializer,
SourceTaskStatusListResponseSerializer,
SourceTaskStatusSerializer,
)
from apps.parsers.source_cards import SourceCardService
@@ -683,15 +689,16 @@ class FNSReportUploadView(APIView):
),
manual_parameters=[
openapi.Parameter(
name="files",
name="file",
in_=openapi.IN_FORM,
type=openapi.TYPE_FILE,
required=True,
description="Файл(ы) для загрузки (fin_*.xlsx)",
description="Файл для загрузки (fin_*.xlsx)",
),
],
consumes=["multipart/form-data"],
responses={
200: FNSFileUploadSuccessSerializer,
202: openapi.Response(
description="Файлы приняты в обработку",
schema=openapi.Schema(
@@ -809,7 +816,7 @@ class SourceCardListView(APIView):
"для фронтенда."
),
responses={
200: SourceCardSerializer(many=True),
200: SourceCardListResponseSerializer,
**ErrorResponses.AUTHENTICATED,
},
)
@@ -832,7 +839,7 @@ class SourceTaskStatusListView(APIView):
"мониторинга парсинга."
),
responses={
200: SourceTaskStatusSerializer(many=True),
200: SourceTaskStatusListResponseSerializer,
**ErrorResponses.AUTHENTICATED,
},
)
@@ -855,7 +862,7 @@ class SourceCardDetailView(APIView):
"включая подисточники, последние загрузки и активные задачи."
),
responses={
200: SourceCardDetailSerializer,
200: SourceCardDetailResponseSerializer,
**ErrorResponses.AUTHENTICATED_NOT_FOUND,
},
)
@@ -878,7 +885,7 @@ class SourceCardRefreshView(APIView):
),
request_body=SourceCardRefreshRequestSerializer,
responses={
202: SourceCardRefreshResponseSerializer,
202: SourceCardRefreshFrontendResponseSerializer,
400: CommonResponses.BAD_REQUEST,
**ErrorResponses.ADMIN_NOT_FOUND,
},
@@ -892,17 +899,9 @@ class SourceCardRefreshView(APIView):
requested_by_id=request.user.id if request.user.is_authenticated else None,
params=serializer.validated_data.get("params", {}),
)
output = SourceCardRefreshResponseSerializer(payload)
serialized_payload = output.data
tasks = serialized_payload.get("tasks", [])
task_id = tasks[0]["task_id"] if tasks else None
response_payload = {
"task_id": task_id,
"status": "accepted",
}
if len(tasks) > 1:
response_payload["tasks"] = tasks
response_payload["source_card"] = serialized_payload.get("source_card")
output = SourceCardRefreshResponseSerializer(payload).data
tasks = output.get("tasks", [])
response_payload = {"task_id": tasks[0]["task_id"] if tasks else None, "status": "accepted"}
return Response(
response_payload,
status=status.HTTP_202_ACCEPTED,
@@ -937,7 +936,7 @@ class ParserLoadLogViewSet(ReadOnlyModelViewSet):
"Возвращает историю загрузок данных парсерами.\n"
"Доступно только администраторам.\n"
"Поддерживает фильтрацию по: source, status, batch_id.\n"
"Поддерживает search по source, status, batch_id и error_message.\n"
"Поддерживает search по всем полям строки frontend-лога.\n"
"Поддерживает ordering по: id, batch_id, source, status, records_count, "
"created_at, updated_at."
),
@@ -947,7 +946,7 @@ class ParserLoadLogViewSet(ReadOnlyModelViewSet):
in_=openapi.IN_QUERY,
type=openapi.TYPE_STRING,
required=False,
description="Поиск по source, status, batch_id и error_message",
description="Поиск по всем полям строки frontend-лога",
),
openapi.Parameter(
name="ordering",
@@ -961,7 +960,7 @@ class ParserLoadLogViewSet(ReadOnlyModelViewSet):
),
],
responses={
200: ParserLoadLogSerializer(many=True),
200: ParserLoadLogPageSerializer,
**ErrorResponses.ADMIN,
},
)

View File

@@ -13,10 +13,23 @@ class RegisterSerializer(serializers.ModelSerializer):
class Meta:
model = Register
fields = ["id", "name", "created_at", "updated_at"]
fields = ["id", "name"]
read_only_fields = fields
class RegisterListResponseSerializer(serializers.Serializer):
"""Frontend-friendly wrapper for registries list."""
results = RegisterSerializer(many=True, read_only=True)
class RegisterUploadSuccessSerializer(serializers.Serializer):
"""Минимальный ответ успешной загрузки реестра."""
success = serializers.BooleanField(read_only=True)
message = serializers.CharField(read_only=True)
class RegistryMembershipPeriodSerializer(serializers.ModelSerializer):
"""Сериализатор периода участия организации в реестре."""

View File

@@ -12,7 +12,9 @@ from apps.registers.serializers import (
OrganizationListQuerySerializer,
OrganizationSerializer,
RegisterFileUploadSerializer,
RegisterListResponseSerializer,
RegisterSerializer,
RegisterUploadSuccessSerializer,
RegistryOrganizationListQuerySerializer,
)
from apps.registers.services import RegisterImportError, RegisterImportService
@@ -44,7 +46,7 @@ class RegisterViewSet(ReadOnlyModelViewSet):
operation_summary="Список реестров",
operation_description="Возвращает список доступных реестров.",
responses={
200: RegisterSerializer(many=True),
200: RegisterListResponseSerializer,
**ErrorResponses.AUTHENTICATED,
},
)
@@ -327,31 +329,7 @@ class RegisterUploadView(APIView):
],
consumes=["multipart/form-data"],
responses={
201: openapi.Response(
description="Список организаций успешно загружен",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"upload_id": openapi.Schema(type=openapi.TYPE_INTEGER),
"registry_id": openapi.Schema(type=openapi.TYPE_STRING),
"registry_name": openapi.Schema(type=openapi.TYPE_STRING),
"actual_date": openapi.Schema(
type=openapi.TYPE_STRING,
format=openapi.FORMAT_DATE,
),
"rows_in_file": openapi.Schema(type=openapi.TYPE_INTEGER),
"organizations_created": openapi.Schema(
type=openapi.TYPE_INTEGER
),
"organizations_updated": openapi.Schema(
type=openapi.TYPE_INTEGER
),
"opened_periods": openapi.Schema(type=openapi.TYPE_INTEGER),
"closed_periods": openapi.Schema(type=openapi.TYPE_INTEGER),
"active_periods": openapi.Schema(type=openapi.TYPE_INTEGER),
},
),
),
201: RegisterUploadSuccessSerializer,
400: CommonResponses.BAD_REQUEST,
**ErrorResponses.ADMIN,
},