From 4ca6b75393a6509e233a3bc5a9f21bafc878918b Mon Sep 17 00:00:00 2001 From: Aleksandr Meshchriakov Date: Mon, 23 Mar 2026 13:12:10 +0100 Subject: [PATCH] fix(api): align contracts with frontend md --- src/apps/core/serializers.py | 6 + src/apps/core/views.py | 8 +- src/apps/exchange/serializers.py | 173 ++++++++++----------- src/apps/exchange/views.py | 25 +-- src/apps/parsers/serializers.py | 49 ++++++ src/apps/parsers/views.py | 39 +++-- src/apps/registers/serializers.py | 15 +- src/apps/registers/views.py | 30 +--- tests/apps/exchange/test_serializers.py | 31 ++-- tests/apps/exchange/test_views.py | 27 +--- tests/apps/parsers/test_sources_api_e2e.py | 14 +- tests/test_api_inventory_e2e.py | 13 +- 12 files changed, 215 insertions(+), 215 deletions(-) diff --git a/src/apps/core/serializers.py b/src/apps/core/serializers.py index 9357614..5580ca3 100644 --- a/src/apps/core/serializers.py +++ b/src/apps/core/serializers.py @@ -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) diff --git a/src/apps/core/views.py b/src/apps/core/views.py index 7ae8a0f..c568ee3 100644 --- a/src/apps/core/views.py +++ b/src/apps/core/views.py @@ -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, }, ) diff --git a/src/apps/exchange/serializers.py b/src/apps/exchange/serializers.py index 4430531..c50bf09 100644 --- a/src/apps/exchange/serializers.py +++ b/src/apps/exchange/serializers.py @@ -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": "*", } diff --git a/src/apps/exchange/views.py b/src/apps/exchange/views.py index 3a91bff..f4d284b 100644 --- a/src/apps/exchange/views.py +++ b/src/apps/exchange/views.py @@ -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"], ) diff --git a/src/apps/parsers/serializers.py b/src/apps/parsers/serializers.py index a8fb6e4..a18da16 100644 --- a/src/apps/parsers/serializers.py +++ b/src/apps/parsers/serializers.py @@ -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) diff --git a/src/apps/parsers/views.py b/src/apps/parsers/views.py index 56f2183..28134b8 100644 --- a/src/apps/parsers/views.py +++ b/src/apps/parsers/views.py @@ -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, }, ) diff --git a/src/apps/registers/serializers.py b/src/apps/registers/serializers.py index 6c7c888..965218a 100644 --- a/src/apps/registers/serializers.py +++ b/src/apps/registers/serializers.py @@ -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): """Сериализатор периода участия организации в реестре.""" diff --git a/src/apps/registers/views.py b/src/apps/registers/views.py index 89d76c9..1727f9d 100644 --- a/src/apps/registers/views.py +++ b/src/apps/registers/views.py @@ -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, }, diff --git a/tests/apps/exchange/test_serializers.py b/tests/apps/exchange/test_serializers.py index 5cbdc22..5a75049 100644 --- a/tests/apps/exchange/test_serializers.py +++ b/tests/apps/exchange/test_serializers.py @@ -36,25 +36,22 @@ class ExchangeCopyRequestSerializerTest(SimpleTestCase): class ExchangePeriodicTaskUpsertSerializerTest(SimpleTestCase): def test_interval_schedule_requires_fields(self): - serializer = ExchangePeriodicTaskUpsertSerializer( - data={"name": "copy-job", "schedule_type": "interval"} - ) + serializer = ExchangePeriodicTaskUpsertSerializer(data={"schedule_type": "interval"}) self.assertFalse(serializer.is_valid()) self.assertIn("interval_every", serializer.errors) self.assertIn("interval_period", serializer.errors) - def test_crontab_schedule_requires_fields(self): - serializer = ExchangePeriodicTaskUpsertSerializer( - data={"name": "copy-job", "schedule_type": "crontab"} - ) + def test_daily_schedule_requires_fields(self): + serializer = ExchangePeriodicTaskUpsertSerializer(data={"schedule_type": "daily"}) self.assertFalse(serializer.is_valid()) self.assertIn("crontab_minute", serializer.errors) self.assertIn("crontab_hour", serializer.errors) - def test_update_mode_to_all_clears_old_single_table(self): + def test_partial_update_preserves_existing_payload(self): instance = SimpleNamespace( + name="copy-job", interval_id=1, interval=SimpleNamespace(every=5, period="minutes"), crontab_id=None, @@ -62,7 +59,7 @@ class ExchangePeriodicTaskUpsertSerializerTest(SimpleTestCase): ) serializer = ExchangePeriodicTaskUpsertSerializer( instance, - data={"mode": "all"}, + data={}, partial=True, ) @@ -70,36 +67,30 @@ class ExchangePeriodicTaskUpsertSerializerTest(SimpleTestCase): self.assertEqual( serializer.validated_data["payload"], { - "mode": "all", - "table": None, + "mode": "single", + "table": "old_table", "tables": None, "truncate_before_copy": True, "notify_on_error": False, }, ) - def test_periodic_task_uses_copy_payload_validation(self): + def test_invalid_schedule_type_is_rejected(self): serializer = ExchangePeriodicTaskUpsertSerializer( data={ - "name": "copy-job", - "schedule_type": "interval", - "interval_every": 1, - "interval_period": "hours", - "mode": "single", + "schedule_type": "crontab", } ) self.assertFalse(serializer.is_valid()) - self.assertIn("table", serializer.errors) + self.assertIn("schedule_type", serializer.errors) def test_notify_on_error_is_added_to_payload(self): serializer = ExchangePeriodicTaskUpsertSerializer( data={ - "name": "copy-job", "schedule_type": "interval", "interval_every": 1, "interval_period": "hours", - "mode": "all", "notify_on_error": True, } ) diff --git a/tests/apps/exchange/test_views.py b/tests/apps/exchange/test_views.py index 2cf2e9d..5831974 100644 --- a/tests/apps/exchange/test_views.py +++ b/tests/apps/exchange/test_views.py @@ -196,13 +196,9 @@ class ExchangeViewsTest(APITestCase): def test_create_periodic_interval_task_success(self): payload = { - "name": "exchange-copy-hourly", - "description": "Hourly sync", - "enabled": True, "schedule_type": "interval", "interval_every": 1, "interval_period": "hours", - "mode": "all", "notify_on_error": True, } @@ -219,11 +215,6 @@ class ExchangeViewsTest(APITestCase): "schedule_type", "interval_every", "interval_period", - "crontab_minute", - "crontab_hour", - "crontab_day_of_week", - "crontab_day_of_month", - "crontab_month_of_year", "notify_on_error", }, ) @@ -292,15 +283,9 @@ class ExchangeViewsTest(APITestCase): kwargs={"task_id": task.id}, ) payload = { - "schedule_type": "crontab", - "crontab_minute": "0", - "crontab_hour": "4", - "crontab_day_of_week": "*", - "crontab_day_of_month": "*", - "crontab_month_of_year": "*", - "mode": "single", - "table": "parsers_proxy", - "enabled": False, + "schedule_type": "daily", + "crontab_minute": 0, + "crontab_hour": 4, "notify_on_error": True, } @@ -312,9 +297,9 @@ class ExchangeViewsTest(APITestCase): self.assertIsNone(task.interval) self.assertIsNotNone(task.crontab) self.assertEqual(str(task.crontab.timezone), settings.TIME_ZONE) - self.assertFalse(task.enabled) - self.assertEqual(response.data["schedule_type"], "crontab") - self.assertEqual(response.data["crontab_hour"], "4") + self.assertTrue(task.enabled) + self.assertEqual(response.data["schedule_type"], "daily") + self.assertEqual(response.data["crontab_hour"], 4) self.assertTrue(response.data["notify_on_error"]) self.assertFalse(IntervalSchedule.objects.filter(id=interval.id).exists()) diff --git a/tests/apps/parsers/test_sources_api_e2e.py b/tests/apps/parsers/test_sources_api_e2e.py index 509a5e7..4b75fbb 100644 --- a/tests/apps/parsers/test_sources_api_e2e.py +++ b/tests/apps/parsers/test_sources_api_e2e.py @@ -209,16 +209,20 @@ class SourcesApiE2ETest(APITestCase): self.assertEqual(minprom_response.data["status"], "accepted") self.assertEqual(procurements_response.data["status"], "accepted") - minprom_tasks = minprom_response.data["tasks"] - self.assertEqual( - [item["task_id"] for item in minprom_tasks], - ["task-industrial", "task-products", "task-manufactures"], - ) + self.assertEqual(set(minprom_response.data.keys()), {"task_id", "status"}) + self.assertEqual(minprom_response.data["task_id"], "task-industrial") self.assertEqual( set(procurements_response.data.keys()), {"task_id", "status"}, ) self.assertEqual(procurements_response.data["task_id"], "task-procurements") + self.assertEqual( + BackgroundJob.objects.filter( + task_id__in=["task-industrial", "task-products", "task-manufactures"], + user_id=self.admin.id, + ).count(), + 3, + ) self.assertTrue( BackgroundJob.objects.filter( task_id="task-procurements", diff --git a/tests/test_api_inventory_e2e.py b/tests/test_api_inventory_e2e.py index 57df119..930c1d7 100644 --- a/tests/test_api_inventory_e2e.py +++ b/tests/test_api_inventory_e2e.py @@ -647,13 +647,9 @@ class ExchangeApiInventoryE2ETest(AuthenticatedApiMixin, APITestCase): create_periodic = self.client.post( periodic_tasks_url, { - "name": "inventory-periodic-task", - "description": "inventory", - "enabled": True, "schedule_type": "interval", "interval_every": 1, "interval_period": "hours", - "mode": "all", "notify_on_error": True, }, format="json", @@ -667,12 +663,9 @@ class ExchangeApiInventoryE2ETest(AuthenticatedApiMixin, APITestCase): patch_periodic = self.client.patch( periodic_detail_url, { - "name": "inventory-periodic-task-updated", - "enabled": False, - "schedule_type": "interval", - "interval_every": 2, - "interval_period": "hours", - "mode": "all", + "schedule_type": "daily", + "crontab_hour": 2, + "crontab_minute": 30, "notify_on_error": False, }, format="json",