django-rest-framework/rest_framework/pagination.py

907 lines
32 KiB
Python
Raw Normal View History

2013-04-25 15:47:34 +04:00
"""
Pagination serializers determine the structure of the output that should
be used for paginated responses.
"""
2015-06-25 23:55:51 +03:00
from base64 import b64decode, b64encode
2015-09-22 17:35:38 +03:00
from collections import OrderedDict, namedtuple
from urllib import parse
2015-06-25 23:55:51 +03:00
from django.core.paginator import InvalidPage
from django.core.paginator import Paginator as DjangoPaginator
from django.template import loader
2017-03-03 18:24:37 +03:00
from django.utils.encoding import force_text
from django.utils.translation import gettext_lazy as _
from rest_framework.compat import coreapi, coreschema
2015-01-09 18:30:36 +03:00
from rest_framework.exceptions import NotFound
from rest_framework.response import Response
from rest_framework.settings import api_settings
2015-06-25 23:55:51 +03:00
from rest_framework.utils.urls import remove_query_param, replace_query_param
2012-09-30 20:31:28 +04:00
def _positive_int(integer_string, strict=False, cutoff=None):
2012-10-01 18:49:19 +04:00
"""
2015-01-09 18:30:36 +03:00
Cast a string to a strictly positive integer.
2012-10-01 18:49:19 +04:00
"""
2015-01-09 18:30:36 +03:00
ret = int(integer_string)
if ret < 0 or (ret == 0 and strict):
2015-01-09 18:30:36 +03:00
raise ValueError()
if cutoff:
return min(ret, cutoff)
2015-01-09 18:30:36 +03:00
return ret
2012-09-30 20:31:28 +04:00
def _divide_with_ceil(a, b):
"""
Returns 'a' divided by 'b', with any remainder rounded up.
"""
if a % b:
2015-01-16 23:30:46 +03:00
return (a // b) + 1
2015-01-16 23:30:46 +03:00
return a // b
def _get_displayed_page_numbers(current, final):
"""
This utility function determines a list of page numbers to display.
This gives us a nice contextually relevant set of page numbers.
For example:
current=14, final=16 -> [1, None, 13, 14, 15, 16]
2015-01-14 20:46:41 +03:00
This implementation gives one page to each side of the cursor,
or two pages to the side when the cursor is at the edge, then
2016-08-08 11:32:22 +03:00
ensures that any breaks between non-continuous page numbers never
remove only a single page.
2016-08-08 11:32:22 +03:00
For an alternative implementation which gives two pages to each side of
the cursor, eg. as in GitHub issue list pagination, see:
2015-01-14 20:46:41 +03:00
https://gist.github.com/tomchristie/321140cebb1c4a558b15
"""
assert current >= 1
assert final >= current
if final <= 5:
2015-01-16 23:30:46 +03:00
return list(range(1, final + 1))
# We always include the first two pages, last two pages, and
# two pages either side of the current page.
included = {1, current - 1, current, current + 1, final}
# If the break would only exclude a single page number then we
# may as well include the page number instead of the break.
2015-01-14 20:46:41 +03:00
if current <= 4:
included.add(2)
2015-01-14 20:46:41 +03:00
included.add(3)
if current >= final - 3:
included.add(final - 1)
2015-01-14 20:46:41 +03:00
included.add(final - 2)
# Now sort the page numbers and drop anything outside the limits.
included = [
idx for idx in sorted(list(included))
if 0 < idx <= final
]
# Finally insert any `...` breaks
if current > 4:
included.insert(1, None)
if current < final - 3:
included.insert(len(included) - 1, None)
return included
def _get_page_links(page_numbers, current, url_func):
"""
Given a list of page numbers and `None` page breaks,
return a list of `PageLink` objects.
"""
page_links = []
for page_number in page_numbers:
if page_number is None:
2015-01-16 00:07:05 +03:00
page_link = PAGE_BREAK
else:
page_link = PageLink(
url=url_func(page_number),
number=page_number,
is_active=(page_number == current),
is_break=False
)
page_links.append(page_link)
return page_links
def _reverse_ordering(ordering_tuple):
"""
Given an order_by tuple such as `('-created', 'uuid')` reverse the
ordering and return a new tuple, eg. `('created', '-uuid')`.
"""
2015-02-09 23:43:50 +03:00
def invert(x):
2017-01-08 19:09:23 +03:00
return x[1:] if x.startswith('-') else '-' + x
2015-02-09 23:43:50 +03:00
return tuple([invert(item) for item in ordering_tuple])
Cursor = namedtuple('Cursor', ['offset', 'reverse', 'position'])
PageLink = namedtuple('PageLink', ['url', 'number', 'is_active', 'is_break'])
2015-01-16 00:07:05 +03:00
PAGE_BREAK = PageLink(url=None, number=None, is_active=False, is_break=True)
class BasePagination:
display_page_controls = False
2015-01-16 19:55:46 +03:00
def paginate_queryset(self, queryset, request, view=None): # pragma: no cover
2015-01-19 18:22:38 +03:00
raise NotImplementedError('paginate_queryset() must be implemented.')
2015-01-16 19:55:46 +03:00
def get_paginated_response(self, data): # pragma: no cover
2015-01-19 18:22:38 +03:00
raise NotImplementedError('get_paginated_response() must be implemented.')
2012-09-30 20:31:28 +04:00
2015-01-16 19:55:46 +03:00
def to_html(self): # pragma: no cover
2015-01-19 18:22:38 +03:00
raise NotImplementedError('to_html() must be implemented to display page controls.')
2015-05-12 16:49:09 +03:00
def get_results(self, data):
return data['results']
Version 3.5 (#4525) * Start test case * Added 'requests' test client * Address typos * Graceful fallback if requests is not installed. * Add cookie support * Tests for auth and CSRF * Py3 compat * py3 compat * py3 compat * Add get_requests_client * Added SchemaGenerator.should_include_link * add settings for html cutoff on related fields * Router doesn't work if prefix is blank, though project urls.py handles prefix * Fix Django 1.10 to-many deprecation * Add django.core.urlresolvers compatibility * Update django-filter & django-guardian * Check for empty router prefix; adjust URL accordingly It's easiest to fix this issue after we have made the regex. To try to fix it before would require doing something different for List vs Detail, which means we'd have to know which type of url we're constructing before acting accordingly. * Fix misc django deprecations * Use TOC extension instead of header * Fix deprecations for py3k * Add py3k compatibility to is_simple_callable * Add is_simple_callable tests * Drop python 3.2 support (EOL, Dropped by Django) * schema_renderers= should *set* the renderers, not append to them. * API client (#4424) * Fix release notes * Add note about 'User account is disabled.' vs 'Unable to log in' * Clean up schema generation (#4527) * Handle multiple methods on custom action (#4529) * RequestsClient, CoreAPIClient * exclude_from_schema * Added 'get_schema_view()' shortcut * Added schema descriptions * Better descriptions for schemas * Add type annotation to schema generation * Coerce schema 'pk' in path to actual field name * Deprecations move into assertion errors * Use get_schema_view in tests * Updte CoreJSON media type * Handle schema structure correctly when path prefixs exist. Closes #4401 * Add PendingDeprecation to Router schema generation. * Added SCHEMA_COERCE_PATH_PK and SCHEMA_COERCE_METHOD_NAMES * Renamed and documented 'get_schema_fields' interface.
2016-10-10 15:03:46 +03:00
def get_schema_fields(self, view):
assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`'
return []
def get_schema_operation_parameters(self, view):
return []
2012-09-30 20:31:28 +04:00
2015-01-09 18:30:36 +03:00
class PageNumberPagination(BasePagination):
"""
2015-01-09 18:30:36 +03:00
A simple page number based style that supports page numbers as
query parameters. For example:
http://api.example.org/accounts/?page=4
http://api.example.org/accounts/?page=4&page_size=100
"""
2015-01-09 18:30:36 +03:00
# The default page size.
# Defaults to `None`, meaning pagination is disabled.
2015-03-04 18:51:00 +03:00
page_size = api_settings.PAGE_SIZE
django_paginator_class = DjangoPaginator
2015-01-09 18:30:36 +03:00
# Client can control the page using this query parameter.
page_query_param = 'page'
2017-03-03 18:24:37 +03:00
page_query_description = _('A page number within the paginated result set.')
2015-01-09 18:30:36 +03:00
# Client can control the page size using this query parameter.
# Default is 'None'. Set to eg 'page_size' to enable usage.
2015-03-04 18:51:00 +03:00
page_size_query_param = None
2017-03-03 18:24:37 +03:00
page_size_query_description = _('Number of results to return per page.')
2015-01-09 18:30:36 +03:00
# Set to an integer to limit the maximum page size the client may request.
2015-03-04 18:51:00 +03:00
# Only relevant if 'page_size_query_param' has also been set.
max_page_size = None
2012-10-01 18:49:19 +04:00
2015-01-16 00:07:05 +03:00
last_page_strings = ('last',)
template = 'rest_framework/pagination/numbers.html'
invalid_page_message = _('Invalid page.')
2015-01-16 19:55:46 +03:00
def paginate_queryset(self, queryset, request, view=None):
"""
Paginate a queryset if required, either returning a
page object, or `None` if pagination is not configured for this view.
"""
2015-01-09 18:30:36 +03:00
page_size = self.get_page_size(request)
if not page_size:
return None
paginator = self.django_paginator_class(queryset, page_size)
2015-01-16 00:07:05 +03:00
page_number = request.query_params.get(self.page_query_param, 1)
if page_number in self.last_page_strings:
page_number = paginator.num_pages
2014-09-02 20:41:23 +04:00
try:
2015-01-09 18:30:36 +03:00
self.page = paginator.page(page_number)
except InvalidPage as exc:
msg = self.invalid_page_message.format(
page_number=page_number, message=str(exc)
2015-01-09 18:30:36 +03:00
)
raise NotFound(msg)
if paginator.num_pages > 1 and self.template is not None:
# The browsable API should display pagination controls.
self.display_page_controls = True
2015-01-16 00:07:05 +03:00
2015-01-09 18:30:36 +03:00
self.request = request
2015-03-04 18:51:00 +03:00
return list(self.page)
2015-01-09 18:30:36 +03:00
def get_paginated_response(self, data):
2015-01-09 18:30:36 +03:00
return Response(OrderedDict([
('count', self.page.paginator.count),
('next', self.get_next_link()),
('previous', self.get_previous_link()),
('results', data)
2015-01-09 18:30:36 +03:00
]))
def get_page_size(self, request):
2015-03-04 18:51:00 +03:00
if self.page_size_query_param:
2015-01-09 18:30:36 +03:00
try:
return _positive_int(
2015-03-04 18:51:00 +03:00
request.query_params[self.page_size_query_param],
strict=True,
2015-03-04 18:51:00 +03:00
cutoff=self.max_page_size
2015-01-09 18:30:36 +03:00
)
except (KeyError, ValueError):
pass
2015-03-04 18:51:00 +03:00
return self.page_size
2015-01-09 18:30:36 +03:00
def get_next_link(self):
if not self.page.has_next():
return None
url = self.request.build_absolute_uri()
page_number = self.page.next_page_number()
return replace_query_param(url, self.page_query_param, page_number)
2015-01-09 18:30:36 +03:00
def get_previous_link(self):
if not self.page.has_previous():
return None
url = self.request.build_absolute_uri()
page_number = self.page.previous_page_number()
if page_number == 1:
return remove_query_param(url, self.page_query_param)
2015-01-09 18:30:36 +03:00
return replace_query_param(url, self.page_query_param, page_number)
2012-09-30 20:31:28 +04:00
2015-01-16 00:07:05 +03:00
def get_html_context(self):
base_url = self.request.build_absolute_uri()
2015-01-15 19:55:04 +03:00
def page_number_to_url(page_number):
if page_number == 1:
return remove_query_param(base_url, self.page_query_param)
else:
return replace_query_param(base_url, self.page_query_param, page_number)
current = self.page.number
final = self.page.paginator.num_pages
page_numbers = _get_displayed_page_numbers(current, final)
page_links = _get_page_links(page_numbers, current, page_number_to_url)
2015-01-16 00:07:05 +03:00
return {
'previous_url': self.get_previous_link(),
'next_url': self.get_next_link(),
'page_links': page_links
2015-01-16 00:07:05 +03:00
}
def to_html(self):
template = loader.get_template(self.template)
context = self.get_html_context()
return template.render(context)
Version 3.5 (#4525) * Start test case * Added 'requests' test client * Address typos * Graceful fallback if requests is not installed. * Add cookie support * Tests for auth and CSRF * Py3 compat * py3 compat * py3 compat * Add get_requests_client * Added SchemaGenerator.should_include_link * add settings for html cutoff on related fields * Router doesn't work if prefix is blank, though project urls.py handles prefix * Fix Django 1.10 to-many deprecation * Add django.core.urlresolvers compatibility * Update django-filter & django-guardian * Check for empty router prefix; adjust URL accordingly It's easiest to fix this issue after we have made the regex. To try to fix it before would require doing something different for List vs Detail, which means we'd have to know which type of url we're constructing before acting accordingly. * Fix misc django deprecations * Use TOC extension instead of header * Fix deprecations for py3k * Add py3k compatibility to is_simple_callable * Add is_simple_callable tests * Drop python 3.2 support (EOL, Dropped by Django) * schema_renderers= should *set* the renderers, not append to them. * API client (#4424) * Fix release notes * Add note about 'User account is disabled.' vs 'Unable to log in' * Clean up schema generation (#4527) * Handle multiple methods on custom action (#4529) * RequestsClient, CoreAPIClient * exclude_from_schema * Added 'get_schema_view()' shortcut * Added schema descriptions * Better descriptions for schemas * Add type annotation to schema generation * Coerce schema 'pk' in path to actual field name * Deprecations move into assertion errors * Use get_schema_view in tests * Updte CoreJSON media type * Handle schema structure correctly when path prefixs exist. Closes #4401 * Add PendingDeprecation to Router schema generation. * Added SCHEMA_COERCE_PATH_PK and SCHEMA_COERCE_METHOD_NAMES * Renamed and documented 'get_schema_fields' interface.
2016-10-10 15:03:46 +03:00
def get_schema_fields(self, view):
assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`'
2017-03-03 18:24:37 +03:00
assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`'
Version 3.5 (#4525) * Start test case * Added 'requests' test client * Address typos * Graceful fallback if requests is not installed. * Add cookie support * Tests for auth and CSRF * Py3 compat * py3 compat * py3 compat * Add get_requests_client * Added SchemaGenerator.should_include_link * add settings for html cutoff on related fields * Router doesn't work if prefix is blank, though project urls.py handles prefix * Fix Django 1.10 to-many deprecation * Add django.core.urlresolvers compatibility * Update django-filter & django-guardian * Check for empty router prefix; adjust URL accordingly It's easiest to fix this issue after we have made the regex. To try to fix it before would require doing something different for List vs Detail, which means we'd have to know which type of url we're constructing before acting accordingly. * Fix misc django deprecations * Use TOC extension instead of header * Fix deprecations for py3k * Add py3k compatibility to is_simple_callable * Add is_simple_callable tests * Drop python 3.2 support (EOL, Dropped by Django) * schema_renderers= should *set* the renderers, not append to them. * API client (#4424) * Fix release notes * Add note about 'User account is disabled.' vs 'Unable to log in' * Clean up schema generation (#4527) * Handle multiple methods on custom action (#4529) * RequestsClient, CoreAPIClient * exclude_from_schema * Added 'get_schema_view()' shortcut * Added schema descriptions * Better descriptions for schemas * Add type annotation to schema generation * Coerce schema 'pk' in path to actual field name * Deprecations move into assertion errors * Use get_schema_view in tests * Updte CoreJSON media type * Handle schema structure correctly when path prefixs exist. Closes #4401 * Add PendingDeprecation to Router schema generation. * Added SCHEMA_COERCE_PATH_PK and SCHEMA_COERCE_METHOD_NAMES * Renamed and documented 'get_schema_fields' interface.
2016-10-10 15:03:46 +03:00
fields = [
2017-03-03 18:24:37 +03:00
coreapi.Field(
name=self.page_query_param,
required=False,
location='query',
schema=coreschema.Integer(
title='Page',
description=force_text(self.page_query_description)
)
)
Version 3.5 (#4525) * Start test case * Added 'requests' test client * Address typos * Graceful fallback if requests is not installed. * Add cookie support * Tests for auth and CSRF * Py3 compat * py3 compat * py3 compat * Add get_requests_client * Added SchemaGenerator.should_include_link * add settings for html cutoff on related fields * Router doesn't work if prefix is blank, though project urls.py handles prefix * Fix Django 1.10 to-many deprecation * Add django.core.urlresolvers compatibility * Update django-filter & django-guardian * Check for empty router prefix; adjust URL accordingly It's easiest to fix this issue after we have made the regex. To try to fix it before would require doing something different for List vs Detail, which means we'd have to know which type of url we're constructing before acting accordingly. * Fix misc django deprecations * Use TOC extension instead of header * Fix deprecations for py3k * Add py3k compatibility to is_simple_callable * Add is_simple_callable tests * Drop python 3.2 support (EOL, Dropped by Django) * schema_renderers= should *set* the renderers, not append to them. * API client (#4424) * Fix release notes * Add note about 'User account is disabled.' vs 'Unable to log in' * Clean up schema generation (#4527) * Handle multiple methods on custom action (#4529) * RequestsClient, CoreAPIClient * exclude_from_schema * Added 'get_schema_view()' shortcut * Added schema descriptions * Better descriptions for schemas * Add type annotation to schema generation * Coerce schema 'pk' in path to actual field name * Deprecations move into assertion errors * Use get_schema_view in tests * Updte CoreJSON media type * Handle schema structure correctly when path prefixs exist. Closes #4401 * Add PendingDeprecation to Router schema generation. * Added SCHEMA_COERCE_PATH_PK and SCHEMA_COERCE_METHOD_NAMES * Renamed and documented 'get_schema_fields' interface.
2016-10-10 15:03:46 +03:00
]
if self.page_size_query_param is not None:
fields.append(
2017-03-03 18:24:37 +03:00
coreapi.Field(
name=self.page_size_query_param,
required=False,
location='query',
schema=coreschema.Integer(
title='Page size',
description=force_text(self.page_size_query_description)
)
)
)
Version 3.5 (#4525) * Start test case * Added 'requests' test client * Address typos * Graceful fallback if requests is not installed. * Add cookie support * Tests for auth and CSRF * Py3 compat * py3 compat * py3 compat * Add get_requests_client * Added SchemaGenerator.should_include_link * add settings for html cutoff on related fields * Router doesn't work if prefix is blank, though project urls.py handles prefix * Fix Django 1.10 to-many deprecation * Add django.core.urlresolvers compatibility * Update django-filter & django-guardian * Check for empty router prefix; adjust URL accordingly It's easiest to fix this issue after we have made the regex. To try to fix it before would require doing something different for List vs Detail, which means we'd have to know which type of url we're constructing before acting accordingly. * Fix misc django deprecations * Use TOC extension instead of header * Fix deprecations for py3k * Add py3k compatibility to is_simple_callable * Add is_simple_callable tests * Drop python 3.2 support (EOL, Dropped by Django) * schema_renderers= should *set* the renderers, not append to them. * API client (#4424) * Fix release notes * Add note about 'User account is disabled.' vs 'Unable to log in' * Clean up schema generation (#4527) * Handle multiple methods on custom action (#4529) * RequestsClient, CoreAPIClient * exclude_from_schema * Added 'get_schema_view()' shortcut * Added schema descriptions * Better descriptions for schemas * Add type annotation to schema generation * Coerce schema 'pk' in path to actual field name * Deprecations move into assertion errors * Use get_schema_view in tests * Updte CoreJSON media type * Handle schema structure correctly when path prefixs exist. Closes #4401 * Add PendingDeprecation to Router schema generation. * Added SCHEMA_COERCE_PATH_PK and SCHEMA_COERCE_METHOD_NAMES * Renamed and documented 'get_schema_fields' interface.
2016-10-10 15:03:46 +03:00
return fields
def get_schema_operation_parameters(self, view):
parameters = [
{
'name': self.page_query_param,
'required': False,
'in': 'query',
'description': force_text(self.page_query_description),
'schema': {
'type': 'integer',
},
},
]
if self.page_size_query_param is not None:
parameters.append(
{
'name': self.page_size_query_param,
'required': False,
'in': 'query',
'description': force_text(self.page_size_query_description),
'schema': {
'type': 'integer',
},
},
)
return parameters
2012-10-01 18:49:19 +04:00
2015-01-09 18:30:36 +03:00
class LimitOffsetPagination(BasePagination):
2012-10-01 18:49:19 +04:00
"""
2015-01-09 18:30:36 +03:00
A limit/offset based style. For example:
http://api.example.org/accounts/?limit=100
http://api.example.org/accounts/?offset=400&limit=100
2012-10-01 18:49:19 +04:00
"""
2015-03-04 18:51:00 +03:00
default_limit = api_settings.PAGE_SIZE
2015-01-09 18:30:36 +03:00
limit_query_param = 'limit'
2017-03-03 18:24:37 +03:00
limit_query_description = _('Number of results to return per page.')
2015-01-09 18:30:36 +03:00
offset_query_param = 'offset'
2017-03-03 18:24:37 +03:00
offset_query_description = _('The initial index from which to return the results.')
2015-01-09 18:30:36 +03:00
max_limit = None
template = 'rest_framework/pagination/numbers.html'
2015-01-16 00:07:05 +03:00
def paginate_queryset(self, queryset, request, view=None):
self.count = self.get_count(queryset)
2015-01-09 18:30:36 +03:00
self.limit = self.get_limit(request)
2015-03-16 15:05:31 +03:00
if self.limit is None:
return None
2015-01-09 18:30:36 +03:00
self.offset = self.get_offset(request)
self.request = request
2015-02-26 15:48:34 +03:00
if self.count > self.limit and self.template is not None:
self.display_page_controls = True
if self.count == 0 or self.offset > self.count:
return []
2015-03-04 18:51:00 +03:00
return list(queryset[self.offset:self.offset + self.limit])
2015-01-09 18:30:36 +03:00
def get_paginated_response(self, data):
2015-01-09 18:30:36 +03:00
return Response(OrderedDict([
('count', self.count),
('next', self.get_next_link()),
('previous', self.get_previous_link()),
('results', data)
2015-01-09 18:30:36 +03:00
]))
def get_limit(self, request):
if self.limit_query_param:
try:
return _positive_int(
2015-01-09 18:30:36 +03:00
request.query_params[self.limit_query_param],
strict=True,
2015-01-09 18:30:36 +03:00
cutoff=self.max_limit
)
except (KeyError, ValueError):
pass
return self.default_limit
def get_offset(self, request):
try:
return _positive_int(
2015-01-09 18:30:36 +03:00
request.query_params[self.offset_query_param],
)
except (KeyError, ValueError):
return 0
def get_next_link(self):
2015-01-09 18:30:36 +03:00
if self.offset + self.limit >= self.count:
return None
2015-01-09 18:30:36 +03:00
url = self.request.build_absolute_uri()
url = replace_query_param(url, self.limit_query_param, self.limit)
2015-01-09 18:30:36 +03:00
offset = self.offset + self.limit
return replace_query_param(url, self.offset_query_param, offset)
def get_previous_link(self):
if self.offset <= 0:
2015-01-09 18:30:36 +03:00
return None
2015-01-09 18:30:36 +03:00
url = self.request.build_absolute_uri()
url = replace_query_param(url, self.limit_query_param, self.limit)
if self.offset - self.limit <= 0:
return remove_query_param(url, self.offset_query_param)
2015-01-09 18:30:36 +03:00
offset = self.offset - self.limit
return replace_query_param(url, self.offset_query_param, offset)
2015-01-16 00:07:05 +03:00
def get_html_context(self):
base_url = self.request.build_absolute_uri()
if self.limit:
current = _divide_with_ceil(self.offset, self.limit) + 1
# The number of pages is a little bit fiddly.
# We need to sum both the number of pages from current offset to end
# plus the number of pages up to the current offset.
# When offset is not strictly divisible by the limit then we may
# end up introducing an extra page as an artifact.
final = (
_divide_with_ceil(self.count - self.offset, self.limit) +
_divide_with_ceil(self.offset, self.limit)
)
if final < 1:
final = 1
else:
current = 1
final = 1
if current > final:
current = final
def page_number_to_url(page_number):
if page_number == 1:
return remove_query_param(base_url, self.offset_query_param)
else:
offset = self.offset + ((page_number - current) * self.limit)
return replace_query_param(base_url, self.offset_query_param, offset)
page_numbers = _get_displayed_page_numbers(current, final)
page_links = _get_page_links(page_numbers, current, page_number_to_url)
2015-01-16 00:07:05 +03:00
return {
'previous_url': self.get_previous_link(),
'next_url': self.get_next_link(),
'page_links': page_links
2015-01-16 00:07:05 +03:00
}
def to_html(self):
template = loader.get_template(self.template)
context = self.get_html_context()
return template.render(context)
2015-01-17 03:10:43 +03:00
def get_count(self, queryset):
"""
Determine an object count, supporting either querysets or regular lists.
"""
try:
return queryset.count()
except (AttributeError, TypeError):
return len(queryset)
Version 3.5 (#4525) * Start test case * Added 'requests' test client * Address typos * Graceful fallback if requests is not installed. * Add cookie support * Tests for auth and CSRF * Py3 compat * py3 compat * py3 compat * Add get_requests_client * Added SchemaGenerator.should_include_link * add settings for html cutoff on related fields * Router doesn't work if prefix is blank, though project urls.py handles prefix * Fix Django 1.10 to-many deprecation * Add django.core.urlresolvers compatibility * Update django-filter & django-guardian * Check for empty router prefix; adjust URL accordingly It's easiest to fix this issue after we have made the regex. To try to fix it before would require doing something different for List vs Detail, which means we'd have to know which type of url we're constructing before acting accordingly. * Fix misc django deprecations * Use TOC extension instead of header * Fix deprecations for py3k * Add py3k compatibility to is_simple_callable * Add is_simple_callable tests * Drop python 3.2 support (EOL, Dropped by Django) * schema_renderers= should *set* the renderers, not append to them. * API client (#4424) * Fix release notes * Add note about 'User account is disabled.' vs 'Unable to log in' * Clean up schema generation (#4527) * Handle multiple methods on custom action (#4529) * RequestsClient, CoreAPIClient * exclude_from_schema * Added 'get_schema_view()' shortcut * Added schema descriptions * Better descriptions for schemas * Add type annotation to schema generation * Coerce schema 'pk' in path to actual field name * Deprecations move into assertion errors * Use get_schema_view in tests * Updte CoreJSON media type * Handle schema structure correctly when path prefixs exist. Closes #4401 * Add PendingDeprecation to Router schema generation. * Added SCHEMA_COERCE_PATH_PK and SCHEMA_COERCE_METHOD_NAMES * Renamed and documented 'get_schema_fields' interface.
2016-10-10 15:03:46 +03:00
def get_schema_fields(self, view):
assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`'
2017-03-03 18:24:37 +03:00
assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`'
Version 3.5 (#4525) * Start test case * Added 'requests' test client * Address typos * Graceful fallback if requests is not installed. * Add cookie support * Tests for auth and CSRF * Py3 compat * py3 compat * py3 compat * Add get_requests_client * Added SchemaGenerator.should_include_link * add settings for html cutoff on related fields * Router doesn't work if prefix is blank, though project urls.py handles prefix * Fix Django 1.10 to-many deprecation * Add django.core.urlresolvers compatibility * Update django-filter & django-guardian * Check for empty router prefix; adjust URL accordingly It's easiest to fix this issue after we have made the regex. To try to fix it before would require doing something different for List vs Detail, which means we'd have to know which type of url we're constructing before acting accordingly. * Fix misc django deprecations * Use TOC extension instead of header * Fix deprecations for py3k * Add py3k compatibility to is_simple_callable * Add is_simple_callable tests * Drop python 3.2 support (EOL, Dropped by Django) * schema_renderers= should *set* the renderers, not append to them. * API client (#4424) * Fix release notes * Add note about 'User account is disabled.' vs 'Unable to log in' * Clean up schema generation (#4527) * Handle multiple methods on custom action (#4529) * RequestsClient, CoreAPIClient * exclude_from_schema * Added 'get_schema_view()' shortcut * Added schema descriptions * Better descriptions for schemas * Add type annotation to schema generation * Coerce schema 'pk' in path to actual field name * Deprecations move into assertion errors * Use get_schema_view in tests * Updte CoreJSON media type * Handle schema structure correctly when path prefixs exist. Closes #4401 * Add PendingDeprecation to Router schema generation. * Added SCHEMA_COERCE_PATH_PK and SCHEMA_COERCE_METHOD_NAMES * Renamed and documented 'get_schema_fields' interface.
2016-10-10 15:03:46 +03:00
return [
2017-03-03 18:24:37 +03:00
coreapi.Field(
name=self.limit_query_param,
required=False,
location='query',
schema=coreschema.Integer(
title='Limit',
description=force_text(self.limit_query_description)
)
),
coreapi.Field(
name=self.offset_query_param,
required=False,
location='query',
schema=coreschema.Integer(
title='Offset',
description=force_text(self.offset_query_description)
)
)
Version 3.5 (#4525) * Start test case * Added 'requests' test client * Address typos * Graceful fallback if requests is not installed. * Add cookie support * Tests for auth and CSRF * Py3 compat * py3 compat * py3 compat * Add get_requests_client * Added SchemaGenerator.should_include_link * add settings for html cutoff on related fields * Router doesn't work if prefix is blank, though project urls.py handles prefix * Fix Django 1.10 to-many deprecation * Add django.core.urlresolvers compatibility * Update django-filter & django-guardian * Check for empty router prefix; adjust URL accordingly It's easiest to fix this issue after we have made the regex. To try to fix it before would require doing something different for List vs Detail, which means we'd have to know which type of url we're constructing before acting accordingly. * Fix misc django deprecations * Use TOC extension instead of header * Fix deprecations for py3k * Add py3k compatibility to is_simple_callable * Add is_simple_callable tests * Drop python 3.2 support (EOL, Dropped by Django) * schema_renderers= should *set* the renderers, not append to them. * API client (#4424) * Fix release notes * Add note about 'User account is disabled.' vs 'Unable to log in' * Clean up schema generation (#4527) * Handle multiple methods on custom action (#4529) * RequestsClient, CoreAPIClient * exclude_from_schema * Added 'get_schema_view()' shortcut * Added schema descriptions * Better descriptions for schemas * Add type annotation to schema generation * Coerce schema 'pk' in path to actual field name * Deprecations move into assertion errors * Use get_schema_view in tests * Updte CoreJSON media type * Handle schema structure correctly when path prefixs exist. Closes #4401 * Add PendingDeprecation to Router schema generation. * Added SCHEMA_COERCE_PATH_PK and SCHEMA_COERCE_METHOD_NAMES * Renamed and documented 'get_schema_fields' interface.
2016-10-10 15:03:46 +03:00
]
def get_schema_operation_parameters(self, view):
parameters = [
{
'name': self.limit_query_param,
'required': False,
'in': 'query',
'description': force_text(self.limit_query_description),
'schema': {
'type': 'integer',
},
},
{
'name': self.offset_query_param,
'required': False,
'in': 'query',
'description': force_text(self.offset_query_description),
'schema': {
'type': 'integer',
},
},
]
return parameters
2015-01-17 03:10:43 +03:00
class CursorPagination(BasePagination):
2015-03-06 13:22:32 +03:00
"""
The cursor pagination implementation is necessarily complex.
2015-03-06 13:22:32 +03:00
For an overview of the position/offset style we use, see this post:
https://cra.mr/2011/03/08/building-cursors-for-the-disqus-api
2015-03-06 13:22:32 +03:00
"""
2015-01-17 03:10:43 +03:00
cursor_query_param = 'cursor'
2017-03-03 18:24:37 +03:00
cursor_query_description = _('The pagination cursor value.')
2015-03-04 18:51:00 +03:00
page_size = api_settings.PAGE_SIZE
invalid_cursor_message = _('Invalid cursor')
2015-03-06 13:22:32 +03:00
ordering = '-created'
2015-01-22 20:25:12 +03:00
template = 'rest_framework/pagination/previous_and_next.html'
2015-01-17 03:10:43 +03:00
# Client can control the page size using this query parameter.
# Default is 'None'. Set to eg 'page_size' to enable usage.
page_size_query_param = None
page_size_query_description = _('Number of results to return per page.')
# Set to an integer to limit the maximum page size the client may request.
# Only relevant if 'page_size_query_param' has also been set.
max_page_size = None
# The offset in the cursor is used in situations where we have a
# nearly-unique index. (Eg millisecond precision creation timestamps)
# We guard against malicious users attempting to cause expensive database
# queries, by having a hard cap on the maximum possible size of the offset.
offset_cutoff = 1000
2015-01-17 03:10:43 +03:00
def paginate_queryset(self, queryset, request, view=None):
self.page_size = self.get_page_size(request)
if not self.page_size:
2015-03-16 15:05:31 +03:00
return None
2015-01-17 03:10:43 +03:00
self.base_url = request.build_absolute_uri()
self.ordering = self.get_ordering(request, queryset, view)
2015-01-17 03:10:43 +03:00
2015-06-12 19:21:57 +03:00
self.cursor = self.decode_cursor(request)
if self.cursor is None:
(offset, reverse, current_position) = (0, False, None)
2015-01-17 03:10:43 +03:00
else:
2015-01-22 13:28:19 +03:00
(offset, reverse, current_position) = self.cursor
2015-01-17 03:10:43 +03:00
2015-01-22 13:28:19 +03:00
# Cursor pagination always enforces an ordering.
if reverse:
2015-01-22 20:25:12 +03:00
queryset = queryset.order_by(*_reverse_ordering(self.ordering))
2015-01-22 13:28:19 +03:00
else:
2015-01-22 20:25:12 +03:00
queryset = queryset.order_by(*self.ordering)
2015-01-17 03:10:43 +03:00
2015-01-22 13:28:19 +03:00
# If we have a cursor with a fixed position then filter by that.
if current_position is not None:
2015-01-22 20:25:12 +03:00
order = self.ordering[0]
is_reversed = order.startswith('-')
order_attr = order.lstrip('-')
# Test for: (cursor reversed) XOR (queryset reversed)
if self.cursor.reverse != is_reversed:
kwargs = {order_attr + '__lt': current_position}
2015-01-22 13:28:19 +03:00
else:
2015-01-22 20:25:12 +03:00
kwargs = {order_attr + '__gt': current_position}
2015-01-22 13:28:19 +03:00
queryset = queryset.filter(**kwargs)
2015-01-22 13:28:19 +03:00
# If we have an offset cursor then offset the entire page by that amount.
# We also always fetch an extra item in order to determine if there is a
# page following on from this one.
results = list(queryset[offset:offset + self.page_size + 1])
2015-03-04 18:51:00 +03:00
self.page = list(results[:self.page_size])
2015-01-22 13:28:19 +03:00
# Determine the position of the final item following the page.
if len(results) > len(self.page):
2016-08-08 11:32:22 +03:00
has_following_position = True
2015-01-22 13:28:19 +03:00
following_position = self._get_position_from_instance(results[-1], self.ordering)
else:
2016-08-08 11:32:22 +03:00
has_following_position = False
2015-01-22 13:28:19 +03:00
following_position = None
if reverse:
# If we have a reverse queryset, then the query ordering was in reverse
# so we need to reverse the items again before returning them to the user.
self.page = list(reversed(self.page))
2015-01-22 13:28:19 +03:00
# Determine next and previous positions for reverse cursors.
self.has_next = (current_position is not None) or (offset > 0)
2016-08-08 11:32:22 +03:00
self.has_previous = has_following_position
2015-01-22 13:28:19 +03:00
if self.has_next:
self.next_position = current_position
if self.has_previous:
self.previous_position = following_position
else:
# Determine next and previous positions for forward cursors.
2016-08-08 11:32:22 +03:00
self.has_next = has_following_position
self.has_previous = (current_position is not None) or (offset > 0)
2015-01-22 13:28:19 +03:00
if self.has_next:
self.next_position = following_position
if self.has_previous:
self.previous_position = current_position
2015-01-22 20:25:12 +03:00
# Display page controls in the browsable API if there is more
# than one page.
2015-02-26 15:48:34 +03:00
if (self.has_previous or self.has_next) and self.template is not None:
2015-01-22 20:25:12 +03:00
self.display_page_controls = True
2015-01-17 03:10:43 +03:00
return self.page
def get_page_size(self, request):
if self.page_size_query_param:
try:
return _positive_int(
request.query_params[self.page_size_query_param],
strict=True,
cutoff=self.max_page_size
)
except (KeyError, ValueError):
pass
return self.page_size
2015-01-17 03:10:43 +03:00
def get_next_link(self):
if not self.has_next:
return None
if self.page and self.cursor and self.cursor.reverse and self.cursor.offset != 0:
# If we're reversing direction and we have an offset cursor
# then we cannot use the first position we find as a marker.
compare = self._get_position_from_instance(self.page[-1], self.ordering)
else:
compare = self.next_position
offset = 0
has_item_with_unique_position = False
for item in reversed(self.page):
2015-01-22 13:28:19 +03:00
position = self._get_position_from_instance(item, self.ordering)
if position != compare:
# The item in this position and the item following it
# have different positions. We can use this position as
# our marker.
has_item_with_unique_position = True
break
2016-08-08 11:32:22 +03:00
# The item in this position has the same position as the item
# following it, we can't use it as a marker position, so increment
# the offset and keep seeking to the previous item.
compare = position
offset += 1
if self.page and not has_item_with_unique_position:
2015-01-22 13:28:19 +03:00
# There were no unique positions in the page.
if not self.has_previous:
# We are on the first page.
# Our cursor will have an offset equal to the page size,
# but no position to filter against yet.
offset = self.page_size
position = None
2015-01-22 13:28:19 +03:00
elif self.cursor.reverse:
# The change in direction will introduce a paging artifact,
# where we end up skipping forward a few extra items.
offset = 0
position = self.previous_position
else:
# Use the position from the existing cursor and increment
# it's offset by the page size.
offset = self.cursor.offset + self.page_size
2015-01-22 13:28:19 +03:00
position = self.previous_position
if not self.page:
position = self.next_position
cursor = Cursor(offset=offset, reverse=False, position=position)
2015-06-12 19:21:57 +03:00
return self.encode_cursor(cursor)
2015-01-17 03:10:43 +03:00
2015-01-22 13:28:19 +03:00
def get_previous_link(self):
if not self.has_previous:
return None
if self.page and self.cursor and not self.cursor.reverse and self.cursor.offset != 0:
# If we're reversing direction and we have an offset cursor
# then we cannot use the first position we find as a marker.
compare = self._get_position_from_instance(self.page[0], self.ordering)
else:
compare = self.previous_position
2015-01-22 13:28:19 +03:00
offset = 0
has_item_with_unique_position = False
2015-01-22 13:28:19 +03:00
for item in self.page:
position = self._get_position_from_instance(item, self.ordering)
if position != compare:
# The item in this position and the item following it
# have different positions. We can use this position as
# our marker.
has_item_with_unique_position = True
2015-01-22 13:28:19 +03:00
break
2016-08-08 11:32:22 +03:00
# The item in this position has the same position as the item
2015-01-22 13:28:19 +03:00
# following it, we can't use it as a marker position, so increment
# the offset and keep seeking to the previous item.
compare = position
offset += 1
if self.page and not has_item_with_unique_position:
2015-01-22 13:28:19 +03:00
# There were no unique positions in the page.
if not self.has_next:
# We are on the final page.
# Our cursor will have an offset equal to the page size,
# but no position to filter against yet.
offset = self.page_size
position = None
2015-01-22 13:28:19 +03:00
elif self.cursor.reverse:
# Use the position from the existing cursor and increment
# it's offset by the page size.
offset = self.cursor.offset + self.page_size
position = self.next_position
else:
# The change in direction will introduce a paging artifact,
# where we end up skipping back a few extra items.
offset = 0
position = self.next_position
if not self.page:
position = self.previous_position
2015-01-22 13:28:19 +03:00
cursor = Cursor(offset=offset, reverse=True, position=position)
2015-06-12 19:21:57 +03:00
return self.encode_cursor(cursor)
2015-01-22 13:28:19 +03:00
def get_ordering(self, request, queryset, view):
"""
Return a tuple of strings, that may be used in an `order_by` method.
"""
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 = filter_instance.get_ordering(request, queryset, view)
assert ordering is not None, (
'Using cursor pagination, but filter class {filter_cls} '
'returned a `None` ordering.'.format(
filter_cls=filter_cls.__name__
)
)
else:
2015-03-06 13:22:32 +03:00
# The default case is to check for an `ordering` attribute
# on this pagination instance.
ordering = self.ordering
assert ordering is not None, (
'Using cursor pagination, but no ordering attribute was declared '
2015-03-06 13:22:32 +03:00
'on the pagination class.'
)
assert '__' not in ordering, (
'Cursor pagination does not support double underscore lookups '
'for orderings. Orderings should be an unchanging, unique or '
'nearly-unique field on the model, such as "-created" or "pk".'
)
assert isinstance(ordering, (str, list, tuple)), (
'Invalid ordering. Expected string or tuple, but got {type}'.format(
type=type(ordering).__name__
)
)
if isinstance(ordering, str):
return (ordering,)
return tuple(ordering)
2015-01-17 03:10:43 +03:00
2015-06-12 19:21:57 +03:00
def decode_cursor(self, request):
"""
Given a request with a cursor, return a `Cursor` instance.
"""
2015-06-12 19:21:57 +03:00
# Determine if we have a cursor, and if so then decode it.
encoded = request.query_params.get(self.cursor_query_param)
if encoded is None:
return None
try:
querystring = b64decode(encoded.encode('ascii')).decode('ascii')
tokens = parse.parse_qs(querystring, keep_blank_values=True)
offset = tokens.get('o', ['0'])[0]
offset = _positive_int(offset, cutoff=self.offset_cutoff)
reverse = tokens.get('r', ['0'])[0]
reverse = bool(int(reverse))
position = tokens.get('p', [None])[0]
except (TypeError, ValueError):
raise NotFound(self.invalid_cursor_message)
return Cursor(offset=offset, reverse=reverse, position=position)
2015-06-12 19:21:57 +03:00
def encode_cursor(self, cursor):
"""
Given a Cursor instance, return an url with encoded cursor.
"""
tokens = {}
if cursor.offset != 0:
tokens['o'] = str(cursor.offset)
if cursor.reverse:
tokens['r'] = '1'
if cursor.position is not None:
tokens['p'] = cursor.position
querystring = parse.urlencode(tokens, doseq=True)
encoded = b64encode(querystring.encode('ascii')).decode('ascii')
2015-06-12 19:21:57 +03:00
return replace_query_param(self.base_url, self.cursor_query_param, encoded)
2015-01-22 13:28:19 +03:00
def _get_position_from_instance(self, instance, ordering):
field_name = ordering[0].lstrip('-')
if isinstance(instance, dict):
attr = instance[field_name]
else:
attr = getattr(instance, field_name)
return str(attr)
2015-01-22 20:25:12 +03:00
def get_paginated_response(self, data):
return Response(OrderedDict([
('next', self.get_next_link()),
('previous', self.get_previous_link()),
('results', data)
]))
def get_html_context(self):
return {
'previous_url': self.get_previous_link(),
'next_url': self.get_next_link()
}
def to_html(self):
template = loader.get_template(self.template)
context = self.get_html_context()
return template.render(context)
Version 3.5 (#4525) * Start test case * Added 'requests' test client * Address typos * Graceful fallback if requests is not installed. * Add cookie support * Tests for auth and CSRF * Py3 compat * py3 compat * py3 compat * Add get_requests_client * Added SchemaGenerator.should_include_link * add settings for html cutoff on related fields * Router doesn't work if prefix is blank, though project urls.py handles prefix * Fix Django 1.10 to-many deprecation * Add django.core.urlresolvers compatibility * Update django-filter & django-guardian * Check for empty router prefix; adjust URL accordingly It's easiest to fix this issue after we have made the regex. To try to fix it before would require doing something different for List vs Detail, which means we'd have to know which type of url we're constructing before acting accordingly. * Fix misc django deprecations * Use TOC extension instead of header * Fix deprecations for py3k * Add py3k compatibility to is_simple_callable * Add is_simple_callable tests * Drop python 3.2 support (EOL, Dropped by Django) * schema_renderers= should *set* the renderers, not append to them. * API client (#4424) * Fix release notes * Add note about 'User account is disabled.' vs 'Unable to log in' * Clean up schema generation (#4527) * Handle multiple methods on custom action (#4529) * RequestsClient, CoreAPIClient * exclude_from_schema * Added 'get_schema_view()' shortcut * Added schema descriptions * Better descriptions for schemas * Add type annotation to schema generation * Coerce schema 'pk' in path to actual field name * Deprecations move into assertion errors * Use get_schema_view in tests * Updte CoreJSON media type * Handle schema structure correctly when path prefixs exist. Closes #4401 * Add PendingDeprecation to Router schema generation. * Added SCHEMA_COERCE_PATH_PK and SCHEMA_COERCE_METHOD_NAMES * Renamed and documented 'get_schema_fields' interface.
2016-10-10 15:03:46 +03:00
def get_schema_fields(self, view):
assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`'
2017-03-03 18:24:37 +03:00
assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`'
fields = [
2017-03-03 18:24:37 +03:00
coreapi.Field(
name=self.cursor_query_param,
required=False,
location='query',
schema=coreschema.String(
title='Cursor',
description=force_text(self.cursor_query_description)
)
)
Version 3.5 (#4525) * Start test case * Added 'requests' test client * Address typos * Graceful fallback if requests is not installed. * Add cookie support * Tests for auth and CSRF * Py3 compat * py3 compat * py3 compat * Add get_requests_client * Added SchemaGenerator.should_include_link * add settings for html cutoff on related fields * Router doesn't work if prefix is blank, though project urls.py handles prefix * Fix Django 1.10 to-many deprecation * Add django.core.urlresolvers compatibility * Update django-filter & django-guardian * Check for empty router prefix; adjust URL accordingly It's easiest to fix this issue after we have made the regex. To try to fix it before would require doing something different for List vs Detail, which means we'd have to know which type of url we're constructing before acting accordingly. * Fix misc django deprecations * Use TOC extension instead of header * Fix deprecations for py3k * Add py3k compatibility to is_simple_callable * Add is_simple_callable tests * Drop python 3.2 support (EOL, Dropped by Django) * schema_renderers= should *set* the renderers, not append to them. * API client (#4424) * Fix release notes * Add note about 'User account is disabled.' vs 'Unable to log in' * Clean up schema generation (#4527) * Handle multiple methods on custom action (#4529) * RequestsClient, CoreAPIClient * exclude_from_schema * Added 'get_schema_view()' shortcut * Added schema descriptions * Better descriptions for schemas * Add type annotation to schema generation * Coerce schema 'pk' in path to actual field name * Deprecations move into assertion errors * Use get_schema_view in tests * Updte CoreJSON media type * Handle schema structure correctly when path prefixs exist. Closes #4401 * Add PendingDeprecation to Router schema generation. * Added SCHEMA_COERCE_PATH_PK and SCHEMA_COERCE_METHOD_NAMES * Renamed and documented 'get_schema_fields' interface.
2016-10-10 15:03:46 +03:00
]
if self.page_size_query_param is not None:
fields.append(
coreapi.Field(
name=self.page_size_query_param,
required=False,
location='query',
schema=coreschema.Integer(
title='Page size',
description=force_text(self.page_size_query_description)
)
)
)
return fields
def get_schema_operation_parameters(self, view):
parameters = [
{
'name': self.cursor_query_param,
'required': False,
'in': 'query',
'description': force_text(self.cursor_query_description),
'schema': {
'type': 'integer',
},
}
]
if self.page_size_query_param is not None:
parameters.append(
{
'name': self.page_size_query_param,
'required': False,
'in': 'query',
'description': force_text(self.page_size_query_description),
'schema': {
'type': 'integer',
},
}
)
return parameters