diff options
Diffstat (limited to 'rest_framework')
| -rw-r--r-- | rest_framework/generics.py | 28 | ||||
| -rw-r--r-- | rest_framework/pagination.py | 221 | ||||
| -rw-r--r-- | rest_framework/renderers.py | 6 | ||||
| -rw-r--r-- | rest_framework/static/rest_framework/css/bootstrap-tweaks.css | 11 | ||||
| -rw-r--r-- | rest_framework/templates/rest_framework/base.html | 9 | ||||
| -rw-r--r-- | rest_framework/templates/rest_framework/pagination/numbers.html | 27 | ||||
| -rw-r--r-- | rest_framework/templatetags/rest_framework.py | 21 | ||||
| -rw-r--r-- | rest_framework/utils/urls.py | 25 |
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">«</span></a></li> + {% else %} + <li class="disabled"><a href="#" aria-label="Previous"><span aria-hidden="true">«</span></a></li> + {% endif %} + + {% for page_link in page_links %} + {% if page_link.is_break %} + <li class="disabled"> + <a href="#"><span aria-hidden="true">…</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">»</span></a></li> + {% else %} + <li class="disabled"><a href="#" aria-label="Next"><span aria-hidden="true">»</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)) |
