From 941c268d32bf1fd7066f8350318659e8ee0963cf Mon Sep 17 00:00:00 2001 From: Aleksandr Meshchriakov Date: Thu, 19 Mar 2026 16:48:38 +0100 Subject: [PATCH] Fix admin API gaps for users, exchange checks, and parser logs --- .gitea/workflows/ci-cd.yml | 10 +- src/apps/exchange/services.py | 26 ++- src/apps/exchange/urls.py | 11 +- src/apps/exchange/views.py | 42 ++++ src/apps/parsers/serializers.py | 53 +++++ src/apps/parsers/urls.py | 2 + src/apps/parsers/views.py | 191 +++++++++++++++++- src/apps/user/admin.py | 27 ++- .../migrations/0007_profile_middle_name.py | 20 ++ src/apps/user/models.py | 17 +- src/apps/user/serializers.py | 26 ++- src/apps/user/services.py | 82 +++++++- src/apps/user/urls.py | 5 + src/apps/user/views.py | 51 ++++- tests/apps/exchange/test_service_units.py | 52 +++++ tests/apps/exchange/test_views.py | 42 ++++ tests/apps/parsers/test_views.py | 49 +++++ tests/apps/user/factories.py | 3 + tests/apps/user/test_models.py | 20 +- tests/apps/user/test_serializers.py | 28 ++- tests/apps/user/test_services.py | 36 ++++ tests/apps/user/test_views.py | 52 ++++- 22 files changed, 817 insertions(+), 28 deletions(-) create mode 100644 src/apps/user/migrations/0007_profile_middle_name.py diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 45b252e..97de422 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -70,10 +70,13 @@ jobs: exit 0 fi + COMMIT_MESSAGE=$(git log -1 --pretty=%s 2>/dev/null || echo "n/a") + MSG="❌ [mostovik-backend] lint failed branch=${GITHUB_REF_NAME} sha=${GITHUB_SHA} - actor=${GITHUB_ACTOR}" + actor=${GITHUB_ACTOR} + commit=${COMMIT_MESSAGE}" curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_KEY}/sendMessage" \ -d "chat_id=${TG_CHANNEL}" \ @@ -132,10 +135,13 @@ jobs: exit 0 fi + COMMIT_MESSAGE=$(git log -1 --pretty=%s 2>/dev/null || echo "n/a") + MSG="❌ [mostovik-backend] test failed branch=${GITHUB_REF_NAME} sha=${GITHUB_SHA} - actor=${GITHUB_ACTOR}" + actor=${GITHUB_ACTOR} + commit=${COMMIT_MESSAGE}" curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_KEY}/sendMessage" \ -d "chat_id=${TG_CHANNEL}" \ diff --git a/src/apps/exchange/services.py b/src/apps/exchange/services.py index 469ab25..2a1f129 100644 --- a/src/apps/exchange/services.py +++ b/src/apps/exchange/services.py @@ -29,6 +29,27 @@ class ExchangeConnectionService: "parsers.FinancialReportLine", ] + @classmethod + def test_connection_payload(cls, **payload) -> dict[str, str]: + """Проверить подключение и структуру без сохранения в БД.""" + connection = ExchangeConnection(is_active=False, **payload) + alias = cls.test_connection(connection) + cls.validate_target_structure( + connection=connection, + alias=alias, + schema_name=connection.schema_name, + ) + + with suppress(Exception): + connections[alias].close() + with suppress(Exception): + connections.databases.pop(alias, None) + + return { + "status": "success", + "message": "Подключение и структура целевой БД валидны.", + } + @classmethod @transaction.atomic def create_active_connection_and_prepare(cls, **payload) -> ExchangeConnection: @@ -456,4 +477,7 @@ class ExchangeConnectionService: ) -> None: connection.last_checked_at = timezone.now() connection.last_error = error_message - connection.save(update_fields=["last_checked_at", "last_error", "updated_at"]) + if connection.pk: + connection.save( + update_fields=["last_checked_at", "last_error", "updated_at"] + ) diff --git a/src/apps/exchange/urls.py b/src/apps/exchange/urls.py index ff493c1..d8365e2 100644 --- a/src/apps/exchange/urls.py +++ b/src/apps/exchange/urls.py @@ -1,6 +1,10 @@ """URL конфигурация приложения exchange.""" -from apps.exchange.views import ExchangeConnectionListCreateView, ExchangeCopyDataView +from apps.exchange.views import ( + ExchangeConnectionListCreateView, + ExchangeConnectionTestView, + ExchangeCopyDataView, +) from django.urls import path app_name = "exchange" @@ -9,6 +13,11 @@ exchange_urlpatterns = [ path( "connections/", ExchangeConnectionListCreateView.as_view(), name="connections" ), + path( + "connections/test/", + ExchangeConnectionTestView.as_view(), + name="connections-test", + ), path("copy/", ExchangeCopyDataView.as_view(), name="copy"), ] diff --git a/src/apps/exchange/views.py b/src/apps/exchange/views.py index 60d4659..5a18095 100644 --- a/src/apps/exchange/views.py +++ b/src/apps/exchange/views.py @@ -78,6 +78,48 @@ class ExchangeConnectionListCreateView(APIView): 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 запуска копирования данных в целевую БД.""" diff --git a/src/apps/parsers/serializers.py b/src/apps/parsers/serializers.py index 5bce10c..7209d06 100644 --- a/src/apps/parsers/serializers.py +++ b/src/apps/parsers/serializers.py @@ -296,6 +296,7 @@ class ParserLoadLogSerializer(serializers.ModelSerializer): """ source_display = serializers.CharField(source="get_source_display", read_only=True) + organizations_count = serializers.SerializerMethodField() class Meta: model = ParserLoadLog @@ -305,6 +306,7 @@ class ParserLoadLogSerializer(serializers.ModelSerializer): "source", "source_display", "records_count", + "organizations_count", "status", "error_message", "created_at", @@ -312,6 +314,57 @@ class ParserLoadLogSerializer(serializers.ModelSerializer): ] read_only_fields = fields + def get_organizations_count(self, obj) -> int: + if obj.source == ParserLoadLog.Source.FNS_REPORTS: + return ( + FinancialReport.objects.filter(load_batch=obj.batch_id) + .exclude(ogrn="") + .values("ogrn") + .distinct() + .count() + ) + if obj.source == ParserLoadLog.Source.INDUSTRIAL: + return ( + IndustrialCertificateRecord.objects.filter(load_batch=obj.batch_id) + .exclude(inn="") + .values("inn") + .distinct() + .count() + ) + if obj.source == ParserLoadLog.Source.INDUSTRIAL_PRODUCTS: + return ( + IndustrialProductRecord.objects.filter(load_batch=obj.batch_id) + .exclude(inn="") + .values("inn") + .distinct() + .count() + ) + if obj.source == ParserLoadLog.Source.MANUFACTURES: + return ( + ManufacturerRecord.objects.filter(load_batch=obj.batch_id) + .exclude(inn="") + .values("inn") + .distinct() + .count() + ) + if obj.source == ParserLoadLog.Source.INSPECTIONS: + return ( + InspectionRecord.objects.filter(load_batch=obj.batch_id) + .exclude(inn="") + .values("inn") + .distinct() + .count() + ) + if obj.source == ParserLoadLog.Source.PROCUREMENTS: + return ( + ProcurementRecord.objects.filter(load_batch=obj.batch_id) + .exclude(customer_inn="") + .values("customer_inn") + .distinct() + .count() + ) + return 0 + class ProxySerializer(serializers.ModelSerializer): """ diff --git a/src/apps/parsers/urls.py b/src/apps/parsers/urls.py index 4093338..4851f00 100644 --- a/src/apps/parsers/urls.py +++ b/src/apps/parsers/urls.py @@ -11,6 +11,7 @@ from apps.parsers.views import ( IndustrialProductViewSet, InspectionViewSet, ManufacturerViewSet, + ParserLoadLogExportView, ParserLoadLogViewSet, ProcurementViewSet, ProxyViewSet, @@ -101,6 +102,7 @@ system_router.register(r"logs", ParserLoadLogViewSet, basename="parser-logs") system_router.register(r"proxies", ProxyViewSet, basename="proxies") system_urlpatterns = [ + path("logs/export/", ParserLoadLogExportView.as_view(), name="parser-logs-export"), path("", include(system_router.urls)), ] diff --git a/src/apps/parsers/views.py b/src/apps/parsers/views.py index 7d5ceff..a04e325 100644 --- a/src/apps/parsers/views.py +++ b/src/apps/parsers/views.py @@ -5,6 +5,7 @@ Views для приложения парсеров. Добавление и удаление данных - через парсеры и админку. """ +import csv import hashlib import time import uuid @@ -43,7 +44,9 @@ from apps.parsers.serializers import ( from apps.parsers.source_cards import SourceCardService from apps.parsers.tasks import process_fns_file from django.conf import settings -from django.db.models import Count +from django.db.models import CharField, Count, Q +from django.db.models.functions import Cast +from django.http import HttpResponse from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema from rest_framework import status @@ -64,6 +67,46 @@ FNS_TAG = swagger_tag("ФНС - Бухгалтерская отчетность" SOURCES_TAG = swagger_tag("Источники для фронта", "frontend_sources") SYSTEM_TAG = swagger_tag("Системные", "system") +PARSER_LOG_ORDERING_FIELDS = { + "id", + "batch_id", + "source", + "status", + "records_count", + "created_at", + "updated_at", +} + + +def _get_parser_logs_queryset(*, search_query: str = ""): + queryset = ParserLoadLog.objects.all().order_by("-created_at") + search_term = search_query.strip() + if not search_term: + return queryset + + return queryset.annotate( + batch_id_text=Cast("batch_id", output_field=CharField()) + ).filter( + Q(source__icontains=search_term) + | Q(status__icontains=search_term) + | Q(error_message__icontains=search_term) + | Q(batch_id_text__icontains=search_term) + ) + + +def _apply_safe_ordering(queryset, ordering: str, allowed_fields: set[str]): + order_by_fields = [] + for raw_field in (item.strip() for item in ordering.split(",") if item.strip()): + field_name = raw_field[1:] if raw_field.startswith("-") else raw_field + if field_name not in allowed_fields: + continue + order_by_fields.append(raw_field) + + if not order_by_fields: + return queryset + + return queryset.order_by(*order_by_fields) + # ============================================================================= # Минпромторг - Сертификаты промышленного производства @@ -703,10 +746,15 @@ class ParserLoadLogViewSet(ReadOnlyModelViewSet): Только для администраторов. """ - queryset = ParserLoadLog.objects.all().order_by("-created_at") serializer_class = ParserLoadLogSerializer permission_classes = [IsAdminUser] filterset_fields = ["source", "status", "batch_id"] + ordering_fields = list(PARSER_LOG_ORDERING_FIELDS) + + def get_queryset(self): + return _get_parser_logs_queryset( + search_query=self.request.query_params.get("search", "") + ) @swagger_auto_schema( tags=[SYSTEM_TAG], @@ -714,8 +762,30 @@ class ParserLoadLogViewSet(ReadOnlyModelViewSet): operation_description=( "Возвращает историю загрузок данных парсерами.\n" "Доступно только администраторам.\n" - "Поддерживает фильтрацию по: source, status, batch_id." + "Поддерживает фильтрацию по: source, status, batch_id.\n" + "Поддерживает search по source, status, batch_id и error_message.\n" + "Поддерживает ordering по: id, batch_id, source, status, records_count, " + "created_at, updated_at." ), + manual_parameters=[ + openapi.Parameter( + name="search", + in_=openapi.IN_QUERY, + type=openapi.TYPE_STRING, + required=False, + description="Поиск по source, status, batch_id и error_message", + ), + openapi.Parameter( + name="ordering", + in_=openapi.IN_QUERY, + type=openapi.TYPE_STRING, + required=False, + description=( + "Сортировка по id, batch_id, source, status, records_count, " + "created_at, updated_at. Для обратной сортировки используйте префикс -" + ), + ), + ], responses={ 200: ParserLoadLogSerializer(many=True), **ErrorResponses.ADMIN, @@ -737,6 +807,121 @@ class ParserLoadLogViewSet(ReadOnlyModelViewSet): return super().retrieve(request, *args, **kwargs) +class ParserLoadLogExportView(APIView): + """Экспорт истории загрузок парсеров в CSV.""" + + permission_classes = [IsAdminUser] + + @swagger_auto_schema( + tags=[SYSTEM_TAG], + operation_summary="Экспорт истории загрузок", + operation_description=( + "Выгружает историю загрузок парсеров в CSV. " + "Поддерживает те же фильтры, search и ordering, что и список логов." + ), + manual_parameters=[ + openapi.Parameter( + name="source", + in_=openapi.IN_QUERY, + type=openapi.TYPE_STRING, + required=False, + description="Фильтр по коду источника", + ), + openapi.Parameter( + name="status", + in_=openapi.IN_QUERY, + type=openapi.TYPE_STRING, + required=False, + description="Фильтр по статусу", + ), + openapi.Parameter( + name="batch_id", + in_=openapi.IN_QUERY, + type=openapi.TYPE_INTEGER, + required=False, + description="Фильтр по ID пакета", + ), + openapi.Parameter( + name="search", + in_=openapi.IN_QUERY, + type=openapi.TYPE_STRING, + required=False, + description="Поиск по source, status, batch_id и error_message", + ), + openapi.Parameter( + name="ordering", + in_=openapi.IN_QUERY, + type=openapi.TYPE_STRING, + required=False, + description="Сортировка по тем же полям, что и в списке логов", + ), + ], + responses={ + 200: "CSV файл", + **ErrorResponses.ADMIN, + }, + ) + def get(self, request): + queryset = _get_parser_logs_queryset( + search_query=request.query_params.get("search", "") + ) + + source = request.query_params.get("source") + status_value = request.query_params.get("status") + batch_id = request.query_params.get("batch_id") + + if source: + queryset = queryset.filter(source=source) + if status_value: + queryset = queryset.filter(status=status_value) + if batch_id: + queryset = queryset.filter(batch_id=batch_id) + + queryset = _apply_safe_ordering( + queryset, + request.query_params.get("ordering", ""), + PARSER_LOG_ORDERING_FIELDS, + ) + + serializer = ParserLoadLogSerializer(queryset, many=True) + response = HttpResponse(content_type="text/csv; charset=utf-8") + response["Content-Disposition"] = 'attachment; filename="parser-load-logs.csv"' + + writer = csv.writer(response) + writer.writerow( + [ + "id", + "batch_id", + "source", + "source_display", + "records_count", + "organizations_count", + "status", + "error_message", + "created_at", + "updated_at", + ] + ) + + for row in serializer.data: + writer.writerow( + [ + row["id"], + row["batch_id"], + row["source"], + row["source_display"], + row["records_count"], + row["organizations_count"], + row["status"], + row["error_message"], + row["created_at"], + row["updated_at"], + ] + ) + + return response + + class ProxyViewSet(ReadOnlyModelViewSet): """ API для просмотра списка прокси-серверов. diff --git a/src/apps/user/admin.py b/src/apps/user/admin.py index 124a62d..f7fd305 100644 --- a/src/apps/user/admin.py +++ b/src/apps/user/admin.py @@ -16,7 +16,14 @@ class ProfileInline(admin.StackedInline): can_delete = False verbose_name_plural = "Профиль" fk_name = "user" - fields = ["first_name", "last_name", "bio", "avatar", "date_of_birth"] + fields = [ + "first_name", + "middle_name", + "last_name", + "bio", + "avatar", + "date_of_birth", + ] @admin.register(User) @@ -150,7 +157,13 @@ class ProfileAdmin(admin.ModelAdmin): "created_at", ] list_filter = ["created_at"] - search_fields = ["user__username", "user__email", "first_name", "last_name"] + search_fields = [ + "user__username", + "user__email", + "first_name", + "middle_name", + "last_name", + ] readonly_fields = ["created_at", "updated_at"] ordering = ["-created_at"] list_per_page = 50 @@ -160,7 +173,15 @@ class ProfileAdmin(admin.ModelAdmin): ("Пользователь", {"fields": ("user",)}), ( "Личная информация", - {"fields": ("first_name", "last_name", "bio", "date_of_birth")}, + { + "fields": ( + "first_name", + "middle_name", + "last_name", + "bio", + "date_of_birth", + ) + }, ), ("Аватар", {"fields": ("avatar",)}), ("Даты", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}), diff --git a/src/apps/user/migrations/0007_profile_middle_name.py b/src/apps/user/migrations/0007_profile_middle_name.py new file mode 100644 index 0000000..adecee1 --- /dev/null +++ b/src/apps/user/migrations/0007_profile_middle_name.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("user", "0006_create_default_role_groups"), + ] + + operations = [ + migrations.AddField( + model_name="profile", + name="middle_name", + field=models.CharField( + blank=True, + max_length=50, + null=True, + verbose_name="middle name", + ), + ), + ] diff --git a/src/apps/user/models.py b/src/apps/user/models.py index 03cebb7..42aa7ba 100644 --- a/src/apps/user/models.py +++ b/src/apps/user/models.py @@ -72,6 +72,13 @@ class Profile(models.Model): first_name = models.CharField(_("first name"), max_length=50, blank=True, null=True) + middle_name = models.CharField( + _("middle name"), + max_length=50, + blank=True, + null=True, + ) + last_name = models.CharField(_("last name"), max_length=50, blank=True, null=True) bio = models.TextField( @@ -104,10 +111,8 @@ class Profile(models.Model): @property def full_name(self): """Полное имя пользователя""" - if self.first_name and self.last_name: - return f"{self.first_name} {self.last_name}" - elif self.first_name: - return self.first_name - elif self.last_name: - return self.last_name + parts = [self.first_name, self.middle_name, self.last_name] + full_name = " ".join(part for part in parts if part) + if full_name: + return full_name return self.user.username diff --git a/src/apps/user/serializers.py b/src/apps/user/serializers.py index 555f2be..8ea92b0 100644 --- a/src/apps/user/serializers.py +++ b/src/apps/user/serializers.py @@ -54,6 +54,7 @@ class UserProfileSerializer(serializers.ModelSerializer): fields = ( "id", "first_name", + "middle_name", "last_name", "full_name", "bio", @@ -127,10 +128,13 @@ class AdminUserCreateSerializer(serializers.ModelSerializer): default=UserService.ROLE_USER, help_text="Прикладная роль пользователя", ) - first_name = serializers.CharField( - required=False, allow_blank=True, allow_null=True + first_name = serializers.CharField(allow_blank=False) + middle_name = serializers.CharField( + required=False, + allow_blank=True, + allow_null=True, ) - last_name = serializers.CharField(required=False, allow_blank=True, allow_null=True) + last_name = serializers.CharField(allow_blank=False) class Meta: model = User @@ -143,6 +147,7 @@ class AdminUserCreateSerializer(serializers.ModelSerializer): "is_active", "is_verified", "first_name", + "middle_name", "last_name", ) extra_kwargs = { @@ -172,6 +177,11 @@ class AdminUserUpdateSerializer(serializers.ModelSerializer): first_name = serializers.CharField( required=False, allow_blank=True, allow_null=True ) + middle_name = serializers.CharField( + required=False, + allow_blank=True, + allow_null=True, + ) last_name = serializers.CharField(required=False, allow_blank=True, allow_null=True) class Meta: @@ -185,6 +195,7 @@ class AdminUserUpdateSerializer(serializers.ModelSerializer): "is_active", "is_verified", "first_name", + "middle_name", "last_name", ) @@ -194,7 +205,14 @@ class ProfileUpdateSerializer(serializers.ModelSerializer): class Meta: model = Profile - fields = ("first_name", "last_name", "bio", "avatar", "date_of_birth") + fields = ( + "first_name", + "middle_name", + "last_name", + "bio", + "avatar", + "date_of_birth", + ) class LoginSerializer(serializers.Serializer): diff --git a/src/apps/user/services.py b/src/apps/user/services.py index a113b5b..8187756 100644 --- a/src/apps/user/services.py +++ b/src/apps/user/services.py @@ -4,6 +4,7 @@ from apps.core.exceptions import NotFoundError from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.db import transaction +from django.db.models import F, Q from rest_framework_simplejwt.tokens import RefreshToken from .models import Profile @@ -69,6 +70,65 @@ class UserService: .order_by("-created_at") ) + @classmethod + def get_filtered_users_queryset( + cls, + *, + search: str = "", + ordering: str = "", + ): + """Queryset списка пользователей с поиском и сортировкой для админского UI.""" + queryset = cls.get_users_queryset() + search_term = search.strip() + + if search_term: + queryset = queryset.filter( + Q(username__icontains=search_term) + | Q(email__icontains=search_term) + | Q(phone__icontains=search_term) + | Q(profile__first_name__icontains=search_term) + | Q(profile__middle_name__icontains=search_term) + | Q(profile__last_name__icontains=search_term) + ).distinct() + + ordering_fields = [] + ordering_map = { + "id": ("id", False), + "email": ("email", False), + "username": ("username", False), + "phone": ("phone", False), + "is_active": ("is_active", False), + "is_verified": ("is_verified", False), + "created_at": ("created_at", False), + "updated_at": ("updated_at", False), + "first_name": ("profile__first_name", True), + "middle_name": ("profile__middle_name", True), + "last_name": ("profile__last_name", True), + "role": ("is_staff", False), + } + + for raw_field in (item.strip() for item in ordering.split(",") if item.strip()): + is_desc = raw_field.startswith("-") + field_name = raw_field[1:] if is_desc else raw_field + mapped_config = ordering_map.get(field_name) + if not mapped_config: + continue + mapped_field, nulls_last = mapped_config + if nulls_last: + ordering_fields.append( + F(mapped_field).desc(nulls_last=True) + if is_desc + else F(mapped_field).asc(nulls_last=True) + ) + continue + + ordering_fields.append(f"-{mapped_field}" if is_desc else mapped_field) + + if ordering_fields: + queryset = queryset.order_by(*ordering_fields, "-created_at") + + return queryset + @classmethod def get_user_by_email(cls, email: str) -> User: """Получает пользователя по email @@ -147,8 +207,9 @@ class UserService: username: str, password: str, role: str, - first_name: str | None = None, - last_name: str | None = None, + first_name: str, + last_name: str, + middle_name: str | None = None, **extra_fields, ) -> User: """Создаёт пользователя администратором и назначает роль.""" @@ -162,6 +223,7 @@ class UserService: cls._update_or_create_profile( user=user, first_name=first_name, + middle_name=middle_name, last_name=last_name, ) return cls.get_users_queryset().get(id=user.id) @@ -174,7 +236,9 @@ class UserService: role = fields.pop("role", None) password = fields.pop("password", None) profile_fields = { - key: fields.pop(key) for key in ("first_name", "last_name") if key in fields + key: fields.pop(key) + for key in ("first_name", "middle_name", "last_name") + if key in fields } for field, value in fields.items(): @@ -201,6 +265,14 @@ class UserService: user.save(update_fields=["is_active"]) return user + @classmethod + def activate_user(cls, user_id: int) -> User: + """Активирует пользователя.""" + user = cls.get_user_by_id(user_id) + user.is_active = True + user.save(update_fields=["is_active"]) + return user + @classmethod def delete_user(cls, user_id: int) -> None: """ @@ -327,11 +399,14 @@ class UserService: *, user: User, first_name: str | None = None, + middle_name: str | None = None, last_name: str | None = None, ) -> Profile: profile, _ = Profile.objects.get_or_create(user=user) if first_name is not None: profile.first_name = first_name + if middle_name is not None: + profile.middle_name = middle_name if last_name is not None: profile.last_name = last_name profile.save() @@ -410,6 +485,7 @@ class ProfileService: "is_verified": user.is_verified, "phone": user.phone, "first_name": profile.first_name, + "middle_name": profile.middle_name, "last_name": profile.last_name, "full_name": profile.full_name, "bio": profile.bio, diff --git a/src/apps/user/urls.py b/src/apps/user/urls.py index 80ab1f3..0efcad4 100644 --- a/src/apps/user/urls.py +++ b/src/apps/user/urls.py @@ -27,6 +27,11 @@ urlpatterns = [ views.AdminUserDeactivateView.as_view(), name="admin-user-deactivate", ), + path( + "admin/users//activate/", + views.AdminUserActivateView.as_view(), + name="admin-user-activate", + ), # Безопасность path( "password/change/", views.PasswordChangeView.as_view(), name="password_change" diff --git a/src/apps/user/views.py b/src/apps/user/views.py index 08ac756..30d684d 100644 --- a/src/apps/user/views.py +++ b/src/apps/user/views.py @@ -163,14 +163,42 @@ class AdminUserListCreateView(APIView): @swagger_auto_schema( tags=[USER_ADMIN_TAG], operation_summary="Список пользователей", - operation_description="Возвращает список пользователей. Доступно только администраторам.", + operation_description=( + "Возвращает список пользователей. Доступно только администраторам.\n" + "Поддерживает search по username, email, phone и ФИО.\n" + "Поддерживает ordering по полям пользователя и profile-именам." + ), + manual_parameters=[ + openapi.Parameter( + name="search", + in_=openapi.IN_QUERY, + type=openapi.TYPE_STRING, + required=False, + description="Поиск по username, email, phone, first_name, middle_name, last_name", + ), + openapi.Parameter( + name="ordering", + in_=openapi.IN_QUERY, + type=openapi.TYPE_STRING, + required=False, + description=( + "Сортировка. Поддерживаются: id, email, username, phone, " + "is_active, is_verified, created_at, updated_at, first_name, " + "middle_name, last_name, role. Для обратной сортировки используйте префикс -" + ), + ), + ], responses={ 200: UserSerializer(many=True), **ErrorResponses.ADMIN, }, ) def get(self, request): - serializer = UserSerializer(UserService.get_users_queryset(), many=True) + queryset = UserService.get_filtered_users_queryset( + search=request.query_params.get("search", ""), + ordering=request.query_params.get("ordering", ""), + ) + serializer = UserSerializer(queryset, many=True) return Response(serializer.data) @swagger_auto_schema( @@ -278,6 +306,25 @@ class AdminUserDeactivateView(APIView): return Response(UserSerializer(user).data) +class AdminUserActivateView(APIView): + """Активация пользователя администратором.""" + + permission_classes = [IsAdminUser] + + @swagger_auto_schema( + tags=[USER_ADMIN_TAG], + operation_summary="Активировать пользователя", + operation_description="Возвращает пользователя в активное состояние.", + responses={ + 200: UserSerializer, + **ErrorResponses.ADMIN_NOT_FOUND, + }, + ) + def post(self, request, user_id: int): + user = UserService.activate_user(user_id) + return Response(UserSerializer(user).data) + + class UserUpdateView(APIView): """Обновление данных пользователя.""" diff --git a/tests/apps/exchange/test_service_units.py b/tests/apps/exchange/test_service_units.py index 11c754d..dde0442 100644 --- a/tests/apps/exchange/test_service_units.py +++ b/tests/apps/exchange/test_service_units.py @@ -4,6 +4,7 @@ from contextlib import suppress from types import SimpleNamespace from unittest.mock import MagicMock, patch +from apps.exchange.models import ExchangeConnection from apps.exchange.services import ExchangeConnectionService, ExchangeServiceError from apps.parsers.models import ( ManufacturerRecord, @@ -75,6 +76,35 @@ class ExchangeConnectionServiceUnitTest(TestCase): schema_name="public", ) + def test_test_connection_payload_does_not_persist_connection(self): + with patch.object( + ExchangeConnectionService, + "test_connection", + return_value="target_alias", + ) as test_connection_mock, patch.object( + ExchangeConnectionService, + "validate_target_structure", + ) as validate_mock, patch( + "apps.exchange.services.connections" + ) as connections_mock: + connections_mock.databases = {"target_alias": {}} + result = ExchangeConnectionService.test_connection_payload( + server="127.0.0.1", + port=5432, + username="postgres", + password=_db_secret(), + database_name="target_db", + schema_name="public", + ) + + self.assertEqual(result["status"], "success") + self.assertEqual( + result["message"], "Подключение и структура целевой БД валидны." + ) + self.assertEqual(ExchangeConnection.objects.count(), 0) + test_connection_mock.assert_called_once() + validate_mock.assert_called_once() + def test_get_active_connection_raises_when_missing(self): with self.assertRaisesMessage( ExchangeServiceError, @@ -124,6 +154,28 @@ class ExchangeConnectionServiceUnitTest(TestCase): self.assertEqual(connection.last_error, "boom") self.assertIsNotNone(connection.last_checked_at) + def test_test_connection_failure_for_unsaved_connection_does_not_try_to_save(self): + connection = ExchangeConnectionFactory.build(last_error="") + db_connection = MagicMock() + db_connection.ensure_connection.side_effect = RuntimeError("boom") + connections_mock = MagicMock() + connections_mock.__getitem__.return_value = db_connection + + with patch.object( + ExchangeConnectionService, + "_configure_alias", + return_value="exchange_target_unsaved", + ), patch( + "apps.exchange.services.connections", connections_mock + ), self.assertRaisesMessage( + ExchangeServiceError, + "Ошибка подключения к целевой БД: boom", + ): + ExchangeConnectionService.test_connection(connection) + + self.assertEqual(connection.last_error, "boom") + self.assertIsNotNone(connection.last_checked_at) + def test_validate_target_structure_calls_all_validation_steps(self): connection = ExchangeConnectionFactory() db_connection = MagicMock() diff --git a/tests/apps/exchange/test_views.py b/tests/apps/exchange/test_views.py index 2a7eaf9..d99ac5f 100644 --- a/tests/apps/exchange/test_views.py +++ b/tests/apps/exchange/test_views.py @@ -18,6 +18,7 @@ class ExchangeViewsTest(APITestCase): self.user = UserFactory.create_user() self.admin = UserFactory.create_superuser() self.connections_url = reverse("api_v1:exchange:connections") + self.test_connection_url = reverse("api_v1:exchange:connections-test") self.copy_url = reverse("api_v1:exchange:copy") def test_connections_endpoint_admin_only(self): @@ -65,6 +66,47 @@ class ExchangeViewsTest(APITestCase): connection_mock.assert_called_once() validate_mock.assert_called_once() + @patch("apps.exchange.services.ExchangeConnectionService.test_connection_payload") + def test_test_connection_success(self, test_connection_mock): + payload = { + "server": "127.0.0.1", + "port": 5432, + "username": "postgres", + "password": "secret", + "database_name": "target_db", + "schema_name": "public", + } + test_connection_mock.return_value = { + "status": "success", + "message": "ok", + } + + self.client.force_authenticate(self.admin) + response = self.client.post(self.test_connection_url, payload, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["data"]["status"], "success") + self.assertEqual(ExchangeConnection.objects.count(), 0) + test_connection_mock.assert_called_once_with(**payload) + + @patch("apps.exchange.services.ExchangeConnectionService.test_connection_payload") + def test_test_connection_failure_returns_400(self, test_connection_mock): + payload = { + "server": "127.0.0.1", + "port": 5432, + "username": "postgres", + "password": "secret", + "database_name": "target_db", + "schema_name": "public", + } + test_connection_mock.side_effect = ExchangeServiceError("Connection refused") + + self.client.force_authenticate(self.admin) + response = self.client.post(self.test_connection_url, payload, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(ExchangeConnection.objects.count(), 0) + @patch("apps.exchange.services.ExchangeConnectionService.test_connection") def test_create_connection_fail_rolls_back_active(self, connection_mock): connection_mock.side_effect = ExchangeServiceError("Connection refused") diff --git a/tests/apps/parsers/test_views.py b/tests/apps/parsers/test_views.py index 5ac584b..4b531e5 100644 --- a/tests/apps/parsers/test_views.py +++ b/tests/apps/parsers/test_views.py @@ -179,6 +179,55 @@ class ParsersViewSetTest(APITestCase): ) self.assertEqual(proxy_detail.status_code, status.HTTP_200_OK) + def test_system_logs_support_search_and_organizations_count(self): + first_log = ParserLoadLogFactory( + source="manufactures", + batch_id=101, + status="success", + error_message="ok", + ) + ParserLoadLogFactory( + source="inspections", + batch_id=202, + status="failed", + error_message="timeout", + ) + ManufacturerRecordFactory(load_batch=101, inn="7701000001") + ManufacturerRecordFactory(load_batch=101, inn="7701000002") + + self.client.force_authenticate(self.admin) + response = self.client.get( + reverse("api_v1:system:parser-logs-list"), + {"search": "101"}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + rows = response.data["data"] + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["id"], first_log.id) + self.assertEqual(rows[0]["organizations_count"], 2) + + def test_system_logs_export_returns_csv(self): + ParserLoadLogFactory( + source="manufactures", + batch_id=333, + status="success", + records_count=4, + ) + ManufacturerRecordFactory(load_batch=333, inn="7701000001") + + self.client.force_authenticate(self.admin) + response = self.client.get( + reverse("api_v1:system:parser-logs-export"), + {"search": "333"}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response["Content-Type"], "text/csv; charset=utf-8") + content = response.content.decode("utf-8") + self.assertIn("organizations_count", content) + self.assertIn("333", content) + def test_fns_upload_invalid_filename(self): self.client.force_authenticate(self.admin) with tempfile.TemporaryDirectory() as tmpdir: diff --git a/tests/apps/user/factories.py b/tests/apps/user/factories.py index 6a9af2b..000497a 100644 --- a/tests/apps/user/factories.py +++ b/tests/apps/user/factories.py @@ -62,6 +62,7 @@ class ProfileFactory(factory.django.DjangoModelFactory): user = factory.SubFactory(UserFactory) first_name = factory.LazyAttribute(lambda _: fake.first_name()) + middle_name = factory.LazyAttribute(lambda _: fake.first_name()) last_name = factory.LazyAttribute(lambda _: fake.last_name()) bio = factory.LazyAttribute(lambda _: fake.text(max_nb_chars=200)) date_of_birth = factory.LazyAttribute( @@ -81,6 +82,8 @@ class ProfileFactory(factory.django.DjangoModelFactory): # Заполняем поля faker'ом, если не переданы if "first_name" not in kwargs: profile.first_name = fake.first_name() + if "middle_name" not in kwargs: + profile.middle_name = fake.first_name() if "last_name" not in kwargs: profile.last_name = fake.last_name() if "bio" not in kwargs: diff --git a/tests/apps/user/test_models.py b/tests/apps/user/test_models.py index 1ae409a..47ff02b 100644 --- a/tests/apps/user/test_models.py +++ b/tests/apps/user/test_models.py @@ -65,8 +65,10 @@ class ProfileModelTest(TestCase): self.assertEqual(self.profile.user, self.user) # Проверяем, что имена заполнены faker'ом self.assertIsNotNone(self.profile.first_name) + self.assertIsNotNone(self.profile.middle_name) self.assertIsNotNone(self.profile.last_name) self.assertTrue(len(self.profile.first_name) > 0) + self.assertTrue(len(self.profile.middle_name) > 0) self.assertTrue(len(self.profile.last_name) > 0) def test_profile_str_representation(self): @@ -90,6 +92,12 @@ class ProfileModelTest(TestCase): self.assertTrue(field.blank) self.assertTrue(field.null) + def test_profile_middle_name_optional(self): + """Test middle_name field is optional""" + field = self.profile._meta.get_field("middle_name") + self.assertTrue(field.blank) + self.assertTrue(field.null) + def test_profile_bio_optional(self): """Test bio field is optional""" field = self.profile._meta.get_field("bio") @@ -112,17 +120,27 @@ class ProfileModelTest(TestCase): """Test full_name property""" # Test with both names first_name = fake.first_name() + middle_name = fake.first_name() last_name = fake.last_name() self.profile.first_name = first_name + self.profile.middle_name = middle_name self.profile.last_name = last_name - self.assertEqual(self.profile.full_name, f"{first_name} {last_name}") + self.assertEqual( + self.profile.full_name, f"{first_name} {middle_name} {last_name}" + ) # Test with only first name + self.profile.middle_name = "" self.profile.last_name = "" self.assertEqual(self.profile.full_name, first_name) + # Test with first and middle name + self.profile.middle_name = middle_name + self.assertEqual(self.profile.full_name, f"{first_name} {middle_name}") + # Test with only last name self.profile.first_name = "" + self.profile.middle_name = "" self.profile.last_name = last_name self.assertEqual(self.profile.full_name, last_name) diff --git a/tests/apps/user/test_serializers.py b/tests/apps/user/test_serializers.py index 12c7f9e..cbfada9 100644 --- a/tests/apps/user/test_serializers.py +++ b/tests/apps/user/test_serializers.py @@ -181,11 +181,27 @@ class AdminUserCreateSerializerTest(TestCase): "is_active": True, "is_verified": False, "first_name": fake.first_name(), + "middle_name": fake.first_name(), "last_name": fake.last_name(), } ) self.assertTrue(serializer.is_valid(), serializer.errors) + def test_admin_user_create_requires_first_and_last_name(self): + serializer = AdminUserCreateSerializer( + data={ + "email": fake.unique.email(), + "username": fake.unique.user_name(), + "phone": f"+7{fake.numerify('##########')}", + "password": fake.password(length=12, special_chars=False), + "role": "user", + } + ) + + self.assertFalse(serializer.is_valid()) + self.assertIn("first_name", serializer.errors) + self.assertIn("last_name", serializer.errors) + class AdminUserUpdateSerializerTest(TestCase): """Tests for AdminUserUpdateSerializer.""" @@ -200,6 +216,7 @@ class AdminUserUpdateSerializerTest(TestCase): "role": "admin", "is_active": False, "first_name": fake.first_name(), + "middle_name": fake.first_name(), }, partial=True, ) @@ -217,6 +234,7 @@ class ProfileUpdateSerializerTest(TestCase): """Test valid profile update data""" update_data = { "first_name": fake.first_name(), + "middle_name": fake.first_name(), "last_name": fake.last_name(), "bio": fake.text(max_nb_chars=200), "date_of_birth": str(fake.date_of_birth(minimum_age=18, maximum_age=80)), @@ -229,13 +247,21 @@ class ProfileUpdateSerializerTest(TestCase): updated_profile = serializer.save() self.assertEqual(updated_profile.first_name, update_data["first_name"]) + self.assertEqual(updated_profile.middle_name, update_data["middle_name"]) self.assertEqual(updated_profile.last_name, update_data["last_name"]) self.assertEqual(updated_profile.bio, update_data["bio"]) def test_fields_allowed(self): """Test only allowed fields can be updated""" serializer = ProfileUpdateSerializer() - allowed_fields = ["first_name", "last_name", "bio", "avatar", "date_of_birth"] + allowed_fields = [ + "first_name", + "middle_name", + "last_name", + "bio", + "avatar", + "date_of_birth", + ] self.assertEqual(set(serializer.Meta.fields), set(allowed_fields)) diff --git a/tests/apps/user/test_services.py b/tests/apps/user/test_services.py index 8222725..f819922 100644 --- a/tests/apps/user/test_services.py +++ b/tests/apps/user/test_services.py @@ -122,6 +122,7 @@ class UserServiceTest(TestCase): self.user.id, role=UserService.ROLE_ADMIN, first_name="Иван", + middle_name="Иванович", last_name="Иванов", ) @@ -130,6 +131,7 @@ class UserServiceTest(TestCase): ) self.assertTrue(updated_user.is_staff) self.assertEqual(updated_user.profile.first_name, "Иван") + self.assertEqual(updated_user.profile.middle_name, "Иванович") self.assertEqual(updated_user.profile.last_name, "Иванов") def test_update_managed_user_updates_password(self): @@ -161,6 +163,37 @@ class UserServiceTest(TestCase): user = UserService.deactivate_user(self.user.id) self.assertFalse(user.is_active) + def test_activate_user_success(self): + """Test activation of user.""" + self.user.is_active = False + self.user.save(update_fields=["is_active"]) + + user = UserService.activate_user(self.user.id) + self.assertTrue(user.is_active) + + def test_get_filtered_users_queryset_searches_by_profile_name(self): + ProfileFactory.create_profile( + user=self.user, + first_name="Найден", + middle_name="Тестович", + last_name="Пользователь", + ) + + queryset = UserService.get_filtered_users_queryset(search="Тестович") + + self.assertEqual(list(queryset.values_list("id", flat=True)), [self.user.id]) + + def test_get_filtered_users_queryset_orders_by_profile_field(self): + first = UserFactory.create_user() + second = UserFactory.create_user() + ProfileFactory.create_profile(user=first, first_name="Борис") + ProfileFactory.create_profile(user=second, first_name="Алексей") + + queryset = UserService.get_filtered_users_queryset(ordering="first_name") + + ids = list(queryset.values_list("id", flat=True)[:2]) + self.assertEqual(ids, [second.id, first.id]) + def test_get_user_capabilities_for_admin(self): """Test admin capabilities set.""" admin = UserFactory.create_user(is_staff=True) @@ -236,6 +269,7 @@ class ProfileServiceTest(TestCase): self.profile = ProfileFactory.create_profile(user=self.user) self.profile_data = { "first_name": fake.first_name(), + "middle_name": fake.first_name(), "last_name": fake.last_name(), "bio": fake.text(max_nb_chars=200), "date_of_birth": str(fake.date_of_birth(minimum_age=18, maximum_age=80)), @@ -270,6 +304,7 @@ class ProfileServiceTest(TestCase): self.assertIsNotNone(updated_profile) self.assertEqual(updated_profile.first_name, self.profile_data["first_name"]) + self.assertEqual(updated_profile.middle_name, self.profile_data["middle_name"]) self.assertEqual(updated_profile.last_name, self.profile_data["last_name"]) self.assertEqual(updated_profile.bio, self.profile_data["bio"]) @@ -288,6 +323,7 @@ class ProfileServiceTest(TestCase): self.assertEqual(profile_data["email"], self.user.email) self.assertEqual(profile_data["username"], self.user.username) self.assertEqual(profile_data["first_name"], self.profile.first_name) + self.assertEqual(profile_data["middle_name"], self.profile.middle_name) self.assertEqual(profile_data["last_name"], self.profile.last_name) self.assertEqual(profile_data["full_name"], self.profile.full_name) self.assertEqual(profile_data["bio"], self.profile.bio) diff --git a/tests/apps/user/test_views.py b/tests/apps/user/test_views.py index a13e063..cca4230 100644 --- a/tests/apps/user/test_views.py +++ b/tests/apps/user/test_views.py @@ -224,6 +224,34 @@ class AdminUserManagementViewTest(APITestCase): self.assertIn(self.admin.username, usernames) self.assertIn(self.user.username, usernames) + def test_admin_can_search_users(self): + ProfileFactory.create_profile( + user=self.user, + first_name="Сергей", + middle_name="Петрович", + last_name="Иванов", + ) + another = UserFactory.create_user() + ProfileFactory.create_profile(user=another, first_name="Илья") + + response = self.client.get(self.list_url, {"search": "Петрович"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + usernames = [item["username"] for item in response.data] + self.assertEqual(usernames, [self.user.username]) + + def test_admin_can_order_users(self): + first = UserFactory.create_user() + second = UserFactory.create_user() + ProfileFactory.create_profile(user=first, first_name="Борис") + ProfileFactory.create_profile(user=second, first_name="Алексей") + + response = self.client.get(self.list_url, {"ordering": "first_name"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + ordered_ids = [item["id"] for item in response.data] + self.assertLess(ordered_ids.index(second.id), ordered_ids.index(first.id)) + def test_admin_can_create_user_with_role(self): password = fake.password(length=12, special_chars=False) payload = { @@ -233,6 +261,7 @@ class AdminUserManagementViewTest(APITestCase): "password": password, "role": "admin", "first_name": "Петр", + "middle_name": "Петрович", "last_name": "Петров", } @@ -243,13 +272,19 @@ class AdminUserManagementViewTest(APITestCase): self.assertTrue(created.is_staff) self.assertEqual(response.data["role"], "admin") self.assertEqual(created.profile.first_name, "Петр") + self.assertEqual(created.profile.middle_name, "Петрович") def test_admin_can_update_user_and_role(self): url = reverse("api_v1:user:admin-user-detail", args=[self.user.id]) response = self.client.patch( url, - {"role": "admin", "first_name": "Иван", "is_verified": True}, + { + "role": "admin", + "first_name": "Иван", + "middle_name": "Иванович", + "is_verified": True, + }, format="json", ) @@ -258,6 +293,7 @@ class AdminUserManagementViewTest(APITestCase): self.assertTrue(self.user.is_staff) self.assertTrue(self.user.is_verified) self.assertEqual(self.user.profile.first_name, "Иван") + self.assertEqual(self.user.profile.middle_name, "Иванович") def test_admin_can_get_user_detail(self): url = reverse("api_v1:user:admin-user-detail", args=[self.user.id]) @@ -302,6 +338,17 @@ class AdminUserManagementViewTest(APITestCase): self.user.refresh_from_db() self.assertFalse(self.user.is_active) + def test_admin_can_activate_user(self): + self.user.is_active = False + self.user.save(update_fields=["is_active"]) + url = reverse("api_v1:user:admin-user-activate", args=[self.user.id]) + + response = self.client.post(url, {}, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.user.refresh_from_db() + self.assertTrue(self.user.is_active) + def test_admin_cannot_deactivate_self(self): url = reverse("api_v1:user:admin-user-deactivate", args=[self.admin.id]) @@ -330,6 +377,7 @@ class ProfileDetailViewTest(APITestCase): self.update_data = { "first_name": fake.first_name(), + "middle_name": fake.first_name(), "last_name": fake.last_name(), "bio": fake.text(max_nb_chars=200), } @@ -340,6 +388,7 @@ class ProfileDetailViewTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["first_name"], self.profile.first_name) + self.assertEqual(response.data["middle_name"], self.profile.middle_name) def test_update_profile_success(self): """Test successful profile update""" @@ -347,6 +396,7 @@ class ProfileDetailViewTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["first_name"], self.update_data["first_name"]) + self.assertEqual(response.data["middle_name"], self.update_data["middle_name"]) self.assertEqual(response.data["last_name"], self.update_data["last_name"]) # Verify in database