Skip to content

Commit c736ce3

Browse files
authored
Fixes #18900: raise QuerySetNotOrdered exception when trying to paginate unordered API querysets (#19943)
* Fixes #18900: introduce/raise QuerySetNotOrdered exception Defines a new exception, `QuerySetNotOrdered`, and raises it in `OptionalLimitOffsetPagination.paginate_queryset` in the right conditions: - the iterable to be paginated is a QuerySet isinstance - the `queryset.ordered` flag is not truthy * Don't try to reapply ordering if ordering is already present * Add ordering for failing tagged-objects list API endpoint I chose to implement this here for TaggedItemViewSet, rather than on the model, because any meaningful ordering is going to be done on the related Tag instance and I didn't want to introduce potential, not well understood side-effects by applying a model-wide ordering via a related model field. * Add default Token ordering behavior * Adds basic tests for raising QuerySetNotOrdered * Note why ordering is not applied in TaggedItem.Meta
1 parent 111fefd commit c736ce3

File tree

8 files changed

+80
-1
lines changed

8 files changed

+80
-1
lines changed

netbox/extras/api/views.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,9 @@ class TagViewSet(NetBoxModelViewSet):
185185

186186

187187
class TaggedItemViewSet(RetrieveModelMixin, ListModelMixin, BaseViewSet):
188-
queryset = TaggedItem.objects.prefetch_related('content_type', 'content_object', 'tag')
188+
queryset = TaggedItem.objects.prefetch_related(
189+
'content_type', 'content_object', 'tag'
190+
).order_by('tag__weight', 'tag__name')
189191
serializer_class = serializers.TaggedItemSerializer
190192
filterset_class = filtersets.TaggedItemFilterSet
191193

netbox/extras/models/tags.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,6 @@ class Meta:
8383
indexes = [models.Index(fields=["content_type", "object_id"])]
8484
verbose_name = _('tagged item')
8585
verbose_name_plural = _('tagged items')
86+
# Note: while there is no ordering applied here (because it would basically be done on fields
87+
# of the related `tag`), there is an ordering applied to extras.api.views.TaggedItemViewSet
88+
# to allow for proper pagination.

netbox/netbox/api/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,7 @@ class SerializerNotFound(Exception):
1212

1313
class GraphQLTypeNotFound(Exception):
1414
pass
15+
16+
17+
class QuerySetNotOrdered(Exception):
18+
pass

netbox/netbox/api/pagination.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.db.models import QuerySet
22
from rest_framework.pagination import LimitOffsetPagination
33

4+
from netbox.api.exceptions import QuerySetNotOrdered
45
from netbox.config import get_config
56

67

@@ -15,6 +16,12 @@ def __init__(self):
1516

1617
def paginate_queryset(self, queryset, request, view=None):
1718

19+
if isinstance(queryset, QuerySet) and not queryset.ordered:
20+
raise QuerySetNotOrdered(
21+
"Paginating over an unordered queryset is unreliable. Ensure that a minimal "
22+
"ordering has been applied to the queryset for this API endpoint."
23+
)
24+
1825
if isinstance(queryset, QuerySet):
1926
self.count = self.get_queryset_count(queryset)
2027
else:

netbox/netbox/tests/test_api.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import uuid
22

3+
from django.test import RequestFactory, TestCase
34
from django.urls import reverse
5+
from rest_framework.request import Request
46

7+
from netbox.api.exceptions import QuerySetNotOrdered
8+
from netbox.api.pagination import OptionalLimitOffsetPagination
59
from utilities.testing import APITestCase
10+
from users.models import Token
611

712

813
class AppTest(APITestCase):
@@ -26,3 +31,40 @@ def test_status(self):
2631
response = self.client.get(f'{url}?format=api', **self.header)
2732

2833
self.assertEqual(response.status_code, 200)
34+
35+
36+
class OptionalLimitOffsetPaginationTest(TestCase):
37+
38+
def setUp(self):
39+
self.paginator = OptionalLimitOffsetPagination()
40+
self.factory = RequestFactory()
41+
42+
def _make_drf_request(self, path='/', query_params=None):
43+
"""Helper to create a proper DRF Request object"""
44+
return Request(self.factory.get(path, query_params or {}))
45+
46+
def test_raises_exception_for_unordered_queryset(self):
47+
"""Should raise QuerySetNotOrdered for unordered QuerySet"""
48+
queryset = Token.objects.all().order_by()
49+
request = self._make_drf_request()
50+
51+
with self.assertRaises(QuerySetNotOrdered) as cm:
52+
self.paginator.paginate_queryset(queryset, request)
53+
54+
error_msg = str(cm.exception)
55+
self.assertIn("Paginating over an unordered queryset is unreliable", error_msg)
56+
self.assertIn("Ensure that a minimal ordering has been applied", error_msg)
57+
58+
def test_allows_ordered_queryset(self):
59+
"""Should not raise exception for ordered QuerySet"""
60+
queryset = Token.objects.all().order_by('created')
61+
request = self._make_drf_request()
62+
63+
self.paginator.paginate_queryset(queryset, request) # Should not raise exception
64+
65+
def test_allows_non_queryset_iterables(self):
66+
"""Should not raise exception for non-QuerySet iterables"""
67+
iterable = [1, 2, 3, 4, 5]
68+
request = self._make_drf_request()
69+
70+
self.paginator.paginate_queryset(iterable, request) # Should not raise exception
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Generated by Django 5.2.4 on 2025-07-23 17:28
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('users', '0009_update_group_perms'),
10+
]
11+
12+
operations = [
13+
migrations.AlterModelOptions(
14+
name='token',
15+
options={'ordering': ('-created',)},
16+
),
17+
]

netbox/users/models/tokens.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ class Token(models.Model):
7474
class Meta:
7575
verbose_name = _('token')
7676
verbose_name_plural = _('tokens')
77+
ordering = ('-created',)
7778

7879
def __str__(self):
7980
return self.key if settings.ALLOW_TOKEN_RETRIEVAL else self.partial

netbox/utilities/query.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,5 +67,8 @@ def reapply_model_ordering(queryset: QuerySet) -> QuerySet:
6767
# MPTT-based models are exempt from this; use caution when annotating querysets of these models
6868
if any(isinstance(manager, TreeManager) for manager in queryset.model._meta.local_managers):
6969
return queryset
70+
elif queryset.ordered:
71+
return queryset
72+
7073
ordering = queryset.model._meta.ordering
7174
return queryset.order_by(*ordering)

0 commit comments

Comments
 (0)