From d030c705acfb7545ed8d9bf9345ddbc012f9a8b9 Mon Sep 17 00:00:00 2001 From: Aleksandr Meshchriakov Date: Mon, 23 Mar 2026 12:39:30 +0100 Subject: [PATCH] fix(user): restore admin users list contract --- src/apps/user/views.py | 115 +++++++++++++++++++++++++++++++- tests/apps/user/test_views.py | 17 +++-- tests/test_api_inventory_e2e.py | 6 ++ 3 files changed, 131 insertions(+), 7 deletions(-) diff --git a/src/apps/user/views.py b/src/apps/user/views.py index 7c31d2a..00faf10 100644 --- a/src/apps/user/views.py +++ b/src/apps/user/views.py @@ -1,11 +1,15 @@ +from urllib.parse import urlencode + from apps.core.openapi import CommonResponses, ErrorResponses, swagger_tag from django.contrib.auth import authenticate from django.contrib.auth.hashers import check_password +from django.core.paginator import Paginator from django.shortcuts import get_object_or_404 from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema from rest_framework import generics, status from rest_framework.decorators import api_view, permission_classes +from rest_framework.exceptions import ValidationError from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -105,6 +109,87 @@ ADMIN_USER_VALIDATION_ERROR_RESPONSE = openapi.Response( ), ) +ADMIN_USER_LIST_RESPONSE = openapi.Response( + description="Список пользователей в формате frontend-контракта", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "count": openapi.Schema(type=openapi.TYPE_INTEGER), + "next": openapi.Schema(type=openapi.TYPE_STRING, format="uri", nullable=True), + "previous": openapi.Schema( + type=openapi.TYPE_STRING, format="uri", nullable=True + ), + "results": openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "id": openapi.Schema(type=openapi.TYPE_INTEGER), + "username": openapi.Schema(type=openapi.TYPE_STRING), + "email": openapi.Schema(type=openapi.TYPE_STRING), + "phone": openapi.Schema( + type=openapi.TYPE_STRING, nullable=True + ), + "is_active": openapi.Schema(type=openapi.TYPE_BOOLEAN), + "role": openapi.Schema(type=openapi.TYPE_STRING), + "role_label": openapi.Schema(type=openapi.TYPE_STRING), + "profile": openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "first_name": openapi.Schema(type=openapi.TYPE_STRING), + "middle_name": openapi.Schema(type=openapi.TYPE_STRING), + "last_name": openapi.Schema(type=openapi.TYPE_STRING), + "full_name": openapi.Schema(type=openapi.TYPE_STRING), + }, + ), + }, + ), + ), + }, + ), +) + + +def _build_page_url(request, page_number: int) -> str: + query_params = request.query_params.copy() + query_params["page"] = page_number + encoded_query = urlencode(query_params, doseq=True) + return request.build_absolute_uri(f"{request.path}?{encoded_query}") + + +def _paginate_user_queryset(request, queryset): + page_size_raw = request.query_params.get("page_size", "20") + page_raw = request.query_params.get("page", "1") + try: + page_size = max(1, min(int(page_size_raw), 100)) + page_number = max(1, int(page_raw)) + except (TypeError, ValueError) as exc: + raise ValidationError( + { + "detail": ( + "Параметры page и page_size должны быть положительными " + "целыми числами." + ) + } + ) from exc + + paginator = Paginator(queryset, page_size) + page_obj = paginator.get_page(page_number) + return { + "count": paginator.count, + "next": ( + _build_page_url(request, page_obj.next_page_number()) + if page_obj.has_next() + else None + ), + "previous": ( + _build_page_url(request, page_obj.previous_page_number()) + if page_obj.has_previous() + else None + ), + "results": page_obj.object_list, + } + class RegisterView(APIView): """ @@ -261,9 +346,23 @@ class AdminUserListCreateView(APIView): "middle_name, last_name, role. Для обратной сортировки используйте префикс -" ), ), + openapi.Parameter( + name="page", + in_=openapi.IN_QUERY, + type=openapi.TYPE_INTEGER, + required=False, + description="Номер страницы, начиная с 1", + ), + openapi.Parameter( + name="page_size", + in_=openapi.IN_QUERY, + type=openapi.TYPE_INTEGER, + required=False, + description="Размер страницы, по умолчанию 20, максимум 100", + ), ], responses={ - 200: FrontendManagedUserSerializer(many=True), + 200: ADMIN_USER_LIST_RESPONSE, **ErrorResponses.ADMIN, }, ) @@ -272,8 +371,18 @@ class AdminUserListCreateView(APIView): search=request.query_params.get("search", ""), ordering=request.query_params.get("ordering", ""), ) - serializer = FrontendManagedUserSerializer(queryset, many=True) - return Response(serializer.data) + paginated = _paginate_user_queryset(request, queryset) + serializer = FrontendUserWithProfileSerializer( + paginated["results"], many=True + ) + return Response( + { + "count": paginated["count"], + "next": paginated["next"], + "previous": paginated["previous"], + "results": serializer.data, + } + ) @swagger_auto_schema( tags=[USER_ADMIN_TAG], diff --git a/tests/apps/user/test_views.py b/tests/apps/user/test_views.py index d15cff7..dad952c 100644 --- a/tests/apps/user/test_views.py +++ b/tests/apps/user/test_views.py @@ -254,7 +254,11 @@ class AdminUserManagementViewTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual( - set(response.data[0].keys()), + set(response.data.keys()), + {"count", "next", "previous", "results"}, + ) + self.assertEqual( + set(response.data["results"][0].keys()), { "id", "username", @@ -263,9 +267,14 @@ class AdminUserManagementViewTest(APITestCase): "is_active", "role", "role_label", + "profile", }, ) - usernames = {item["username"] for item in response.data} + self.assertEqual( + set(response.data["results"][0]["profile"].keys()), + {"first_name", "middle_name", "last_name", "full_name"}, + ) + usernames = {item["username"] for item in response.data["results"]} self.assertIn(self.admin.username, usernames) self.assertIn(self.user.username, usernames) @@ -282,7 +291,7 @@ class AdminUserManagementViewTest(APITestCase): 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] + usernames = [item["username"] for item in response.data["results"]] self.assertEqual(usernames, [self.user.username]) def test_admin_can_order_users(self): @@ -294,7 +303,7 @@ class AdminUserManagementViewTest(APITestCase): 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] + ordered_ids = [item["id"] for item in response.data["results"]] self.assertLess(ordered_ids.index(second.id), ordered_ids.index(first.id)) def test_admin_can_create_user_with_role(self): diff --git a/tests/test_api_inventory_e2e.py b/tests/test_api_inventory_e2e.py index 0a525d5..57df119 100644 --- a/tests/test_api_inventory_e2e.py +++ b/tests/test_api_inventory_e2e.py @@ -245,6 +245,12 @@ class UserApiInventoryE2ETest(AuthenticatedApiMixin, APITestCase): self.authenticate(self.admin) list_response = self.client.get(reverse("api_v1:user:admin-users")) self.assertEqual(list_response.status_code, status.HTTP_200_OK) + self.assertEqual( + set(list_response.data.keys()), + {"count", "next", "previous", "results"}, + ) + self.assertTrue(list_response.data["results"]) + self.assertIn("profile", list_response.data["results"][0]) create_payload = { "email": fake.unique.email(),