aboutsummaryrefslogtreecommitdiffstats
path: root/rest_framework
diff options
context:
space:
mode:
authorTom Christie2015-01-16 20:36:09 +0000
committerTom Christie2015-01-16 20:36:09 +0000
commitdc18040ba47325afb38ae62042a6103bfd794c4b (patch)
treef36aecf23dfe09aceba53c218350e9c2032263fe /rest_framework
parentf13fcba9a9f41f7e00e0ea8956fcc65ca168c76c (diff)
parent86d2774cf30351fd4174e97501532056ed0d8f95 (diff)
downloaddjango-rest-framework-dc18040ba47325afb38ae62042a6103bfd794c4b.tar.bz2
Merge pull request #2419 from tomchristie/include-pagination-in-browsable-api
Include pagination control in browsable API.
Diffstat (limited to 'rest_framework')
-rw-r--r--rest_framework/generics.py28
-rw-r--r--rest_framework/pagination.py221
-rw-r--r--rest_framework/renderers.py6
-rw-r--r--rest_framework/static/rest_framework/css/bootstrap-tweaks.css11
-rw-r--r--rest_framework/templates/rest_framework/base.html9
-rw-r--r--rest_framework/templates/rest_framework/pagination/numbers.html27
-rw-r--r--rest_framework/templatetags/rest_framework.py21
-rw-r--r--rest_framework/utils/urls.py25
8 files changed, 296 insertions, 52 deletions
diff --git a/rest_framework/generics.py b/rest_framework/generics.py
index cdf6ece0..61dcb84a 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 b9d48796..1b7524c6 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 c4de30db..4c46b049 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 36c7be48..15b42178 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 e9668193..877387f2 100644
--- a/rest_framework/templates/rest_framework/base.html
+++ b/rest_framework/templates/rest_framework/base.html
@@ -119,9 +119,18 @@
<div class="page-header">
<h1>{{ name }}</h1>
</div>
+ <div style="float:left">
{% block description %}
{{ description }}
{% endblock %}
+ </div>
+
+ {% if paginator %}
+ <nav style="float: right">
+ {% get_pagination_html paginator %}
+ </nav>
+ {% endif %}
+
<div class="request-info" style="clear: both" >
<pre class="prettyprint"><b>{{ request.method }}</b> {{ request.get_full_path }}</pre>
</div>
diff --git a/rest_framework/templates/rest_framework/pagination/numbers.html b/rest_framework/templates/rest_framework/pagination/numbers.html
new file mode 100644
index 00000000..04045810
--- /dev/null
+++ b/rest_framework/templates/rest_framework/pagination/numbers.html
@@ -0,0 +1,27 @@
+<ul class="pagination" style="margin: 5px 0 10px 0">
+ {% if previous_url %}
+ <li><a href="{{ previous_url }}" aria-label="Previous"><span aria-hidden="true">&laquo;</span></a></li>
+ {% else %}
+ <li class="disabled"><a href="#" aria-label="Previous"><span aria-hidden="true">&laquo;</span></a></li>
+ {% endif %}
+
+ {% for page_link in page_links %}
+ {% if page_link.is_break %}
+ <li class="disabled">
+ <a href="#"><span aria-hidden="true">&hellip;</span></a>
+ </li>
+ {% else %}
+ {% if page_link.is_active %}
+ <li class="active"><a href="{{ page_link.url }}">{{ page_link.number }}</a></li>
+ {% else %}
+ <li><a href="{{ page_link.url }}">{{ page_link.number }}</a></li>
+ {% endif %}
+ {% endif %}
+ {% endfor %}
+
+ {% if next_url %}
+ <li><a href="{{ next_url }}" aria-label="Next"><span aria-hidden="true">&raquo;</span></a></li>
+ {% else %}
+ <li class="disabled"><a href="#" aria-label="Next"><span aria-hidden="true">&raquo;</span></a></li>
+ {% endif %}
+</ul>
diff --git a/rest_framework/templatetags/rest_framework.py b/rest_framework/templatetags/rest_framework.py
index 69e03af4..a969836f 100644
--- a/rest_framework/templatetags/rest_framework.py
+++ b/rest_framework/templatetags/rest_framework.py
@@ -1,36 +1,25 @@
from __future__ import unicode_literals, absolute_import
from django import template
from django.core.urlresolvers import reverse, NoReverseMatch
-from django.http import QueryDict
from django.utils import six
-from django.utils.six.moves.urllib import parse as urlparse
from django.utils.encoding import iri_to_uri, force_text
from django.utils.html import escape
from django.utils.safestring import SafeData, mark_safe
from django.utils.html import smart_urlquote
from rest_framework.renderers import HTMLFormRenderer
+from rest_framework.utils.urls import replace_query_param
import re
register = template.Library()
-
-def replace_query_param(url, key, val):
- """
- Given a URL and a key/val pair, set or replace an item in the query
- parameters of the URL, and return the new URL.
- """
- (scheme, netloc, path, query, fragment) = urlparse.urlsplit(url)
- query_dict = QueryDict(query).copy()
- query_dict[key] = val
- query = query_dict.urlencode()
- return urlparse.urlunsplit((scheme, netloc, path, query, fragment))
-
-
# Regex for adding classes to html snippets
class_re = re.compile(r'(?<=class=["\'])(.*)(?=["\'])')
-# And the template tags themselves...
+@register.simple_tag
+def get_pagination_html(pager):
+ return pager.to_html()
+
@register.simple_tag
def render_field(field, style=None):
diff --git a/rest_framework/utils/urls.py b/rest_framework/utils/urls.py
new file mode 100644
index 00000000..880ef9ed
--- /dev/null
+++ b/rest_framework/utils/urls.py
@@ -0,0 +1,25 @@
+from django.utils.six.moves.urllib import parse as urlparse
+
+
+def replace_query_param(url, key, val):
+ """
+ Given a URL and a key/val pair, set or replace an item in the query
+ parameters of the URL, and return the new URL.
+ """
+ (scheme, netloc, path, query, fragment) = urlparse.urlsplit(url)
+ query_dict = urlparse.parse_qs(query)
+ query_dict[key] = [val]
+ query = urlparse.urlencode(sorted(list(query_dict.items())), doseq=True)
+ return urlparse.urlunsplit((scheme, netloc, path, query, fragment))
+
+
+def remove_query_param(url, key):
+ """
+ Given a URL and a key/val pair, remove an item in the query
+ parameters of the URL, and return the new URL.
+ """
+ (scheme, netloc, path, query, fragment) = urlparse.urlsplit(url)
+ query_dict = urlparse.parse_qs(query)
+ query_dict.pop(key, None)
+ query = urlparse.urlencode(sorted(list(query_dict.items())), doseq=True)
+ return urlparse.urlunsplit((scheme, netloc, path, query, fragment))