diff options
| author | Tom Christie | 2015-01-16 20:36:09 +0000 | 
|---|---|---|
| committer | Tom Christie | 2015-01-16 20:36:09 +0000 | 
| commit | dc18040ba47325afb38ae62042a6103bfd794c4b (patch) | |
| tree | f36aecf23dfe09aceba53c218350e9c2032263fe /rest_framework | |
| parent | f13fcba9a9f41f7e00e0ea8956fcc65ca168c76c (diff) | |
| parent | 86d2774cf30351fd4174e97501532056ed0d8f95 (diff) | |
| download | django-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.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)) | 
