mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-28 10:16:10 -06:00
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
This commit is contained in:
parent
111fefdf9c
commit
c736ce3179
@ -185,7 +185,9 @@ class TagViewSet(NetBoxModelViewSet):
|
|||||||
|
|
||||||
|
|
||||||
class TaggedItemViewSet(RetrieveModelMixin, ListModelMixin, BaseViewSet):
|
class TaggedItemViewSet(RetrieveModelMixin, ListModelMixin, BaseViewSet):
|
||||||
queryset = TaggedItem.objects.prefetch_related('content_type', 'content_object', 'tag')
|
queryset = TaggedItem.objects.prefetch_related(
|
||||||
|
'content_type', 'content_object', 'tag'
|
||||||
|
).order_by('tag__weight', 'tag__name')
|
||||||
serializer_class = serializers.TaggedItemSerializer
|
serializer_class = serializers.TaggedItemSerializer
|
||||||
filterset_class = filtersets.TaggedItemFilterSet
|
filterset_class = filtersets.TaggedItemFilterSet
|
||||||
|
|
||||||
|
@ -83,3 +83,6 @@ class TaggedItem(GenericTaggedItemBase):
|
|||||||
indexes = [models.Index(fields=["content_type", "object_id"])]
|
indexes = [models.Index(fields=["content_type", "object_id"])]
|
||||||
verbose_name = _('tagged item')
|
verbose_name = _('tagged item')
|
||||||
verbose_name_plural = _('tagged items')
|
verbose_name_plural = _('tagged items')
|
||||||
|
# Note: while there is no ordering applied here (because it would basically be done on fields
|
||||||
|
# of the related `tag`), there is an ordering applied to extras.api.views.TaggedItemViewSet
|
||||||
|
# to allow for proper pagination.
|
||||||
|
@ -12,3 +12,7 @@ class SerializerNotFound(Exception):
|
|||||||
|
|
||||||
class GraphQLTypeNotFound(Exception):
|
class GraphQLTypeNotFound(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class QuerySetNotOrdered(Exception):
|
||||||
|
pass
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from rest_framework.pagination import LimitOffsetPagination
|
from rest_framework.pagination import LimitOffsetPagination
|
||||||
|
|
||||||
|
from netbox.api.exceptions import QuerySetNotOrdered
|
||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
|
|
||||||
|
|
||||||
@ -15,6 +16,12 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
|||||||
|
|
||||||
def paginate_queryset(self, queryset, request, view=None):
|
def paginate_queryset(self, queryset, request, view=None):
|
||||||
|
|
||||||
|
if isinstance(queryset, QuerySet) and not queryset.ordered:
|
||||||
|
raise QuerySetNotOrdered(
|
||||||
|
"Paginating over an unordered queryset is unreliable. Ensure that a minimal "
|
||||||
|
"ordering has been applied to the queryset for this API endpoint."
|
||||||
|
)
|
||||||
|
|
||||||
if isinstance(queryset, QuerySet):
|
if isinstance(queryset, QuerySet):
|
||||||
self.count = self.get_queryset_count(queryset)
|
self.count = self.get_queryset_count(queryset)
|
||||||
else:
|
else:
|
||||||
|
@ -1,8 +1,13 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from django.test import RequestFactory, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from rest_framework.request import Request
|
||||||
|
|
||||||
|
from netbox.api.exceptions import QuerySetNotOrdered
|
||||||
|
from netbox.api.pagination import OptionalLimitOffsetPagination
|
||||||
from utilities.testing import APITestCase
|
from utilities.testing import APITestCase
|
||||||
|
from users.models import Token
|
||||||
|
|
||||||
|
|
||||||
class AppTest(APITestCase):
|
class AppTest(APITestCase):
|
||||||
@ -26,3 +31,40 @@ class AppTest(APITestCase):
|
|||||||
response = self.client.get(f'{url}?format=api', **self.header)
|
response = self.client.get(f'{url}?format=api', **self.header)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
class OptionalLimitOffsetPaginationTest(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.paginator = OptionalLimitOffsetPagination()
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
|
||||||
|
def _make_drf_request(self, path='/', query_params=None):
|
||||||
|
"""Helper to create a proper DRF Request object"""
|
||||||
|
return Request(self.factory.get(path, query_params or {}))
|
||||||
|
|
||||||
|
def test_raises_exception_for_unordered_queryset(self):
|
||||||
|
"""Should raise QuerySetNotOrdered for unordered QuerySet"""
|
||||||
|
queryset = Token.objects.all().order_by()
|
||||||
|
request = self._make_drf_request()
|
||||||
|
|
||||||
|
with self.assertRaises(QuerySetNotOrdered) as cm:
|
||||||
|
self.paginator.paginate_queryset(queryset, request)
|
||||||
|
|
||||||
|
error_msg = str(cm.exception)
|
||||||
|
self.assertIn("Paginating over an unordered queryset is unreliable", error_msg)
|
||||||
|
self.assertIn("Ensure that a minimal ordering has been applied", error_msg)
|
||||||
|
|
||||||
|
def test_allows_ordered_queryset(self):
|
||||||
|
"""Should not raise exception for ordered QuerySet"""
|
||||||
|
queryset = Token.objects.all().order_by('created')
|
||||||
|
request = self._make_drf_request()
|
||||||
|
|
||||||
|
self.paginator.paginate_queryset(queryset, request) # Should not raise exception
|
||||||
|
|
||||||
|
def test_allows_non_queryset_iterables(self):
|
||||||
|
"""Should not raise exception for non-QuerySet iterables"""
|
||||||
|
iterable = [1, 2, 3, 4, 5]
|
||||||
|
request = self._make_drf_request()
|
||||||
|
|
||||||
|
self.paginator.paginate_queryset(iterable, request) # Should not raise exception
|
||||||
|
17
netbox/users/migrations/0010_add_token_meta_ordering.py
Normal file
17
netbox/users/migrations/0010_add_token_meta_ordering.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-07-23 17:28
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '0009_update_group_perms'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='token',
|
||||||
|
options={'ordering': ('-created',)},
|
||||||
|
),
|
||||||
|
]
|
@ -74,6 +74,7 @@ class Token(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _('token')
|
verbose_name = _('token')
|
||||||
verbose_name_plural = _('tokens')
|
verbose_name_plural = _('tokens')
|
||||||
|
ordering = ('-created',)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.key if settings.ALLOW_TOKEN_RETRIEVAL else self.partial
|
return self.key if settings.ALLOW_TOKEN_RETRIEVAL else self.partial
|
||||||
|
@ -67,5 +67,8 @@ def reapply_model_ordering(queryset: QuerySet) -> QuerySet:
|
|||||||
# MPTT-based models are exempt from this; use caution when annotating querysets of these models
|
# MPTT-based models are exempt from this; use caution when annotating querysets of these models
|
||||||
if any(isinstance(manager, TreeManager) for manager in queryset.model._meta.local_managers):
|
if any(isinstance(manager, TreeManager) for manager in queryset.model._meta.local_managers):
|
||||||
return queryset
|
return queryset
|
||||||
|
elif queryset.ordered:
|
||||||
|
return queryset
|
||||||
|
|
||||||
ordering = queryset.model._meta.ordering
|
ordering = queryset.model._meta.ordering
|
||||||
return queryset.order_by(*ordering)
|
return queryset.order_by(*ordering)
|
||||||
|
Loading…
Reference in New Issue
Block a user