|
@@ -1,18 +1,40 @@
|
|
|
from django.db.models import QuerySet
|
|
from django.db.models import QuerySet
|
|
|
|
|
+from django.utils.translation import gettext_lazy as _
|
|
|
|
|
+from rest_framework.exceptions import ValidationError
|
|
|
from rest_framework.pagination import LimitOffsetPagination
|
|
from rest_framework.pagination import LimitOffsetPagination
|
|
|
|
|
+from rest_framework.utils.urls import remove_query_param, replace_query_param
|
|
|
|
|
|
|
|
from netbox.api.exceptions import QuerySetNotOrdered
|
|
from netbox.api.exceptions import QuerySetNotOrdered
|
|
|
from netbox.config import get_config
|
|
from netbox.config import get_config
|
|
|
|
|
|
|
|
|
|
|
|
|
-class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
|
|
|
|
|
|
+class NetBoxPagination(LimitOffsetPagination):
|
|
|
"""
|
|
"""
|
|
|
- Override the stock paginator to allow setting limit=0 to disable pagination for a request. This returns all objects
|
|
|
|
|
- matching a query, but retains the same format as a paginated request. The limit can only be disabled if
|
|
|
|
|
- MAX_PAGE_SIZE has been set to 0 or None.
|
|
|
|
|
|
|
+ Provides two mutually exclusive pagination mechanisms: offset-based and cursor-based.
|
|
|
|
|
+
|
|
|
|
|
+ Offset-based pagination employs `offset` and (optionally) `limit` parameters to page through results following the
|
|
|
|
|
+ model's natural order. `offset` indicates the number of results to skip. This provides very human-friendly behavior,
|
|
|
|
|
+ but performance can suffer when querying very large data sets due the overhead required to determine the starting
|
|
|
|
|
+ point in the database.
|
|
|
|
|
+
|
|
|
|
|
+ Cursor-based pagination employs `start` and (optionally) `limit` parameters to page through results as ordered by
|
|
|
|
|
+ the model's primary key (i.e. `id`). `start` indicates the numeric ID of the first object to return; `limit`
|
|
|
|
|
+ indicates the maximum number of objects to return beginning with the specified ID. Objects *must* be ordered by ID
|
|
|
|
|
+ to ensure pagination is consistent. This approach is less human-friendly but offers superior performance to
|
|
|
|
|
+ offset-based pagination. In cursor mode, `count` is omitted (null) for performance.
|
|
|
|
|
+
|
|
|
|
|
+ Offset- and cursor-based pagination are mutually exclusive: Only `offset` _or_ `start` is permitted for a request.
|
|
|
|
|
+
|
|
|
|
|
+ `limit` may be set to zero (`?limit=0`). This returns all objects matching a query, but retains the same format as
|
|
|
|
|
+ a paginated request. The limit can only be disabled if `MAX_PAGE_SIZE` has been set to 0 or None.
|
|
|
"""
|
|
"""
|
|
|
|
|
+ start_query_param = 'start'
|
|
|
|
|
+
|
|
|
def __init__(self):
|
|
def __init__(self):
|
|
|
self.default_limit = get_config().PAGINATE_COUNT
|
|
self.default_limit = get_config().PAGINATE_COUNT
|
|
|
|
|
+ self.start = None
|
|
|
|
|
+ self._page_length = 0
|
|
|
|
|
+ self._last_pk = None
|
|
|
|
|
|
|
|
def paginate_queryset(self, queryset, request, view=None):
|
|
def paginate_queryset(self, queryset, request, view=None):
|
|
|
|
|
|
|
@@ -22,15 +44,42 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
|
|
"ordering has been applied to the queryset for this API endpoint."
|
|
"ordering has been applied to the queryset for this API endpoint."
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+ self.start = self.get_start(request)
|
|
|
|
|
+ self.limit = self.get_limit(request)
|
|
|
|
|
+ self.request = request
|
|
|
|
|
+
|
|
|
|
|
+ # Cursor-based pagination
|
|
|
|
|
+ if self.start is not None:
|
|
|
|
|
+ if self.offset_query_param in request.query_params:
|
|
|
|
|
+ raise ValidationError(
|
|
|
|
|
+ _("'{start_param}' and '{offset_param}' are mutually exclusive.").format(
|
|
|
|
|
+ start_param=self.start_query_param,
|
|
|
|
|
+ offset_param=self.offset_query_param,
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ if 'ordering' in request.query_params:
|
|
|
|
|
+ raise ValidationError(_("Ordering cannot be specified in conjunction with cursor-based pagination."))
|
|
|
|
|
+
|
|
|
|
|
+ self.count = None
|
|
|
|
|
+ self.offset = 0
|
|
|
|
|
+
|
|
|
|
|
+ queryset = queryset.filter(pk__gte=self.start).order_by('pk')
|
|
|
|
|
+ results = list(queryset[:self.limit]) if self.limit else list(queryset)
|
|
|
|
|
+
|
|
|
|
|
+ self._page_length = len(results)
|
|
|
|
|
+ if results:
|
|
|
|
|
+ self._last_pk = results[-1].pk if hasattr(results[-1], 'pk') else results[-1]['pk']
|
|
|
|
|
+
|
|
|
|
|
+ return results
|
|
|
|
|
+
|
|
|
|
|
+ # Offset-based pagination
|
|
|
if isinstance(queryset, QuerySet):
|
|
if isinstance(queryset, QuerySet):
|
|
|
self.count = self.get_queryset_count(queryset)
|
|
self.count = self.get_queryset_count(queryset)
|
|
|
else:
|
|
else:
|
|
|
# We're dealing with an iterable, not a QuerySet
|
|
# We're dealing with an iterable, not a QuerySet
|
|
|
self.count = len(queryset)
|
|
self.count = len(queryset)
|
|
|
|
|
|
|
|
- self.limit = self.get_limit(request)
|
|
|
|
|
self.offset = self.get_offset(request)
|
|
self.offset = self.get_offset(request)
|
|
|
- self.request = request
|
|
|
|
|
|
|
|
|
|
if self.limit and self.count > self.limit and self.template is not None:
|
|
if self.limit and self.count > self.limit and self.template is not None:
|
|
|
self.display_page_controls = True
|
|
self.display_page_controls = True
|
|
@@ -42,6 +91,25 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
|
|
return list(queryset[self.offset:self.offset + self.limit])
|
|
return list(queryset[self.offset:self.offset + self.limit])
|
|
|
return list(queryset[self.offset:])
|
|
return list(queryset[self.offset:])
|
|
|
|
|
|
|
|
|
|
+ def get_start(self, request):
|
|
|
|
|
+ try:
|
|
|
|
|
+ value = int(request.query_params[self.start_query_param])
|
|
|
|
|
+ if value < 0:
|
|
|
|
|
+ raise ValidationError(
|
|
|
|
|
+ _("Invalid '{param}' parameter: must be a non-negative integer.").format(
|
|
|
|
|
+ param=self.start_query_param,
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ return value
|
|
|
|
|
+ except KeyError:
|
|
|
|
|
+ return None
|
|
|
|
|
+ except (ValueError, TypeError):
|
|
|
|
|
+ raise ValidationError(
|
|
|
|
|
+ _("Invalid '{param}' parameter: must be a non-negative integer.").format(
|
|
|
|
|
+ param=self.start_query_param,
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
def get_limit(self, request):
|
|
def get_limit(self, request):
|
|
|
max_limit = self.default_limit
|
|
max_limit = self.default_limit
|
|
|
MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
|
|
MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
|
|
@@ -75,6 +143,16 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
|
|
if not self.limit:
|
|
if not self.limit:
|
|
|
return None
|
|
return None
|
|
|
|
|
|
|
|
|
|
+ # Cursor mode
|
|
|
|
|
+ if self.start is not None:
|
|
|
|
|
+ if self._page_length < self.limit:
|
|
|
|
|
+ return None
|
|
|
|
|
+ url = self.request.build_absolute_uri()
|
|
|
|
|
+ url = replace_query_param(url, self.start_query_param, self._last_pk + 1)
|
|
|
|
|
+ url = replace_query_param(url, self.limit_query_param, self.limit)
|
|
|
|
|
+ url = remove_query_param(url, self.offset_query_param)
|
|
|
|
|
+ return url
|
|
|
|
|
+
|
|
|
return super().get_next_link()
|
|
return super().get_next_link()
|
|
|
|
|
|
|
|
def get_previous_link(self):
|
|
def get_previous_link(self):
|
|
@@ -83,10 +161,30 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
|
|
if not self.limit:
|
|
if not self.limit:
|
|
|
return None
|
|
return None
|
|
|
|
|
|
|
|
- return super().get_previous_link()
|
|
|
|
|
|
|
+ # Cursor mode: forward-only
|
|
|
|
|
+ if self.start is not None:
|
|
|
|
|
+ return None
|
|
|
|
|
|
|
|
|
|
+ return super().get_previous_link()
|
|
|
|
|
|
|
|
-class StripCountAnnotationsPaginator(OptionalLimitOffsetPagination):
|
|
|
|
|
|
|
+ def get_schema_operation_parameters(self, view):
|
|
|
|
|
+ parameters = super().get_schema_operation_parameters(view)
|
|
|
|
|
+ parameters.append({
|
|
|
|
|
+ 'name': self.start_query_param,
|
|
|
|
|
+ 'required': False,
|
|
|
|
|
+ 'in': 'query',
|
|
|
|
|
+ 'description': (
|
|
|
|
|
+ 'Cursor-based pagination: return results with pk >= start, ordered by pk. '
|
|
|
|
|
+ 'Mutually exclusive with offset.'
|
|
|
|
|
+ ),
|
|
|
|
|
+ 'schema': {
|
|
|
|
|
+ 'type': 'integer',
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
|
|
+ return parameters
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class StripCountAnnotationsPaginator(NetBoxPagination):
|
|
|
"""
|
|
"""
|
|
|
Strips the annotations on the queryset before getting the count
|
|
Strips the annotations on the queryset before getting the count
|
|
|
to optimize pagination of complex queries.
|
|
to optimize pagination of complex queries.
|