From f55e8b91e9b490ece65554af0ea208f8b291e815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Sun, 2 Nov 2025 14:51:58 +0300 Subject: [PATCH 1/4] Move setuptools to testing requirements It was defined in testenv.deps for tox, which makes it a literal testing requirement The version requirement is copied from pyproject.toml --- requirements/requirements-testing.txt | 1 + tox.ini | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index b1e3c82eca..a3821a5089 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -6,3 +6,4 @@ importlib-metadata<5.0 # temporary pin of attrs attrs==22.1.0 pytz # Remove when dropping support for Django<5.0 +setuptools>=77.0.3 diff --git a/tox.ini b/tox.ini index cbaaf159e5..01a959455c 100644 --- a/tox.ini +++ b/tox.ini @@ -23,7 +23,6 @@ deps = djangomain: https://github.com/django/django/archive/main.tar.gz -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt - setuptools [testenv:base] ; Ensure optional dependencies are not required From 932390581ee5883b89e668a6c64c4e455aafa462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Sun, 2 Nov 2025 14:00:11 +0300 Subject: [PATCH 2/4] Enforce ordering only when queryset is not ordered --- rest_framework/pagination.py | 9 +++++---- tests/test_pagination.py | 5 +++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index b6329b8c3a..bd55ea2d11 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -624,10 +624,11 @@ def paginate_queryset(self, queryset, request, view=None): (offset, reverse, current_position) = self.cursor # Cursor pagination always enforces an ordering. - if reverse: - queryset = queryset.order_by(*_reverse_ordering(self.ordering)) - else: - queryset = queryset.order_by(*self.ordering) + if not queryset.ordered: + if reverse: + queryset = queryset.order_by(*_reverse_ordering(self.ordering)) + else: + queryset = queryset.order_by(*self.ordering) # If we have a cursor with a fixed position then filter by that. if current_position is not None: diff --git a/tests/test_pagination.py b/tests/test_pagination.py index d8f66e95bc..63b897def7 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -969,8 +969,9 @@ def __init__(self, idx): self.created = idx class MockQuerySet: - def __init__(self, items): + def __init__(self, items, ordered=False): self.items = items + self.ordered = ordered def filter(self, created__gt=None, created__lt=None): if created__gt is not None: @@ -987,7 +988,7 @@ def filter(self, created__gt=None, created__lt=None): def order_by(self, *ordering): if ordering[0].startswith('-'): - return MockQuerySet(list(reversed(self.items))) + return MockQuerySet(list(reversed(self.items)), ordered=True) return self def __getitem__(self, sliced): From 24b265217e7fc811d2ac1a5316f0d5ba1291bd87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Sun, 2 Nov 2025 14:05:19 +0300 Subject: [PATCH 3/4] Remove ordering filter class handling These filters are already applied by GenericAPIView.filter_queryset() --- rest_framework/pagination.py | 20 ++------------------ tests/test_pagination.py | 36 ------------------------------------ 2 files changed, 2 insertions(+), 54 deletions(-) diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index bd55ea2d11..9e68cdfd1e 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -615,7 +615,7 @@ def paginate_queryset(self, queryset, request, view=None): return None self.base_url = request.build_absolute_uri() - self.ordering = self.get_ordering(request, queryset, view) + self.ordering = self.get_ordering() self.cursor = self.decode_cursor(request) if self.cursor is None: @@ -802,28 +802,12 @@ def get_previous_link(self): cursor = Cursor(offset=offset, reverse=True, position=position) return self.encode_cursor(cursor) - def get_ordering(self, request, queryset, view): + def get_ordering(self): """ Return a tuple of strings, that may be used in an `order_by` method. """ - # The default case is to check for an `ordering` attribute - # on this pagination instance. ordering = self.ordering - ordering_filters = [ - filter_cls for filter_cls in getattr(view, 'filter_backends', []) - if hasattr(filter_cls, 'get_ordering') - ] - - if ordering_filters: - # If a filter exists on the view that implements `get_ordering` - # then we defer to that filter to determine the ordering. - filter_cls = ordering_filters[0] - filter_instance = filter_cls() - ordering_from_filter = filter_instance.get_ordering(request, queryset, view) - if ordering_from_filter: - ordering = ordering_from_filter - assert ordering is not None, ( 'Using cursor pagination, but no ordering attribute was declared ' 'on the pagination class.' diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 63b897def7..af8e82b6b3 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -616,42 +616,6 @@ def test_invalid_cursor(self): with pytest.raises(exceptions.NotFound): self.pagination.paginate_queryset(self.queryset, request) - def test_use_with_ordering_filter(self): - class MockView: - filter_backends = (filters.OrderingFilter,) - ordering_fields = ['username', 'created'] - ordering = 'created' - - request = Request(factory.get('/', {'ordering': 'username'})) - ordering = self.pagination.get_ordering(request, [], MockView()) - assert ordering == ('username',) - - request = Request(factory.get('/', {'ordering': '-username'})) - ordering = self.pagination.get_ordering(request, [], MockView()) - assert ordering == ('-username',) - - request = Request(factory.get('/', {'ordering': 'invalid'})) - ordering = self.pagination.get_ordering(request, [], MockView()) - assert ordering == ('created',) - - def test_use_with_ordering_filter_without_ordering_default_value(self): - class MockView: - filter_backends = (filters.OrderingFilter,) - ordering_fields = ['username', 'created'] - - request = Request(factory.get('/')) - ordering = self.pagination.get_ordering(request, [], MockView()) - # it gets the value of `ordering` provided by CursorPagination - assert ordering == ('created',) - - request = Request(factory.get('/', {'ordering': 'username'})) - ordering = self.pagination.get_ordering(request, [], MockView()) - assert ordering == ('username',) - - request = Request(factory.get('/', {'ordering': 'invalid'})) - ordering = self.pagination.get_ordering(request, [], MockView()) - assert ordering == ('created',) - def test_cursor_pagination(self): (previous, current, next, previous_url, next_url) = self.get_pages('/') From e03bd60426c9c11f57082b58d237cce3cf3128e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Tue, 4 Nov 2025 14:41:59 +0300 Subject: [PATCH 4/4] Return queryset's ordering from .get_ordering() --- rest_framework/pagination.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index 9e68cdfd1e..2bf51b1d6e 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -11,6 +11,7 @@ from django.core.paginator import InvalidPage from django.core.paginator import Paginator as DjangoPaginator +from django.db.models import OrderBy from django.template import loader from django.utils.encoding import force_str from django.utils.translation import gettext_lazy as _ @@ -615,7 +616,7 @@ def paginate_queryset(self, queryset, request, view=None): return None self.base_url = request.build_absolute_uri() - self.ordering = self.get_ordering() + self.ordering = self.get_ordering(queryset) self.cursor = self.decode_cursor(request) if self.cursor is None: @@ -802,10 +803,36 @@ def get_previous_link(self): cursor = Cursor(offset=offset, reverse=True, position=position) return self.encode_cursor(cursor) - def get_ordering(self): + def get_ordering_from_queryset(self, queryset): + if not queryset.ordered: + return False + + qs_ordering = queryset.query.order_by + + # Fallback to model's Meta ordering if no order_by is given + if not qs_ordering: + qs_ordering = queryset.query.get_meta().ordering + + ordering = [] + for expr in qs_ordering: + if isinstance(expr, str): + ordering.append(expr) + + elif isinstance(expr, OrderBy): + field_name = expr.expression.name + descending = expr.descending + ordering.append(f"{'-' if descending else ''}{field_name}") + + return ordering + + def get_ordering(self, queryset): """ Return a tuple of strings, that may be used in an `order_by` method. """ + # Return the ordering value from the queryset if it has one. + if queryset.ordered: + return self.get_ordering_from_queryset(queryset) + ordering = self.ordering assert ordering is not None, (