diff --git a/docs/developer/other-utilities.rst b/docs/developer/other-utilities.rst index e35b8c18..0b31b6a7 100644 --- a/docs/developer/other-utilities.rst +++ b/docs/developer/other-utilities.rst @@ -83,6 +83,45 @@ Every openwisp module which has an API should use this class to configure its own default settings, which will be merged with the settings of the other modules. +``openwisp_utils.api.pagination.OpenWispPagination`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A reusable pagination class for DRF views that provides consistent +pagination behavior across OpenWISP modules with sensible defaults. + +- **10** items per page (``page_size``) +- **100** max items per page (``max_page_size``) +- Custom page size via ``?page_size=N`` query parameter + +**Usage in Views:** + +.. code-block:: python + + from openwisp_utils.api.pagination import OpenWispPagination + from rest_framework.viewsets import ModelViewSet + + + class DeviceViewSet(ModelViewSet): + queryset = Device.objects.all() + serializer_class = DeviceSerializer + pagination_class = OpenWispPagination + +**API Request Examples:** + +.. code-block:: bash + + # Returns first 10 items (default page size) + GET /api/v1/controller/devices/ + + # Returns items 11-20 (second page) + GET /api/v1/controller/devices/?page=2 + + # Returns first 25 items (custom page size) + GET /api/v1/controller/devices/?page_size=25 + + # Returns items 26-50 with custom page size + GET /api/v1/controller/devices/?page=2&page_size=25 + Storage Utilities ----------------- diff --git a/openwisp_utils/api/pagination.py b/openwisp_utils/api/pagination.py new file mode 100644 index 00000000..495d0c0c --- /dev/null +++ b/openwisp_utils/api/pagination.py @@ -0,0 +1,17 @@ +from django.core.exceptions import ImproperlyConfigured + +try: + from rest_framework.pagination import PageNumberPagination +except ImportError: # pragma: nocover + raise ImproperlyConfigured( + "Django REST Framework is required to use " + "this feature but it is not installed" + ) + + +class OpenWispPagination(PageNumberPagination): + """Reusable pagination class with sensible defaults.""" + + page_size = 10 + max_page_size = 100 + page_size_query_param = "page_size" diff --git a/tests/test_project/api/urls.py b/tests/test_project/api/urls.py index 0fdc80ae..127b0643 100644 --- a/tests/test_project/api/urls.py +++ b/tests/test_project/api/urls.py @@ -1,11 +1,16 @@ from django.urls import re_path +from rest_framework.routers import DefaultRouter from . import views +router = DefaultRouter() +router.register(r"shelves", views.ShelfViewSet, basename="shelf") + urlpatterns = [ re_path( r"^receive_project/(?P[^/\?]+)/$", views.receive_project, name="receive_project", - ) + ), + *router.urls, ] diff --git a/tests/test_project/api/views.py b/tests/test_project/api/views.py index c73c1afb..d2e73e3d 100644 --- a/tests/test_project/api/views.py +++ b/tests/test_project/api/views.py @@ -1,8 +1,11 @@ from django.http import JsonResponse from django.utils.translation import gettext_lazy as _ from django.views import View +from openwisp_utils.api.pagination import OpenWispPagination +from rest_framework import viewsets -from ..models import Project +from ..models import Project, Shelf +from ..serializers import ShelfSerializer class ReceiveProjectView(View): @@ -24,3 +27,11 @@ def get(self, request, pk): receive_project = ReceiveProjectView.as_view() + + +class ShelfViewSet(viewsets.ModelViewSet): + """ViewSet for Shelf model with OpenWispPagination.""" + + queryset = Shelf.objects.all() + serializer_class = ShelfSerializer + pagination_class = OpenWispPagination diff --git a/tests/test_project/tests/test_api.py b/tests/test_project/tests/test_api.py index a862d0b5..e512f2c3 100644 --- a/tests/test_project/tests/test_api.py +++ b/tests/test_project/tests/test_api.py @@ -1,6 +1,8 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.test import TestCase +from openwisp_utils.api.pagination import OpenWispPagination +from rest_framework.pagination import PageNumberPagination from test_project.serializers import ShelfSerializer from ..models import Shelf @@ -64,3 +66,78 @@ def test_rest_framework_settings_override(self): "TEST": True, }, ) + + +class TestOpenWispPagination(CreateMixin, TestCase): + shelf_model = Shelf + + def setUp(self): + super().setUp() + self.url = "/api/v1/shelves/" + # Create 21 shelves to test pagination across multiple pages + for i in range(21): + self._create_shelf(name=f"shelf{i}") + + def test_list_shelf_api_pagination(self): + """Test shelf list API with default pagination.""" + number_of_shelves = 21 + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], number_of_shelves) + self.assertIsNotNone(response.data["next"]) + self.assertIn("page=2", response.data["next"]) + self.assertIsNone(response.data["previous"]) + self.assertEqual(len(response.data["results"]), 10) + + next_response = self.client.get(response.data["next"]) + self.assertEqual(next_response.status_code, 200) + self.assertEqual(next_response.data["count"], number_of_shelves) + self.assertIsNotNone(next_response.data["next"]) + self.assertIn("page=3", next_response.data["next"]) + self.assertIsNotNone(next_response.data["previous"]) + # Page 1 is the default, so DRF doesn't include page=1 in the previous URL + self.assertIn(self.url, next_response.data["previous"]) + self.assertEqual(len(next_response.data["results"]), 10) + + third_response = self.client.get(next_response.data["next"]) + self.assertEqual(third_response.status_code, 200) + self.assertEqual(third_response.data["count"], number_of_shelves) + self.assertIsNone(third_response.data["next"]) + self.assertIsNotNone(third_response.data["previous"]) + self.assertIn("page=2", third_response.data["previous"]) + self.assertEqual(len(third_response.data["results"]), 1) + + def test_list_shelf_api_custom_page_size(self): + """Test shelf list API with custom page_size parameter.""" + number_of_shelves = 21 + page_size = 5 + url_with_page_size = f"{self.url}?page_size={page_size}" + + response = self.client.get(url_with_page_size) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], number_of_shelves) + self.assertIsNotNone(response.data["next"]) + self.assertIn(f"page_size={page_size}", response.data["next"]) + self.assertIn("page=2", response.data["next"]) + self.assertIsNone(response.data["previous"]) + self.assertEqual(len(response.data["results"]), page_size) + + next_response = self.client.get(response.data["next"]) + self.assertEqual(next_response.status_code, 200) + self.assertEqual(next_response.data["count"], number_of_shelves) + self.assertIsNotNone(next_response.data["next"]) + self.assertIn(f"page_size={page_size}", next_response.data["next"]) + self.assertIn("page=3", next_response.data["next"]) + self.assertIsNotNone(next_response.data["previous"]) + self.assertIn(f"page_size={page_size}", next_response.data["previous"]) + # Page 1 is the default, so DRF doesn't include page=1 in the previous URL + self.assertIn(url_with_page_size, next_response.data["previous"]) + self.assertEqual(len(next_response.data["results"]), page_size) + + def test_pagination_attributes(self): + """Test OpenWispPagination class attributes.""" + pagination = OpenWispPagination() + self.assertIsInstance(pagination, PageNumberPagination) + self.assertEqual(pagination.page_size, 10) + self.assertEqual(pagination.max_page_size, 100) + self.assertEqual(pagination.page_size_query_param, "page_size")