From da010c86da13ea1d02fbd91829ff5e71304383b6 Mon Sep 17 00:00:00 2001 From: Gregory Mariani <85942661+gmaOCR@users.noreply.github.com> Date: Mon, 25 Nov 2024 20:24:00 +0100 Subject: [PATCH] Adding api/admin/baskets endpoint (#363) * Adding BasketAdminList to urls.py Creating admin basket view and serializer correction list comma added restore final update optimizing test * Adding CustomPageNumberPagination for baskets admin endpoint * Adding BasketAdminTest * PR #363 change request to include pagination * perf test test_assign_basket_strategy_call_frequency * perf test test_assign_basket_strategy_call_frequency and fix black linting * Refactoring for linter * Fixing test issue and add overriden decorator * Linter refactoring --- oscarapi/serializers/admin/basket.py | 13 +++ oscarapi/tests/unit/testadminapi.py | 1 + oscarapi/tests/unit/testbasket.py | 156 +++++++++++++++++++++++++++ oscarapi/urls.py | 17 +++ oscarapi/views/admin/basket.py | 60 +++++++++++ oscarapi/views/root.py | 1 + oscarapi/views/utils.py | 26 ++++- 7 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 oscarapi/serializers/admin/basket.py create mode 100644 oscarapi/views/admin/basket.py diff --git a/oscarapi/serializers/admin/basket.py b/oscarapi/serializers/admin/basket.py new file mode 100644 index 00000000..0af6793b --- /dev/null +++ b/oscarapi/serializers/admin/basket.py @@ -0,0 +1,13 @@ +from rest_framework import serializers + +from oscar.core.loading import get_model + +from oscarapi.utils.loading import get_api_class + + +Basket = get_model("basket", "Basket") +BasketSerializer = get_api_class("serializers.basket", "BasketSerializer") + + +class AdminBasketSerializer(BasketSerializer): + url = serializers.HyperlinkedIdentityField(view_name="admin-basket-detail") diff --git a/oscarapi/tests/unit/testadminapi.py b/oscarapi/tests/unit/testadminapi.py index 45f0c4b6..58c139f9 100644 --- a/oscarapi/tests/unit/testadminapi.py +++ b/oscarapi/tests/unit/testadminapi.py @@ -80,6 +80,7 @@ def test_root_view(self): "partners", "users", "attributeoptiongroups", + "baskets", ] for api in admin_apis: diff --git a/oscarapi/tests/unit/testbasket.py b/oscarapi/tests/unit/testbasket.py index cbbf62df..ae6bbad2 100644 --- a/oscarapi/tests/unit/testbasket.py +++ b/oscarapi/tests/unit/testbasket.py @@ -1,4 +1,5 @@ import json +from unittest import skipIf from unittest.mock import patch from django.test import override_settings @@ -9,6 +10,7 @@ from oscarapi.basket.operations import get_basket, get_user_basket from oscarapi.tests.utils import APITest +from oscarapi import settings Basket = get_model("basket", "Basket") @@ -1149,6 +1151,160 @@ def test_get_user_basket_with_multiple_baskets(self): self.assertEqual(user_basket, Basket.open.first()) +@skipIf(settings.BLOCK_ADMIN_API_ACCESS, "Admin API is enabled") +class BasketAdminTest(APITest): + """ + Test suite for admin basket list operations. + Covers access permissions, pagination, and ordering. + """ + + fixtures = [ + "product", + "productcategory", + "productattribute", + "productclass", + "productattributevalue", + "category", + "attributeoptiongroup", + "attributeoption", + "stockrecord", + "partner", + "option", + ] + + def test_basket_admin_list_access(self): + """ + Test access permissions for basket list view. + + Verifies that: + - Unauthenticated users are forbidden + - Standard users are forbidden + - Admin users can access the list + """ + url = reverse("admin-basket-list") + + # Test unauthenticated access + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + # Test standard user access + self.login("nobody", "nobody") + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + # Test admin access + self.login("admin", "admin") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_basket_admin_list_pagination(self): + """ + Test pagination functionality for basket list view. + + Checks: + - Default page size is 100 + - Custom page size works correctly + - Next page link is present + """ + + # Create baskets for testing + admin_user = User.objects.get(username="admin") + for _ in range(300): + Basket.objects.create(owner=admin_user) + + self.login("admin", "admin") + url = reverse("admin-basket-list") + + # Test first page pagination + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 100) + + # Check next link exists on first page + self.assertIsNotNone( + response.data["next"], "Next page link should be present on first page" + ) + + # Verify no previous link on first page + self.assertIsNone( + response.data["previous"], "First page should not have a previous link" + ) + + # Get the next page + next_page_url = response.data["next"] + next_page_response = self.client.get(next_page_url) + + self.assertEqual(next_page_response.status_code, 200) + self.assertEqual(len(next_page_response.data["results"]), 100) + + # Check links on second page + self.assertIsNotNone( + next_page_response.data["next"], + "Next page link should be present on second page", + ) + self.assertIsNotNone( + next_page_response.data["previous"], + "Second page should have a previous link", + ) + + # Test custom page size + response = self.client.get(f"{url}?page_size=5") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 5) + + def test_basket_admin_list_ordering(self): + """ + Test ordering of basket list view. + + Verifies that baskets are ordered by ID in descending order. + """ + # Create baskets for testing + admin_user = User.objects.get(username="admin") + for _ in range(200): + Basket.objects.create(owner=admin_user) + + self.login("admin", "admin") + url = reverse("admin-basket-list") + + # Fetch and verify ordering + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + basket_ids = [basket["id"] for basket in response.data["results"]] + self.assertEqual(basket_ids, sorted(basket_ids, reverse=True)) + + def test_assign_basket_strategy_call_frequency(self): + admin_user, _ = User.objects.get_or_create( + username="admin", defaults={"is_staff": True, "password": "admin"} + ) + total_baskets = 350 + + # Populate baskets for the test + Basket.objects.bulk_create( + [Basket(owner=admin_user) for _ in range(total_baskets)] + ) + + # Log in as admin + self.client.login(username="admin", password="admin") + + url = reverse("admin-basket-list") + + # Mock assign_basket_strategy and bypass serialization + with patch("oscarapi.views.admin.basket.assign_basket_strategy") as mock_assign: + with patch( + "oscarapi.serializers.basket.BasketSerializer.to_representation", + return_value={}, + ): + self.client.get(url) + + # Assert that the mock was called exactly 100 times instead of 350 + self.assertEqual( + mock_assign.call_count, + 100, + f"First page should have 100 assign_basket_strategy calls, got {mock_assign.call_count}", + ) + + @override_settings( MIDDLEWARE=( "django.middleware.common.CommonMiddleware", diff --git a/oscarapi/urls.py b/oscarapi/urls.py index 084843ea..edd9b328 100644 --- a/oscarapi/urls.py +++ b/oscarapi/urls.py @@ -149,6 +149,17 @@ ], ) +( + BasketAdminList, + BasketAdminDetail, +) = get_api_classes( + "views.admin.basket", + [ + "BasketAdminList", + "BasketAdminDetail", + ], +) + (UserAdminList, UserAdminDetail) = get_api_classes( "views.admin.user", ["UserAdminList", "UserAdminDetail"] ) @@ -238,6 +249,12 @@ ] admin_urlpatterns = [ + path("baskets/", BasketAdminList.as_view(), name="admin-basket-list"), + path( + "baskets//", + BasketAdminDetail.as_view(), + name="admin-basket-detail", + ), path("products/", ProductAdminList.as_view(), name="admin-product-list"), path( "products//", diff --git a/oscarapi/views/admin/basket.py b/oscarapi/views/admin/basket.py new file mode 100644 index 00000000..93e9db98 --- /dev/null +++ b/oscarapi/views/admin/basket.py @@ -0,0 +1,60 @@ +# pylint: disable=W0632 +import functools + +from rest_framework import generics + +from oscar.core.loading import get_model +from oscarapi.basket.operations import ( + assign_basket_strategy, + editable_baskets, +) + +from oscarapi.utils.loading import get_api_class +from oscarapi.views.utils import QuerySetList, CustomPageNumberPagination + +APIAdminPermission = get_api_class("permissions", "APIAdminPermission") +AdminBasketSerializer = get_api_class( + "serializers.admin.basket", "AdminBasketSerializer" +) +Basket = get_model("basket", "Basket") + + +class BasketAdminList(generics.ListCreateAPIView): + """ + List of all baskets for admin users + """ + + serializer_class = AdminBasketSerializer + pagination_class = CustomPageNumberPagination + permission_classes = (APIAdminPermission,) + + queryset = editable_baskets() + + def get_queryset(self): + qs = super(BasketAdminList, self).get_queryset() + qs = qs.order_by("-id") + return qs + + def list(self, request, *args, **kwargs): + qs = self.filter_queryset(self.get_queryset()) + page = self.paginate_queryset(qs) + + if page is not None: + mapped_with_baskets = list( + map( + functools.partial(assign_basket_strategy, request=self.request), + page, + ) + ) + serializer = self.get_serializer(mapped_with_baskets, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(qs, many=True) + return QuerySetList(mapped_with_baskets, qs) + + +class BasketAdminDetail(generics.RetrieveUpdateDestroyAPIView): + + queryset = Basket.objects.all() + serializer_class = AdminBasketSerializer + permission_classes = (APIAdminPermission,) diff --git a/oscarapi/views/root.py b/oscarapi/views/root.py index 002e08d2..f70c11f7 100644 --- a/oscarapi/views/root.py +++ b/oscarapi/views/root.py @@ -34,6 +34,7 @@ def PUBLIC_APIS(r, f): def ADMIN_APIS(r, f): return [ + ("baskets", reverse("admin-basket-list", request=r, format=f)), ("productclasses", reverse("admin-productclass-list", request=r, format=f)), ("products", reverse("admin-product-list", request=r, format=f)), ("categories", reverse("admin-category-list", request=r, format=f)), diff --git a/oscarapi/views/utils.py b/oscarapi/views/utils.py index dd23ca1f..d269732d 100644 --- a/oscarapi/views/utils.py +++ b/oscarapi/views/utils.py @@ -1,10 +1,11 @@ from django.core.exceptions import ValidationError from oscar.core.loading import get_model - from oscarapi import permissions from rest_framework import exceptions, generics +from rest_framework.utils.urls import replace_query_param +from rest_framework.pagination import PageNumberPagination from rest_framework.relations import HyperlinkedRelatedField __all__ = ("BasketPermissionMixin",) @@ -54,3 +55,26 @@ def check_basket_permission(self, request, basket_pk=None, basket=None): basket = generics.get_object_or_404(Basket.objects, pk=basket_pk) self.check_object_permissions(request, basket) return basket + + +class CustomPageNumberPagination(PageNumberPagination): + page_size = 100 + page_size_query_param = "page_size" + max_page_size = 10000 + page_query_param = "page" + + def get_next_link(self): + + if not self.page.has_next(): + return None + url = self.request.build_absolute_uri() + page_number = self.page.next_page_number() + + return replace_query_param(url, self.page_query_param, page_number) + + def get_previous_link(self): + if not self.page.has_previous(): + return None + url = self.request.build_absolute_uri() + page_number = self.page.previous_page_number() + return replace_query_param(url, self.page_query_param, page_number)