diff options
| -rw-r--r-- | docs/api-guide/pagination.md | 21 | ||||
| -rw-r--r-- | docs/img/link-header-pagination.png | bin | 0 -> 35799 bytes | |||
| -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 | ||||
| -rw-r--r-- | tests/test_metadata.py | 30 | ||||
| -rw-r--r-- | tests/test_pagination.py | 734 | 
12 files changed, 764 insertions, 369 deletions
| diff --git a/docs/api-guide/pagination.md b/docs/api-guide/pagination.md index 9fbeb22a..8ab2edd5 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.pngBinary files differ new file mode 100644 index 00000000..d3c556a4 --- /dev/null +++ b/docs/img/link-header-pagination.png 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)) diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 5ff59c72..972a896a 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -1,9 +1,7 @@  from __future__ import unicode_literals - -from rest_framework import exceptions, serializers, views +from rest_framework import exceptions, serializers, status, views  from rest_framework.request import Request  from rest_framework.test import APIRequestFactory -import pytest  request = Request(APIRequestFactory().options('/')) @@ -17,7 +15,8 @@ class TestMetadata:              """Example view."""              pass -        response = ExampleView().options(request=request) +        view = ExampleView.as_view() +        response = view(request=request)          expected = {              'name': 'Example',              'description': 'Example view.', @@ -31,7 +30,7 @@ class TestMetadata:                  'multipart/form-data'              ]          } -        assert response.status_code == 200 +        assert response.status_code == status.HTTP_200_OK          assert response.data == expected      def test_none_metadata(self): @@ -42,8 +41,10 @@ class TestMetadata:          class ExampleView(views.APIView):              metadata_class = None -        with pytest.raises(exceptions.MethodNotAllowed): -            ExampleView().options(request=request) +        view = ExampleView.as_view() +        response = view(request=request) +        assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED +        assert response.data == {'detail': 'Method "OPTIONS" not allowed.'}      def test_actions(self):          """ @@ -63,7 +64,8 @@ class TestMetadata:              def get_serializer(self):                  return ExampleSerializer() -        response = ExampleView().options(request=request) +        view = ExampleView.as_view() +        response = view(request=request)          expected = {              'name': 'Example',              'description': 'Example view.', @@ -104,7 +106,7 @@ class TestMetadata:                  }              }          } -        assert response.status_code == 200 +        assert response.status_code == status.HTTP_200_OK          assert response.data == expected      def test_global_permissions(self): @@ -132,8 +134,9 @@ class TestMetadata:                  if request.method == 'POST':                      raise exceptions.PermissionDenied() -        response = ExampleView().options(request=request) -        assert response.status_code == 200 +        view = ExampleView.as_view() +        response = view(request=request) +        assert response.status_code == status.HTTP_200_OK          assert list(response.data['actions'].keys()) == ['PUT']      def test_object_permissions(self): @@ -161,6 +164,7 @@ class TestMetadata:                  if self.request.method == 'PUT':                      raise exceptions.PermissionDenied() -        response = ExampleView().options(request=request) -        assert response.status_code == 200 +        view = ExampleView.as_view() +        response = view(request=request) +        assert response.status_code == status.HTTP_200_OK          assert list(response.data['actions'].keys()) == ['POST'] diff --git a/tests/test_pagination.py b/tests/test_pagination.py index d410cd5e..7cc92347 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -1,339 +1,475 @@  from __future__ import unicode_literals -import datetime -from decimal import Decimal -from django.test import TestCase -from django.utils import unittest -from rest_framework import generics, serializers, status, filters -from rest_framework.compat import django_filters +from rest_framework import exceptions, generics, pagination, serializers, status, filters +from rest_framework.request import Request +from rest_framework.pagination import PageLink, PAGE_BREAK  from rest_framework.test import APIRequestFactory -from .models import BasicModel, FilterableItem +import pytest  factory = APIRequestFactory() -# Helper function to split arguments out of an url -def split_arguments_from_url(url): -    if '?' not in url: -        return url - -    path, args = url.split('?') -    args = dict(r.split('=') for r in args.split('&')) -    return path, args - - -class BasicSerializer(serializers.ModelSerializer): -    class Meta: -        model = BasicModel +class TestPaginationIntegration: +    """ +    Integration tests. +    """ +    def setup(self): +        class PassThroughSerializer(serializers.BaseSerializer): +            def to_representation(self, item): +                return item -class FilterableItemSerializer(serializers.ModelSerializer): -    class Meta: -        model = FilterableItem +        class EvenItemsOnly(filters.BaseFilterBackend): +            def filter_queryset(self, request, queryset, view): +                return [item for item in queryset if item % 2 == 0] + +        class BasicPagination(pagination.PageNumberPagination): +            paginate_by = 5 +            paginate_by_param = 'page_size' +            max_paginate_by = 20 + +        self.view = generics.ListAPIView.as_view( +            serializer_class=PassThroughSerializer, +            queryset=range(1, 101), +            filter_backends=[EvenItemsOnly], +            pagination_class=BasicPagination +        ) + +    def test_filtered_items_are_paginated(self): +        request = factory.get('/', {'page': 2}) +        response = self.view(request) +        assert response.status_code == status.HTTP_200_OK +        assert response.data == { +            'results': [12, 14, 16, 18, 20], +            'previous': 'http://testserver/', +            'next': 'http://testserver/?page=3', +            'count': 50 +        } + +    def test_setting_page_size(self): +        """ +        When 'paginate_by_param' is set, the client may choose a page size. +        """ +        request = factory.get('/', {'page_size': 10}) +        response = self.view(request) +        assert response.status_code == status.HTTP_200_OK +        assert response.data == { +            'results': [2, 4, 6, 8, 10, 12, 14, 16, 18, 20], +            'previous': None, +            'next': 'http://testserver/?page=2&page_size=10', +            'count': 50 +        } + +    def test_setting_page_size_over_maximum(self): +        """ +        When page_size parameter exceeds maxiumum allowable, +        then it should be capped to the maxiumum. +        """ +        request = factory.get('/', {'page_size': 1000}) +        response = self.view(request) +        assert response.status_code == status.HTTP_200_OK +        assert response.data == { +            'results': [ +                2, 4, 6, 8, 10, 12, 14, 16, 18, 20, +                22, 24, 26, 28, 30, 32, 34, 36, 38, 40 +            ], +            'previous': None, +            'next': 'http://testserver/?page=2&page_size=1000', +            'count': 50 +        } + +    def test_additional_query_params_are_preserved(self): +        request = factory.get('/', {'page': 2, 'filter': 'even'}) +        response = self.view(request) +        assert response.status_code == status.HTTP_200_OK +        assert response.data == { +            'results': [12, 14, 16, 18, 20], +            'previous': 'http://testserver/?filter=even', +            'next': 'http://testserver/?filter=even&page=3', +            'count': 50 +        } + +    def test_404_not_found_for_invalid_page(self): +        request = factory.get('/', {'page': 'invalid'}) +        response = self.view(request) +        assert response.status_code == status.HTTP_404_NOT_FOUND +        assert response.data == { +            'detail': 'Invalid page "invalid": That page number is not an integer.' +        } -class RootView(generics.ListCreateAPIView): +class TestPaginationDisabledIntegration:      """ -    Example description for OPTIONS. +    Integration tests for disabled pagination.      """ -    queryset = BasicModel.objects.all() -    serializer_class = BasicSerializer -    paginate_by = 10 +    def setup(self): +        class PassThroughSerializer(serializers.BaseSerializer): +            def to_representation(self, item): +                return item -class DefaultPageSizeKwargView(generics.ListAPIView): -    """ -    View for testing default paginate_by_param usage -    """ -    queryset = BasicModel.objects.all() -    serializer_class = BasicSerializer +        self.view = generics.ListAPIView.as_view( +            serializer_class=PassThroughSerializer, +            queryset=range(1, 101), +            pagination_class=None +        ) - -class PaginateByParamView(generics.ListAPIView): -    """ -    View for testing custom paginate_by_param usage -    """ -    queryset = BasicModel.objects.all() -    serializer_class = BasicSerializer -    paginate_by_param = 'page_size' +    def test_unpaginated_list(self): +        request = factory.get('/', {'page': 2}) +        response = self.view(request) +        assert response.status_code == status.HTTP_200_OK +        assert response.data == list(range(1, 101)) -class MaxPaginateByView(generics.ListAPIView): +class TestDeprecatedStylePagination:      """ -    View for testing custom max_paginate_by usage +    Integration tests for deprecated style of setting pagination +    attributes on the view.      """ -    queryset = BasicModel.objects.all() -    serializer_class = BasicSerializer -    paginate_by = 3 -    max_paginate_by = 5 -    paginate_by_param = 'page_size' +    def setup(self): +        class PassThroughSerializer(serializers.BaseSerializer): +            def to_representation(self, item): +                return item -class IntegrationTestPagination(TestCase): -    """ -    Integration tests for paginated list views. -    """ +        class ExampleView(generics.ListAPIView): +            serializer_class = PassThroughSerializer +            queryset = range(1, 101) +            pagination_class = pagination.PageNumberPagination +            paginate_by = 20 +            page_query_param = 'page_number' -    def setUp(self): -        """ -        Create 26 BasicModel instances. -        """ -        for char in 'abcdefghijklmnopqrstuvwxyz': -            BasicModel(text=char * 3).save() -        self.objects = BasicModel.objects -        self.data = [ -            {'id': obj.id, 'text': obj.text} -            for obj in self.objects.all() -        ] -        self.view = RootView.as_view() - -    def test_get_paginated_root_view(self): -        """ -        GET requests to paginated ListCreateAPIView should return paginated results. -        """ -        request = factory.get('/') -        # Note: Database queries are a `SELECT COUNT`, and `SELECT <fields>` -        with self.assertNumQueries(2): -            response = self.view(request).render() -        self.assertEqual(response.status_code, status.HTTP_200_OK) -        self.assertEqual(response.data['count'], 26) -        self.assertEqual(response.data['results'], self.data[:10]) -        self.assertNotEqual(response.data['next'], None) -        self.assertEqual(response.data['previous'], None) - -        request = factory.get(*split_arguments_from_url(response.data['next'])) -        with self.assertNumQueries(2): -            response = self.view(request).render() -        self.assertEqual(response.status_code, status.HTTP_200_OK) -        self.assertEqual(response.data['count'], 26) -        self.assertEqual(response.data['results'], self.data[10:20]) -        self.assertNotEqual(response.data['next'], None) -        self.assertNotEqual(response.data['previous'], None) - -        request = factory.get(*split_arguments_from_url(response.data['next'])) -        with self.assertNumQueries(2): -            response = self.view(request).render() -        self.assertEqual(response.status_code, status.HTTP_200_OK) -        self.assertEqual(response.data['count'], 26) -        self.assertEqual(response.data['results'], self.data[20:]) -        self.assertEqual(response.data['next'], None) -        self.assertNotEqual(response.data['previous'], None) - - -class IntegrationTestPaginationAndFiltering(TestCase): - -    def setUp(self): -        """ -        Create 50 FilterableItem instances. -        """ -        base_data = ('a', Decimal('0.25'), datetime.date(2012, 10, 8)) -        for i in range(26): -            text = chr(i + ord(base_data[0])) * 3  # Produces string 'aaa', 'bbb', etc. -            decimal = base_data[1] + i -            date = base_data[2] - datetime.timedelta(days=i * 2) -            FilterableItem(text=text, decimal=decimal, date=date).save() - -        self.objects = FilterableItem.objects -        self.data = [ -            {'id': obj.id, 'text': obj.text, 'decimal': str(obj.decimal), 'date': obj.date.isoformat()} -            for obj in self.objects.all() -        ] - -    @unittest.skipUnless(django_filters, 'django-filter not installed') -    def test_get_django_filter_paginated_filtered_root_view(self): -        """ -        GET requests to paginated filtered ListCreateAPIView should return -        paginated results. The next and previous links should preserve the -        filtered parameters. -        """ -        class DecimalFilter(django_filters.FilterSet): -            decimal = django_filters.NumberFilter(lookup_type='lt') - -            class Meta: -                model = FilterableItem -                fields = ['text', 'decimal', 'date'] - -        class FilterFieldsRootView(generics.ListCreateAPIView): -            queryset = FilterableItem.objects.all() -            serializer_class = FilterableItemSerializer -            paginate_by = 10 -            filter_class = DecimalFilter -            filter_backends = (filters.DjangoFilterBackend,) - -        view = FilterFieldsRootView.as_view() - -        EXPECTED_NUM_QUERIES = 2 - -        request = factory.get('/', {'decimal': '15.20'}) -        with self.assertNumQueries(EXPECTED_NUM_QUERIES): -            response = view(request).render() -        self.assertEqual(response.status_code, status.HTTP_200_OK) -        self.assertEqual(response.data['count'], 15) -        self.assertEqual(response.data['results'], self.data[:10]) -        self.assertNotEqual(response.data['next'], None) -        self.assertEqual(response.data['previous'], None) - -        request = factory.get(*split_arguments_from_url(response.data['next'])) -        with self.assertNumQueries(EXPECTED_NUM_QUERIES): -            response = view(request).render() -        self.assertEqual(response.status_code, status.HTTP_200_OK) -        self.assertEqual(response.data['count'], 15) -        self.assertEqual(response.data['results'], self.data[10:15]) -        self.assertEqual(response.data['next'], None) -        self.assertNotEqual(response.data['previous'], None) - -        request = factory.get(*split_arguments_from_url(response.data['previous'])) -        with self.assertNumQueries(EXPECTED_NUM_QUERIES): -            response = view(request).render() -        self.assertEqual(response.status_code, status.HTTP_200_OK) -        self.assertEqual(response.data['count'], 15) -        self.assertEqual(response.data['results'], self.data[:10]) -        self.assertNotEqual(response.data['next'], None) -        self.assertEqual(response.data['previous'], None) - -    def test_get_basic_paginated_filtered_root_view(self): -        """ -        Same as `test_get_django_filter_paginated_filtered_root_view`, -        except using a custom filter backend instead of the django-filter -        backend, -        """ +        self.view = ExampleView.as_view() -        class DecimalFilterBackend(filters.BaseFilterBackend): -            def filter_queryset(self, request, queryset, view): -                return queryset.filter(decimal__lt=Decimal(request.GET['decimal'])) - -        class BasicFilterFieldsRootView(generics.ListCreateAPIView): -            queryset = FilterableItem.objects.all() -            serializer_class = FilterableItemSerializer -            paginate_by = 10 -            filter_backends = (DecimalFilterBackend,) - -        view = BasicFilterFieldsRootView.as_view() - -        request = factory.get('/', {'decimal': '15.20'}) -        with self.assertNumQueries(2): -            response = view(request).render() -        self.assertEqual(response.status_code, status.HTTP_200_OK) -        self.assertEqual(response.data['count'], 15) -        self.assertEqual(response.data['results'], self.data[:10]) -        self.assertNotEqual(response.data['next'], None) -        self.assertEqual(response.data['previous'], None) - -        request = factory.get(*split_arguments_from_url(response.data['next'])) -        with self.assertNumQueries(2): -            response = view(request).render() -        self.assertEqual(response.status_code, status.HTTP_200_OK) -        self.assertEqual(response.data['count'], 15) -        self.assertEqual(response.data['results'], self.data[10:15]) -        self.assertEqual(response.data['next'], None) -        self.assertNotEqual(response.data['previous'], None) - -        request = factory.get(*split_arguments_from_url(response.data['previous'])) -        with self.assertNumQueries(2): -            response = view(request).render() -        self.assertEqual(response.status_code, status.HTTP_200_OK) -        self.assertEqual(response.data['count'], 15) -        self.assertEqual(response.data['results'], self.data[:10]) -        self.assertNotEqual(response.data['next'], None) -        self.assertEqual(response.data['previous'], None) - - -class TestUnpaginated(TestCase): +    def test_paginate_by_attribute_on_view(self): +        request = factory.get('/?page_number=2') +        response = self.view(request) +        assert response.status_code == status.HTTP_200_OK +        assert response.data == { +            'results': [ +                21, 22, 23, 24, 25, 26, 27, 28, 29, 30, +                31, 32, 33, 34, 35, 36, 37, 38, 39, 40 +            ], +            'previous': 'http://testserver/', +            'next': 'http://testserver/?page_number=3', +            'count': 100 +        } + + +class TestPageNumberPagination:      """ -    Tests for list views without pagination. +    Unit tests for `pagination.PageNumberPagination`.      """ -    def setUp(self): -        """ -        Create 13 BasicModel instances. -        """ -        for i in range(13): -            BasicModel(text=i).save() -        self.objects = BasicModel.objects -        self.data = [ -            {'id': obj.id, 'text': obj.text} -            for obj in self.objects.all() -        ] -        self.view = DefaultPageSizeKwargView.as_view() - -    def test_unpaginated(self): -        """ -        Tests the default page size for this view. -        no page size --> no limit --> no meta data -        """ -        request = factory.get('/') -        response = self.view(request) -        self.assertEqual(response.data, self.data) - - -class TestCustomPaginateByParam(TestCase): +    def setup(self): +        class ExamplePagination(pagination.PageNumberPagination): +            paginate_by = 5 +        self.pagination = ExamplePagination() +        self.queryset = range(1, 101) + +    def paginate_queryset(self, request): +        return list(self.pagination.paginate_queryset(self.queryset, request)) + +    def get_paginated_content(self, queryset): +        response = self.pagination.get_paginated_response(queryset) +        return response.data + +    def get_html_context(self): +        return self.pagination.get_html_context() + +    def test_no_page_number(self): +        request = Request(factory.get('/')) +        queryset = self.paginate_queryset(request) +        content = self.get_paginated_content(queryset) +        context = self.get_html_context() +        assert queryset == [1, 2, 3, 4, 5] +        assert content == { +            'results': [1, 2, 3, 4, 5], +            'previous': None, +            'next': 'http://testserver/?page=2', +            'count': 100 +        } +        assert context == { +            'previous_url': None, +            'next_url': 'http://testserver/?page=2', +            'page_links': [ +                PageLink('http://testserver/', 1, True, False), +                PageLink('http://testserver/?page=2', 2, False, False), +                PageLink('http://testserver/?page=3', 3, False, False), +                PAGE_BREAK, +                PageLink('http://testserver/?page=20', 20, False, False), +            ] +        } +        assert self.pagination.display_page_controls +        assert isinstance(self.pagination.to_html(), type('')) + +    def test_second_page(self): +        request = Request(factory.get('/', {'page': 2})) +        queryset = self.paginate_queryset(request) +        content = self.get_paginated_content(queryset) +        context = self.get_html_context() +        assert queryset == [6, 7, 8, 9, 10] +        assert content == { +            'results': [6, 7, 8, 9, 10], +            'previous': 'http://testserver/', +            'next': 'http://testserver/?page=3', +            'count': 100 +        } +        assert context == { +            'previous_url': 'http://testserver/', +            'next_url': 'http://testserver/?page=3', +            'page_links': [ +                PageLink('http://testserver/', 1, False, False), +                PageLink('http://testserver/?page=2', 2, True, False), +                PageLink('http://testserver/?page=3', 3, False, False), +                PAGE_BREAK, +                PageLink('http://testserver/?page=20', 20, False, False), +            ] +        } + +    def test_last_page(self): +        request = Request(factory.get('/', {'page': 'last'})) +        queryset = self.paginate_queryset(request) +        content = self.get_paginated_content(queryset) +        context = self.get_html_context() +        assert queryset == [96, 97, 98, 99, 100] +        assert content == { +            'results': [96, 97, 98, 99, 100], +            'previous': 'http://testserver/?page=19', +            'next': None, +            'count': 100 +        } +        assert context == { +            'previous_url': 'http://testserver/?page=19', +            'next_url': None, +            'page_links': [ +                PageLink('http://testserver/', 1, False, False), +                PAGE_BREAK, +                PageLink('http://testserver/?page=18', 18, False, False), +                PageLink('http://testserver/?page=19', 19, False, False), +                PageLink('http://testserver/?page=20', 20, True, False), +            ] +        } + +    def test_invalid_page(self): +        request = Request(factory.get('/', {'page': 'invalid'})) +        with pytest.raises(exceptions.NotFound): +            self.paginate_queryset(request) + + +class TestLimitOffset:      """ -    Tests for list views with default page size kwarg +    Unit tests for `pagination.LimitOffsetPagination`.      """ -    def setUp(self): +    def setup(self): +        class ExamplePagination(pagination.LimitOffsetPagination): +            default_limit = 10 +        self.pagination = ExamplePagination() +        self.queryset = range(1, 101) + +    def paginate_queryset(self, request): +        return list(self.pagination.paginate_queryset(self.queryset, request)) + +    def get_paginated_content(self, queryset): +        response = self.pagination.get_paginated_response(queryset) +        return response.data + +    def get_html_context(self): +        return self.pagination.get_html_context() + +    def test_no_offset(self): +        request = Request(factory.get('/', {'limit': 5})) +        queryset = self.paginate_queryset(request) +        content = self.get_paginated_content(queryset) +        context = self.get_html_context() +        assert queryset == [1, 2, 3, 4, 5] +        assert content == { +            'results': [1, 2, 3, 4, 5], +            'previous': None, +            'next': 'http://testserver/?limit=5&offset=5', +            'count': 100 +        } +        assert context == { +            'previous_url': None, +            'next_url': 'http://testserver/?limit=5&offset=5', +            'page_links': [ +                PageLink('http://testserver/?limit=5', 1, True, False), +                PageLink('http://testserver/?limit=5&offset=5', 2, False, False), +                PageLink('http://testserver/?limit=5&offset=10', 3, False, False), +                PAGE_BREAK, +                PageLink('http://testserver/?limit=5&offset=95', 20, False, False), +            ] +        } +        assert self.pagination.display_page_controls +        assert isinstance(self.pagination.to_html(), type('')) + +    def test_single_offset(self):          """ -        Create 13 BasicModel instances. +        When the offset is not a multiple of the limit we get some edge cases: +        * The first page should still be offset zero. +        * We may end up displaying an extra page in the pagination control.          """ -        for i in range(13): -            BasicModel(text=i).save() -        self.objects = BasicModel.objects -        self.data = [ -            {'id': obj.id, 'text': obj.text} -            for obj in self.objects.all() -        ] -        self.view = PaginateByParamView.as_view() - -    def test_default_page_size(self): +        request = Request(factory.get('/', {'limit': 5, 'offset': 1})) +        queryset = self.paginate_queryset(request) +        content = self.get_paginated_content(queryset) +        context = self.get_html_context() +        assert queryset == [2, 3, 4, 5, 6] +        assert content == { +            'results': [2, 3, 4, 5, 6], +            'previous': 'http://testserver/?limit=5', +            'next': 'http://testserver/?limit=5&offset=6', +            'count': 100 +        } +        assert context == { +            'previous_url': 'http://testserver/?limit=5', +            'next_url': 'http://testserver/?limit=5&offset=6', +            'page_links': [ +                PageLink('http://testserver/?limit=5', 1, False, False), +                PageLink('http://testserver/?limit=5&offset=1', 2, True, False), +                PageLink('http://testserver/?limit=5&offset=6', 3, False, False), +                PAGE_BREAK, +                PageLink('http://testserver/?limit=5&offset=96', 21, False, False), +            ] +        } + +    def test_first_offset(self): +        request = Request(factory.get('/', {'limit': 5, 'offset': 5})) +        queryset = self.paginate_queryset(request) +        content = self.get_paginated_content(queryset) +        context = self.get_html_context() +        assert queryset == [6, 7, 8, 9, 10] +        assert content == { +            'results': [6, 7, 8, 9, 10], +            'previous': 'http://testserver/?limit=5', +            'next': 'http://testserver/?limit=5&offset=10', +            'count': 100 +        } +        assert context == { +            'previous_url': 'http://testserver/?limit=5', +            'next_url': 'http://testserver/?limit=5&offset=10', +            'page_links': [ +                PageLink('http://testserver/?limit=5', 1, False, False), +                PageLink('http://testserver/?limit=5&offset=5', 2, True, False), +                PageLink('http://testserver/?limit=5&offset=10', 3, False, False), +                PAGE_BREAK, +                PageLink('http://testserver/?limit=5&offset=95', 20, False, False), +            ] +        } + +    def test_middle_offset(self): +        request = Request(factory.get('/', {'limit': 5, 'offset': 10})) +        queryset = self.paginate_queryset(request) +        content = self.get_paginated_content(queryset) +        context = self.get_html_context() +        assert queryset == [11, 12, 13, 14, 15] +        assert content == { +            'results': [11, 12, 13, 14, 15], +            'previous': 'http://testserver/?limit=5&offset=5', +            'next': 'http://testserver/?limit=5&offset=15', +            'count': 100 +        } +        assert context == { +            'previous_url': 'http://testserver/?limit=5&offset=5', +            'next_url': 'http://testserver/?limit=5&offset=15', +            'page_links': [ +                PageLink('http://testserver/?limit=5', 1, False, False), +                PageLink('http://testserver/?limit=5&offset=5', 2, False, False), +                PageLink('http://testserver/?limit=5&offset=10', 3, True, False), +                PageLink('http://testserver/?limit=5&offset=15', 4, False, False), +                PAGE_BREAK, +                PageLink('http://testserver/?limit=5&offset=95', 20, False, False), +            ] +        } + +    def test_ending_offset(self): +        request = Request(factory.get('/', {'limit': 5, 'offset': 95})) +        queryset = self.paginate_queryset(request) +        content = self.get_paginated_content(queryset) +        context = self.get_html_context() +        assert queryset == [96, 97, 98, 99, 100] +        assert content == { +            'results': [96, 97, 98, 99, 100], +            'previous': 'http://testserver/?limit=5&offset=90', +            'next': None, +            'count': 100 +        } +        assert context == { +            'previous_url': 'http://testserver/?limit=5&offset=90', +            'next_url': None, +            'page_links': [ +                PageLink('http://testserver/?limit=5', 1, False, False), +                PAGE_BREAK, +                PageLink('http://testserver/?limit=5&offset=85', 18, False, False), +                PageLink('http://testserver/?limit=5&offset=90', 19, False, False), +                PageLink('http://testserver/?limit=5&offset=95', 20, True, False), +            ] +        } + +    def test_invalid_offset(self):          """ -        Tests the default page size for this view. -        no page size --> no limit --> no meta data +        An invalid offset query param should be treated as 0.          """ -        request = factory.get('/') -        response = self.view(request).render() -        self.assertEqual(response.data, self.data) +        request = Request(factory.get('/', {'limit': 5, 'offset': 'invalid'})) +        queryset = self.paginate_queryset(request) +        assert queryset == [1, 2, 3, 4, 5] -    def test_paginate_by_param(self): +    def test_invalid_limit(self):          """ -        If paginate_by_param is set, the new kwarg should limit per view requests. +        An invalid limit query param should be ignored in favor of the default.          """ -        request = factory.get('/', {'page_size': 5}) -        response = self.view(request).render() -        self.assertEqual(response.data['count'], 13) -        self.assertEqual(response.data['results'], self.data[:5]) +        request = Request(factory.get('/', {'limit': 'invalid', 'offset': 0})) +        queryset = self.paginate_queryset(request) +        assert queryset == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] -class TestMaxPaginateByParam(TestCase): +def test_get_displayed_page_numbers():      """ -    Tests for list views with max_paginate_by kwarg -    """ - -    def setUp(self): -        """ -        Create 13 BasicModel instances. -        """ -        for i in range(13): -            BasicModel(text=i).save() -        self.objects = BasicModel.objects -        self.data = [ -            {'id': obj.id, 'text': obj.text} -            for obj in self.objects.all() -        ] -        self.view = MaxPaginateByView.as_view() - -    def test_max_paginate_by(self): -        """ -        If max_paginate_by is set, it should limit page size for the view. -        """ -        request = factory.get('/', data={'page_size': 10}) -        response = self.view(request).render() -        self.assertEqual(response.data['count'], 13) -        self.assertEqual(response.data['results'], self.data[:5]) +    Test our contextual page display function. -    def test_max_paginate_by_without_page_size_param(self): -        """ -        If max_paginate_by is set, but client does not specifiy page_size, -        standard `paginate_by` behavior should be used. -        """ -        request = factory.get('/') -        response = self.view(request).render() -        self.assertEqual(response.data['results'], self.data[:3]) +    This determines which pages to display in a pagination control, +    given the current page and the last page. +    """ +    displayed_page_numbers = pagination._get_displayed_page_numbers + +    # At five pages or less, all pages are displayed, always. +    assert displayed_page_numbers(1, 5) == [1, 2, 3, 4, 5] +    assert displayed_page_numbers(2, 5) == [1, 2, 3, 4, 5] +    assert displayed_page_numbers(3, 5) == [1, 2, 3, 4, 5] +    assert displayed_page_numbers(4, 5) == [1, 2, 3, 4, 5] +    assert displayed_page_numbers(5, 5) == [1, 2, 3, 4, 5] + +    # Between six and either pages we may have a single page break. +    assert displayed_page_numbers(1, 6) == [1, 2, 3, None, 6] +    assert displayed_page_numbers(2, 6) == [1, 2, 3, None, 6] +    assert displayed_page_numbers(3, 6) == [1, 2, 3, 4, 5, 6] +    assert displayed_page_numbers(4, 6) == [1, 2, 3, 4, 5, 6] +    assert displayed_page_numbers(5, 6) == [1, None, 4, 5, 6] +    assert displayed_page_numbers(6, 6) == [1, None, 4, 5, 6] + +    assert displayed_page_numbers(1, 7) == [1, 2, 3, None, 7] +    assert displayed_page_numbers(2, 7) == [1, 2, 3, None, 7] +    assert displayed_page_numbers(3, 7) == [1, 2, 3, 4, None, 7] +    assert displayed_page_numbers(4, 7) == [1, 2, 3, 4, 5, 6, 7] +    assert displayed_page_numbers(5, 7) == [1, None, 4, 5, 6, 7] +    assert displayed_page_numbers(6, 7) == [1, None, 5, 6, 7] +    assert displayed_page_numbers(7, 7) == [1, None, 5, 6, 7] + +    assert displayed_page_numbers(1, 8) == [1, 2, 3, None, 8] +    assert displayed_page_numbers(2, 8) == [1, 2, 3, None, 8] +    assert displayed_page_numbers(3, 8) == [1, 2, 3, 4, None, 8] +    assert displayed_page_numbers(4, 8) == [1, 2, 3, 4, 5, None, 8] +    assert displayed_page_numbers(5, 8) == [1, None, 4, 5, 6, 7, 8] +    assert displayed_page_numbers(6, 8) == [1, None, 5, 6, 7, 8] +    assert displayed_page_numbers(7, 8) == [1, None, 6, 7, 8] +    assert displayed_page_numbers(8, 8) == [1, None, 6, 7, 8] + +    # At nine or more pages we may have two page breaks, one on each side. +    assert displayed_page_numbers(1, 9) == [1, 2, 3, None, 9] +    assert displayed_page_numbers(2, 9) == [1, 2, 3, None, 9] +    assert displayed_page_numbers(3, 9) == [1, 2, 3, 4, None, 9] +    assert displayed_page_numbers(4, 9) == [1, 2, 3, 4, 5, None, 9] +    assert displayed_page_numbers(5, 9) == [1, None, 4, 5, 6, None, 9] +    assert displayed_page_numbers(6, 9) == [1, None, 5, 6, 7, 8, 9] +    assert displayed_page_numbers(7, 9) == [1, None, 6, 7, 8, 9] +    assert displayed_page_numbers(8, 9) == [1, None, 7, 8, 9] +    assert displayed_page_numbers(9, 9) == [1, None, 7, 8, 9] | 
