diff --git a/docs/api-guide/pagination.md b/docs/api-guide/pagination.md
index 9fbeb22a0..8ab2edd53 100644
--- a/docs/api-guide/pagination.md
+++ b/docs/api-guide/pagination.md
@@ -63,7 +63,7 @@ Or apply the style globally, using the `DEFAULT_PAGINATION_CLASS` settings key.
# Custom pagination styles
-To create a custom pagination serializer class you should subclass `pagination.BasePagination` and override the `paginate_queryset(self, queryset, request, view)` and `get_paginated_response(self, data)` methods:
+To create a custom pagination serializer class you should subclass `pagination.BasePagination` and override the `paginate_queryset(self, queryset, request, view=None)` and `get_paginated_response(self, data)` methods:
* The `paginate_queryset` method is passed the initial queryset and should return an iterable object that contains only the data in the requested page.
* The `get_paginated_response` method is passed the serialized page data and should return a `Response` instance.
@@ -74,7 +74,7 @@ Note that the `paginate_queryset` method may set state on the pagination instanc
Let's modify the built-in `PageNumberPagination` style, so that instead of include the pagination links in the body of the response, we'll instead include a `Link` header, in a [similar style to the GitHub API][github-link-pagination].
- class LinkHeaderPagination(PageNumberPagination)
+ class LinkHeaderPagination(pagination.PageNumberPagination):
def get_paginated_response(self, data):
next_url = self.get_next_link()
previous_url = self.get_previous_link()
@@ -82,7 +82,7 @@ Let's modify the built-in `PageNumberPagination` style, so that instead of inclu
link = '<{next_url}; rel="next">, <{previous_url}; rel="prev">'
elif next_url is not None:
link = '<{next_url}; rel="next">'
- elif prev_url is not None:
+ elif previous_url is not None:
link = '<{previous_url}; rel="prev">'
else:
link = ''
@@ -97,10 +97,20 @@ Let's modify the built-in `PageNumberPagination` style, so that instead of inclu
To have your custom pagination class be used by default, use the `DEFAULT_PAGINATION_CLASS` setting:
REST_FRAMEWORK = {
- 'DEFAULT_PAGINATION_CLASS':
- 'my_project.apps.core.pagination.LinkHeaderPagination',
+ 'DEFAULT_PAGINATION_CLASS': 'my_project.apps.core.pagination.LinkHeaderPagination',
+ 'PAGINATE_BY': 10
}
+API responses for list endpoints will now include a `Link` header, instead of including the pagination links as part of the body of the response, for example:
+
+---
+
+![Link Header][link-header]
+
+*A custom pagination style, using the 'Link' header'*
+
+---
+
# Third party packages
The following third party packages are also available.
@@ -111,5 +121,6 @@ The [`DRF-extensions` package][drf-extensions] includes a [`PaginateByMaxMixin`
[cite]: https://docs.djangoproject.com/en/dev/topics/pagination/
[github-link-pagination]: https://developer.github.com/guides/traversing-with-pagination/
+[link-header]: ../img/link-header-pagination.png
[drf-extensions]: http://chibisov.github.io/drf-extensions/docs/
[paginate-by-max-mixin]: http://chibisov.github.io/drf-extensions/docs/#paginatebymaxmixin
diff --git a/docs/img/link-header-pagination.png b/docs/img/link-header-pagination.png
new file mode 100644
index 000000000..d3c556a4d
Binary files /dev/null and b/docs/img/link-header-pagination.png differ
diff --git a/rest_framework/generics.py b/rest_framework/generics.py
index cdf6ece08..61dcb84a4 100644
--- a/rest_framework/generics.py
+++ b/rest_framework/generics.py
@@ -150,21 +150,31 @@ class GenericAPIView(views.APIView):
return queryset
@property
- def pager(self):
- if not hasattr(self, '_pager'):
+ def paginator(self):
+ """
+ The paginator instance associated with the view, or `None`.
+ """
+ if not hasattr(self, '_paginator'):
if self.pagination_class is None:
- self._pager = None
+ self._paginator = None
else:
- self._pager = self.pagination_class()
- return self._pager
+ self._paginator = self.pagination_class()
+ return self._paginator
def paginate_queryset(self, queryset):
- if self.pager is None:
- return queryset
- return self.pager.paginate_queryset(queryset, self.request, view=self)
+ """
+ Return a single page of results, or `None` if pagination is disabled.
+ """
+ if self.paginator is None:
+ return None
+ return self.paginator.paginate_queryset(queryset, self.request, view=self)
def get_paginated_response(self, data):
- return self.pager.get_paginated_response(data)
+ """
+ Return a paginated style `Response` object for the given output data.
+ """
+ assert self.paginator is not None
+ return self.paginator.get_paginated_response(data)
# Concrete view classes that provide method handlers
diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py
index b9d487968..1b7524c6c 100644
--- a/rest_framework/pagination.py
+++ b/rest_framework/pagination.py
@@ -3,14 +3,18 @@ Pagination serializers determine the structure of the output that should
be used for paginated responses.
"""
from __future__ import unicode_literals
+from collections import namedtuple
from django.core.paginator import InvalidPage, Paginator as DjangoPaginator
+from django.template import Context, loader
from django.utils import six
from django.utils.translation import ugettext as _
from rest_framework.compat import OrderedDict
from rest_framework.exceptions import NotFound
from rest_framework.response import Response
from rest_framework.settings import api_settings
-from rest_framework.templatetags.rest_framework import replace_query_param
+from rest_framework.utils.urls import (
+ replace_query_param, remove_query_param
+)
def _strict_positive_int(integer_string, cutoff=None):
@@ -25,23 +29,117 @@ def _strict_positive_int(integer_string, cutoff=None):
return ret
+def _divide_with_ceil(a, b):
+ """
+ Returns 'a' divded by 'b', with any remainder rounded up.
+ """
+ if a % b:
+ return (a // b) + 1
+ return a // b
+
+
def _get_count(queryset):
"""
Determine an object count, supporting either querysets or regular lists.
"""
try:
return queryset.count()
- except AttributeError:
+ except (AttributeError, TypeError):
return len(queryset)
+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]
+
+ 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
+ ensures that any breaks between non-continous page numbers never
+ remove only a single page.
+
+ For an alernativative implementation which gives two pages to each side of
+ the cursor, eg. as in GitHub issue list pagination, see:
+
+ https://gist.github.com/tomchristie/321140cebb1c4a558b15
+ """
+ assert current >= 1
+ assert final >= current
+
+ if final <= 5:
+ 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 = set((
+ 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.
+ if current <= 4:
+ included.add(2)
+ included.add(3)
+ if current >= final - 3:
+ included.add(final - 1)
+ included.add(final - 2)
+
+ # Now sort the page numbers and drop anything outside the limits.
+ included = [
+ idx for idx in sorted(list(included))
+ if idx > 0 and 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:
+ 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
+
+
+PageLink = namedtuple('PageLink', ['url', 'number', 'is_active', 'is_break'])
+
+PAGE_BREAK = PageLink(url=None, number=None, is_active=False, is_break=True)
+
+
class BasePagination(object):
- def paginate_queryset(self, queryset, request, view):
+ display_page_controls = False
+
+ def paginate_queryset(self, queryset, request, view=None): # pragma: no cover
raise NotImplemented('paginate_queryset() must be implemented.')
- def get_paginated_response(self, data):
+ def get_paginated_response(self, data): # pragma: no cover
raise NotImplemented('get_paginated_response() must be implemented.')
+ def to_html(self): # pragma: no cover
+ raise NotImplemented('to_html() must be implemented to display page controls.')
+
class PageNumberPagination(BasePagination):
"""
@@ -66,10 +164,15 @@ class PageNumberPagination(BasePagination):
# Only relevant if 'paginate_by_param' has also been set.
max_paginate_by = api_settings.MAX_PAGINATE_BY
- def paginate_queryset(self, queryset, request, view):
+ last_page_strings = ('last',)
+
+ template = 'rest_framework/pagination/numbers.html'
+
+ def _handle_backwards_compat(self, view):
"""
- Paginate a queryset if required, either returning a
- page object, or `None` if pagination is not configured for this view.
+ Prior to version 3.1, pagination was handled in the view, and the
+ attributes were set there. The attributes should now be set on
+ the pagination class, but the old style is still pending deprecation.
"""
for attr in (
'paginate_by', 'page_query_param',
@@ -78,23 +181,21 @@ class PageNumberPagination(BasePagination):
if hasattr(view, attr):
setattr(self, attr, getattr(view, attr))
+ 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.
+ """
+ self._handle_backwards_compat(view)
+
page_size = self.get_page_size(request)
if not page_size:
return None
paginator = DjangoPaginator(queryset, page_size)
- page_string = request.query_params.get(self.page_query_param, 1)
- try:
- page_number = paginator.validate_number(page_string)
- except InvalidPage:
- if page_string == 'last':
- page_number = paginator.num_pages
- else:
- msg = _(
- 'Choose a valid page number. Page numbers must be a '
- 'whole number, or must be the string "last".'
- )
- raise NotFound(msg)
+ page_number = request.query_params.get(self.page_query_param, 1)
+ if page_number in self.last_page_strings:
+ page_number = paginator.num_pages
try:
self.page = paginator.page(page_number)
@@ -104,6 +205,10 @@ class PageNumberPagination(BasePagination):
)
raise NotFound(msg)
+ if paginator.count > 1:
+ # The browsable API should display pagination controls.
+ self.display_page_controls = True
+
self.request = request
return self.page
@@ -139,8 +244,35 @@ class PageNumberPagination(BasePagination):
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)
return replace_query_param(url, self.page_query_param, page_number)
+ def get_html_context(self):
+ base_url = self.request.build_absolute_uri()
+
+ 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)
+
+ return {
+ 'previous_url': self.get_previous_link(),
+ 'next_url': self.get_next_link(),
+ 'page_links': page_links
+ }
+
+ def to_html(self):
+ template = loader.get_template(self.template)
+ context = Context(self.get_html_context())
+ return template.render(context)
+
class LimitOffsetPagination(BasePagination):
"""
@@ -153,12 +285,15 @@ class LimitOffsetPagination(BasePagination):
limit_query_param = 'limit'
offset_query_param = 'offset'
max_limit = None
+ template = 'rest_framework/pagination/numbers.html'
- def paginate_queryset(self, queryset, request, view):
+ def paginate_queryset(self, queryset, request, view=None):
self.limit = self.get_limit(request)
self.offset = self.get_offset(request)
self.count = _get_count(queryset)
self.request = request
+ if self.count > self.limit:
+ self.display_page_controls = True
return queryset[self.offset:self.offset + self.limit]
def get_paginated_response(self, data):
@@ -189,16 +324,56 @@ class LimitOffsetPagination(BasePagination):
except (KeyError, ValueError):
return 0
- def get_next_link(self, page):
+ def get_next_link(self):
if self.offset + self.limit >= self.count:
return None
+
url = self.request.build_absolute_uri()
offset = self.offset + self.limit
return replace_query_param(url, self.offset_query_param, offset)
- def get_previous_link(self, page):
- if self.offset - self.limit < 0:
+ def get_previous_link(self):
+ if self.offset <= 0:
return None
+
url = self.request.build_absolute_uri()
+
+ if self.offset - self.limit <= 0:
+ return remove_query_param(url, self.offset_query_param)
+
offset = self.offset - self.limit
return replace_query_param(url, self.offset_query_param, offset)
+
+ def get_html_context(self):
+ base_url = self.request.build_absolute_uri()
+ 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)
+ )
+
+ 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)
+
+ return {
+ 'previous_url': self.get_previous_link(),
+ 'next_url': self.get_next_link(),
+ 'page_links': page_links
+ }
+
+ def to_html(self):
+ template = loader.get_template(self.template)
+ context = Context(self.get_html_context())
+ return template.render(context)
diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py
index c4de30db7..4c46b049f 100644
--- a/rest_framework/renderers.py
+++ b/rest_framework/renderers.py
@@ -584,6 +584,11 @@ class BrowsableAPIRenderer(BaseRenderer):
renderer_content_type += ' ;%s' % renderer.charset
response_headers['Content-Type'] = renderer_content_type
+ if hasattr(view, 'paginator') and view.paginator.display_page_controls:
+ paginator = view.paginator
+ else:
+ paginator = None
+
context = {
'content': self.get_content(renderer, data, accepted_media_type, renderer_context),
'view': view,
@@ -592,6 +597,7 @@ class BrowsableAPIRenderer(BaseRenderer):
'description': self.get_description(view),
'name': self.get_name(view),
'version': VERSION,
+ 'paginator': paginator,
'breadcrumblist': self.get_breadcrumbs(request),
'allowed_methods': view.allowed_methods,
'available_formats': [renderer_cls.format for renderer_cls in view.renderer_classes],
diff --git a/rest_framework/static/rest_framework/css/bootstrap-tweaks.css b/rest_framework/static/rest_framework/css/bootstrap-tweaks.css
index 36c7be481..15b42178f 100644
--- a/rest_framework/static/rest_framework/css/bootstrap-tweaks.css
+++ b/rest_framework/static/rest_framework/css/bootstrap-tweaks.css
@@ -60,6 +60,13 @@ a single block in the template.
color: #C20000;
}
+.pagination>.disabled>a,
+.pagination>.disabled>a:hover,
+.pagination>.disabled>a:focus {
+ cursor: default;
+ pointer-events: none;
+}
+
/*=== dabapps bootstrap styles ====*/
html {
@@ -185,10 +192,6 @@ body a:hover {
color: #c20000;
}
-#content a span {
- text-decoration: underline;
- }
-
.request-info {
clear:both;
}
diff --git a/rest_framework/templates/rest_framework/base.html b/rest_framework/templates/rest_framework/base.html
index e96681932..877387f28 100644
--- a/rest_framework/templates/rest_framework/base.html
+++ b/rest_framework/templates/rest_framework/base.html
@@ -119,9 +119,18 @@