diff options
Diffstat (limited to 'rest_framework/pagination.py')
| -rw-r--r-- | rest_framework/pagination.py | 221 | 
1 files changed, 198 insertions, 23 deletions
| 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) | 
