From 3833a5bb8a9174e5fb09dac59a964eff24b6065e Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 14 Jan 2015 16:51:26 +0000 Subject: [PATCH 1/8] Include pagination control in browsable API --- rest_framework/pagination.py | 90 ++++++++++++++++++- rest_framework/renderers.py | 1 + .../rest_framework/css/bootstrap-tweaks.css | 4 - .../templates/rest_framework/base.html | 9 ++ .../rest_framework/pagination/numbers.html | 27 ++++++ rest_framework/templatetags/rest_framework.py | 17 ++++ 6 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 rest_framework/templates/rest_framework/pagination/numbers.html diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index b9d487968..bd343c0dd 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.templatetags.rest_framework import ( + replace_query_param, remove_query_param +) def _strict_positive_int(integer_string, cutoff=None): @@ -35,6 +39,49 @@ def _get_count(queryset): 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] + """ + assert current >= 1 + assert final >= current + + # 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) + if current == final - 3: + included.add(final - 1) + + # 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 + + +PageLink = namedtuple('PageLink', ['url', 'number', 'is_active', 'is_break']) + + class BasePagination(object): def paginate_queryset(self, queryset, request, view): raise NotImplemented('paginate_queryset() must be implemented.') @@ -66,6 +113,8 @@ class PageNumberPagination(BasePagination): # Only relevant if 'paginate_by_param' has also been set. max_paginate_by = api_settings.MAX_PAGINATE_BY + template = 'rest_framework/pagination/numbers.html' + def paginate_queryset(self, queryset, request, view): """ Paginate a queryset if required, either returning a @@ -104,6 +153,8 @@ class PageNumberPagination(BasePagination): ) raise NotFound(msg) + # Indicate that the browsable API should display pagination controls. + self.mark_as_used = True self.request = request return self.page @@ -139,8 +190,45 @@ 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 to_html(self): + current = self.page.number + final = self.page.paginator.num_pages + + page_links = [] + base_url = self.request.build_absolute_uri() + for page_number in _get_displayed_page_numbers(current, final): + if page_number is None: + page_link = PageLink( + url=None, + number=None, + is_active=False, + is_break=True + ) + else: + if page_number == 1: + url = remove_query_param(base_url, self.page_query_param) + else: + url = replace_query_param(url, self.page_query_param, page_number) + page_link = PageLink( + url=url, + number=page_number, + is_active=(page_number == current), + is_break=False + ) + page_links.append(page_link) + + template = loader.get_template(self.template) + context = Context({ + 'previous_url': self.get_previous_link(), + 'next_url': self.get_next_link(), + 'page_links': page_links + }) + return template.render(context) + class LimitOffsetPagination(BasePagination): """ diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index c4de30db7..4c002b168 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -592,6 +592,7 @@ class BrowsableAPIRenderer(BaseRenderer): 'description': self.get_description(view), 'name': self.get_name(view), 'version': VERSION, + 'pager': getattr(view, 'pager', None), '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 36c7be481..d4a7d31a2 100644 --- a/rest_framework/static/rest_framework/css/bootstrap-tweaks.css +++ b/rest_framework/static/rest_framework/css/bootstrap-tweaks.css @@ -185,10 +185,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 e96681932..e00309811 100644 --- a/rest_framework/templates/rest_framework/base.html +++ b/rest_framework/templates/rest_framework/base.html @@ -119,9 +119,18 @@ +
{% block description %} {{ description }} {% endblock %} +
+ + {% if pager.mark_as_used %} + + {% endif %} +
{{ request.method }} {{ request.get_full_path }}
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 000000000..040458104 --- /dev/null +++ b/rest_framework/templates/rest_framework/pagination/numbers.html @@ -0,0 +1,27 @@ + diff --git a/rest_framework/templatetags/rest_framework.py b/rest_framework/templatetags/rest_framework.py index 69e03af40..bf159d8b1 100644 --- a/rest_framework/templatetags/rest_framework.py +++ b/rest_framework/templatetags/rest_framework.py @@ -26,6 +26,23 @@ def replace_query_param(url, key, val): return urlparse.urlunsplit((scheme, netloc, path, query, fragment)) +def remove_query_param(url, key): + """ + 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.pop(key, None) + query = query_dict.urlencode() + return urlparse.urlunsplit((scheme, netloc, path, query, fragment)) + + +@register.simple_tag +def get_pagination_html(pager): + return pager.to_html() + + # Regex for adding classes to html snippets class_re = re.compile(r'(?<=class=["\'])(.*)(?=["\'])') From 313aa727e3c44016e531a7af75051fc6e6d7cb96 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 14 Jan 2015 17:46:41 +0000 Subject: [PATCH 2/8] Tweaks --- docs/api-guide/pagination.md | 19 +++++++++++++++---- docs/img/link-header-pagination.png | Bin 0 -> 35799 bytes rest_framework/pagination.py | 12 ++++++++++-- 3 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 docs/img/link-header-pagination.png diff --git a/docs/api-guide/pagination.md b/docs/api-guide/pagination.md index 9fbeb22a0..ba71a3032 100644 --- a/docs/api-guide/pagination.md +++ b/docs/api-guide/pagination.md @@ -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.png new file mode 100644 index 0000000000000000000000000000000000000000..d3c556a4d6aebc3dc1575c43eced4059d864bab2 GIT binary patch literal 35799 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

FdM1>*;W0Ix=Z$X}_;J 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 Hu0mjfzM=e` literal 0 HcmV?d00001 diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index bd343c0dd..69d0f77d3 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -46,6 +46,12 @@ def _get_displayed_page_numbers(current, final): For example: current=14, final=16 -> [1, None, 13, 14, 15, 16] + + This implementation gives one page to each side of the cursor, + for an implementation which gives two pages to each side of the cursor, + which is a copy of how GitHub treat pagination in their issue lists, see: + + https://gist.github.com/tomchristie/321140cebb1c4a558b15 """ assert current >= 1 assert final >= current @@ -60,10 +66,12 @@ def _get_displayed_page_numbers(current, 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: + if current <= 4: included.add(2) - if current == final - 3: + 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 = [ From d76e83dd78627a0cf4bcd4b28a7710fb678d8d4e Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 15 Jan 2015 16:52:07 +0000 Subject: [PATCH 3/8] Tweaks, and add pagination controls for offset/limit. --- rest_framework/generics.py | 16 +-- rest_framework/pagination.py | 126 +++++++++++++----- rest_framework/renderers.py | 7 +- .../rest_framework/css/bootstrap-tweaks.css | 7 + .../templates/rest_framework/base.html | 4 +- 5 files changed, 119 insertions(+), 41 deletions(-) diff --git a/rest_framework/generics.py b/rest_framework/generics.py index cdf6ece08..4cc4c64d2 100644 --- a/rest_framework/generics.py +++ b/rest_framework/generics.py @@ -150,21 +150,21 @@ class GenericAPIView(views.APIView): return queryset @property - def pager(self): - if not hasattr(self, '_pager'): + def paginator(self): + 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: + if self.paginator is None: return queryset - return self.pager.paginate_queryset(queryset, self.request, view=self) + return self.paginator.paginate_queryset(queryset, self.request, view=self) def get_paginated_response(self, data): - return self.pager.get_paginated_response(data) + 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 69d0f77d3..2b78f1f7f 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -29,6 +29,15 @@ 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. @@ -48,14 +57,21 @@ def _get_displayed_page_numbers(current, final): current=14, final=16 -> [1, None, 13, 14, 15, 16] This implementation gives one page to each side of the cursor, - for an implementation which gives two pages to each side of the cursor, - which is a copy of how GitHub treat pagination in their issue lists, see: + 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 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(( @@ -87,16 +103,46 @@ def _get_displayed_page_numbers(current, final): 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 = PageLink( + url=None, + number=None, + is_active=False, + is_break=True + ) + 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']) class BasePagination(object): + display_page_controls = False + def paginate_queryset(self, queryset, request, view): raise NotImplemented('paginate_queryset() must be implemented.') def get_paginated_response(self, data): raise NotImplemented('get_paginated_response() must be implemented.') + def to_html(self): + raise NotImplemented('to_html() must be implemented to display page controls.') + class PageNumberPagination(BasePagination): """ @@ -161,8 +207,9 @@ class PageNumberPagination(BasePagination): ) raise NotFound(msg) - # Indicate that the browsable API should display pagination controls. - self.mark_as_used = True + if paginator.count > 1: + # The browsable API should display pagination controls. + self.display_page_controls = True self.request = request return self.page @@ -203,31 +250,17 @@ class PageNumberPagination(BasePagination): return replace_query_param(url, self.page_query_param, page_number) def to_html(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_links = [] - base_url = self.request.build_absolute_uri() - for page_number in _get_displayed_page_numbers(current, final): - if page_number is None: - page_link = PageLink( - url=None, - number=None, - is_active=False, - is_break=True - ) - else: - if page_number == 1: - url = remove_query_param(base_url, self.page_query_param) - else: - url = replace_query_param(url, self.page_query_param, page_number) - page_link = PageLink( - url=url, - number=page_number, - is_active=(page_number == current), - is_break=False - ) - page_links.append(page_link) + page_numbers = _get_displayed_page_numbers(current, final) + page_links = _get_page_links(page_numbers, current, page_number_to_url) template = loader.get_template(self.template) context = Context({ @@ -250,11 +283,15 @@ class LimitOffsetPagination(BasePagination): offset_query_param = 'offset' max_limit = None + template = 'rest_framework/pagination/numbers.html' + def paginate_queryset(self, queryset, request, view): 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): @@ -285,16 +322,45 @@ 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 to_html(self): + base_url = self.request.build_absolute_uri() + current = _divide_with_ceil(self.offset, self.limit) + 1 + final = _divide_with_ceil(self.count, 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) + + template = loader.get_template(self.template) + context = Context({ + 'previous_url': self.get_previous_link(), + 'next_url': self.get_next_link(), + 'page_links': page_links + }) + return template.render(context) \ No newline at end of file diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 4c002b168..4c46b049f 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,7 +597,7 @@ class BrowsableAPIRenderer(BaseRenderer): 'description': self.get_description(view), 'name': self.get_name(view), 'version': VERSION, - 'pager': getattr(view, 'pager', None), + '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 d4a7d31a2..15b42178f 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 { diff --git a/rest_framework/templates/rest_framework/base.html b/rest_framework/templates/rest_framework/base.html index e00309811..877387f28 100644 --- a/rest_framework/templates/rest_framework/base.html +++ b/rest_framework/templates/rest_framework/base.html @@ -125,9 +125,9 @@ {% endblock %} - {% if pager.mark_as_used %} + {% if paginator %}

{% endif %} From 68dfa369b5ca877643b41c8df7c5fc0c786a9f08 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 15 Jan 2015 16:55:04 +0000 Subject: [PATCH 4/8] Flake 8 fixes --- rest_framework/pagination.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index 2b78f1f7f..61b8e07ac 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -251,6 +251,7 @@ class PageNumberPagination(BasePagination): def to_html(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) @@ -363,4 +364,4 @@ class LimitOffsetPagination(BasePagination): 'next_url': self.get_next_link(), 'page_links': page_links }) - return template.render(context) \ No newline at end of file + return template.render(context) From 53edd37df5aa0ac29dbe7824db2e33da1d901f98 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 15 Jan 2015 21:07:05 +0000 Subject: [PATCH 5/8] Tests for LimitOffsetPagination --- docs/api-guide/pagination.md | 2 +- rest_framework/pagination.py | 57 ++++++++--------- tests/test_pagination.py | 117 ++++++++++++++++++++++++++++++++++- 3 files changed, 144 insertions(+), 32 deletions(-) diff --git a/docs/api-guide/pagination.md b/docs/api-guide/pagination.md index ba71a3032..8ab2edd53 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. diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index 61b8e07ac..0dac56830 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -44,7 +44,7 @@ def _get_count(queryset): """ try: return queryset.count() - except AttributeError: + except (AttributeError, TypeError): return len(queryset) @@ -111,12 +111,7 @@ def _get_page_links(page_numbers, current, url_func): page_links = [] for page_number in page_numbers: if page_number is None: - page_link = PageLink( - url=None, - number=None, - is_active=False, - is_break=True - ) + page_link = PAGE_BREAK else: page_link = PageLink( url=url_func(page_number), @@ -130,11 +125,13 @@ def _get_page_links(page_numbers, current, url_func): 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): display_page_controls = False - def paginate_queryset(self, queryset, request, view): + def paginate_queryset(self, queryset, request, view=None): raise NotImplemented('paginate_queryset() must be implemented.') def get_paginated_response(self, data): @@ -167,9 +164,11 @@ class PageNumberPagination(BasePagination): # Only relevant if 'paginate_by_param' has also been set. max_paginate_by = api_settings.MAX_PAGINATE_BY + last_page_strings = ('last',) + template = 'rest_framework/pagination/numbers.html' - def paginate_queryset(self, queryset, request, view): + 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. @@ -186,18 +185,9 @@ class PageNumberPagination(BasePagination): 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) @@ -210,6 +200,7 @@ class PageNumberPagination(BasePagination): if paginator.count > 1: # The browsable API should display pagination controls. self.display_page_controls = True + self.request = request return self.page @@ -249,7 +240,7 @@ class PageNumberPagination(BasePagination): return remove_query_param(url, self.page_query_param) return replace_query_param(url, self.page_query_param, page_number) - def to_html(self): + def get_html_context(self): base_url = self.request.build_absolute_uri() def page_number_to_url(page_number): @@ -263,12 +254,15 @@ class PageNumberPagination(BasePagination): page_numbers = _get_displayed_page_numbers(current, final) page_links = _get_page_links(page_numbers, current, page_number_to_url) - template = loader.get_template(self.template) - context = Context({ + 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) @@ -286,7 +280,7 @@ class LimitOffsetPagination(BasePagination): 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) @@ -343,7 +337,7 @@ class LimitOffsetPagination(BasePagination): offset = self.offset - self.limit return replace_query_param(url, self.offset_query_param, offset) - def to_html(self): + def get_html_context(self): base_url = self.request.build_absolute_uri() current = _divide_with_ceil(self.offset, self.limit) + 1 final = _divide_with_ceil(self.count, self.limit) @@ -358,10 +352,13 @@ class LimitOffsetPagination(BasePagination): page_numbers = _get_displayed_page_numbers(current, final) page_links = _get_page_links(page_numbers, current, page_number_to_url) - template = loader.get_template(self.template) - context = Context({ + 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/tests/test_pagination.py b/tests/test_pagination.py index d410cd5eb..32fe7a66f 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -3,8 +3,10 @@ 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 import generics, pagination, serializers, status, filters from rest_framework.compat import django_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 @@ -337,3 +339,116 @@ class TestMaxPaginateByParam(TestCase): request = factory.get('/') response = self.view(request).render() self.assertEqual(response.data['results'], self.data[:3]) + + +class TestLimitOffset: + def setup(self): + self.pagination = pagination.LimitOffsetPagination() + self.queryset = range(1, 101) + + def paginate_queryset(self, request): + return 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), + ] + } + + 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), + ] + } From 50db8c092ab51a5eb94e2bb495c317097fceeb59 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 16 Jan 2015 16:55:28 +0000 Subject: [PATCH 6/8] Minor test cleanup --- tests/test_metadata.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 5ff59c723..972a896a4 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'] From 8b0f25aa0a91cb7b56f9ce4dde4330fe5daaad9b Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 16 Jan 2015 16:55:46 +0000 Subject: [PATCH 7/8] More pagination tests & cleanup --- rest_framework/generics.py | 12 +- rest_framework/pagination.py | 31 +- tests/test_pagination.py | 643 ++++++++++++++++++----------------- 3 files changed, 366 insertions(+), 320 deletions(-) diff --git a/rest_framework/generics.py b/rest_framework/generics.py index 4cc4c64d2..61dcb84a4 100644 --- a/rest_framework/generics.py +++ b/rest_framework/generics.py @@ -151,6 +151,9 @@ class GenericAPIView(views.APIView): @property def paginator(self): + """ + The paginator instance associated with the view, or `None`. + """ if not hasattr(self, '_paginator'): if self.pagination_class is None: self._paginator = None @@ -159,11 +162,18 @@ class GenericAPIView(views.APIView): return self._paginator def paginate_queryset(self, queryset): + """ + Return a single page of results, or `None` if pagination is disabled. + """ if self.paginator is None: - return queryset + return None return self.paginator.paginate_queryset(queryset, self.request, view=self) def get_paginated_response(self, 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) diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index 0dac56830..c5a364f0a 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -131,13 +131,13 @@ PAGE_BREAK = PageLink(url=None, number=None, is_active=False, is_break=True) class BasePagination(object): display_page_controls = False - def paginate_queryset(self, queryset, request, view=None): + 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): + def to_html(self): # pragma: no cover raise NotImplemented('to_html() must be implemented to display page controls.') @@ -168,10 +168,11 @@ class PageNumberPagination(BasePagination): template = 'rest_framework/pagination/numbers.html' - def paginate_queryset(self, queryset, request, view=None): + 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', @@ -180,6 +181,13 @@ 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 @@ -277,7 +285,6 @@ 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=None): @@ -340,7 +347,15 @@ class LimitOffsetPagination(BasePagination): def get_html_context(self): base_url = self.request.build_absolute_uri() current = _divide_with_ceil(self.offset, self.limit) + 1 - final = _divide_with_ceil(self.count, self.limit) + # 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: diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 32fe7a66f..b3436b359 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -1,349 +1,270 @@ 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, pagination, 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 FilterableItemSerializer(serializers.ModelSerializer): - class Meta: - model = FilterableItem - - -class RootView(generics.ListCreateAPIView): +class TestPaginationIntegration: """ - Example description for OPTIONS. - """ - queryset = BasicModel.objects.all() - serializer_class = BasicSerializer - paginate_by = 10 - - -class DefaultPageSizeKwargView(generics.ListAPIView): - """ - View for testing default paginate_by_param usage - """ - queryset = BasicModel.objects.all() - serializer_class = BasicSerializer - - -class PaginateByParamView(generics.ListAPIView): - """ - View for testing custom paginate_by_param usage - """ - queryset = BasicModel.objects.all() - serializer_class = BasicSerializer - paginate_by_param = 'page_size' - - -class MaxPaginateByView(generics.ListAPIView): - """ - View for testing custom max_paginate_by usage - """ - queryset = BasicModel.objects.all() - serializer_class = BasicSerializer - paginate_by = 3 - max_paginate_by = 5 - paginate_by_param = 'page_size' - - -class IntegrationTestPagination(TestCase): - """ - Integration tests for paginated list views. + Integration tests. """ - 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 setup(self): + class PassThroughSerializer(serializers.BaseSerializer): + def to_representation(self, item): + return item - 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 ` - 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, - """ - - class DecimalFilterBackend(filters.BaseFilterBackend): + class EvenItemsOnly(filters.BaseFilterBackend): def filter_queryset(self, request, queryset, view): - return queryset.filter(decimal__lt=Decimal(request.GET['decimal'])) + return [item for item in queryset if item % 2 == 0] - class BasicFilterFieldsRootView(generics.ListCreateAPIView): - queryset = FilterableItem.objects.all() - serializer_class = FilterableItemSerializer - paginate_by = 10 - filter_backends = (DecimalFilterBackend,) + class BasicPagination(pagination.PageNumberPagination): + paginate_by = 5 + paginate_by_param = 'page_size' + max_paginate_by = 20 - view = BasicFilterFieldsRootView.as_view() + self.view = generics.ListAPIView.as_view( + serializer_class=PassThroughSerializer, + queryset=range(1, 101), + filter_backends=[EvenItemsOnly], + pagination_class=BasicPagination + ) - 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): - """ - Tests for list views without pagination. - """ - - 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('/') + def test_filtered_items_are_paginated(self): + request = factory.get('/', {'page': 2}) response = self.view(request) - self.assertEqual(response.data, self.data) + 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 TestCustomPaginateByParam(TestCase): +class TestPaginationDisabledIntegration: """ - Tests for list views with default page size kwarg + Integration tests for disabled pagination. """ - 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 = PaginateByParamView.as_view() + def setup(self): + class PassThroughSerializer(serializers.BaseSerializer): + def to_representation(self, item): + return item - def test_default_page_size(self): - """ - Tests the default page size for this view. - no page size --> no limit --> no meta data - """ - request = factory.get('/') - response = self.view(request).render() - self.assertEqual(response.data, self.data) + self.view = generics.ListAPIView.as_view( + serializer_class=PassThroughSerializer, + queryset=range(1, 101), + pagination_class=None + ) - def test_paginate_by_param(self): - """ - If paginate_by_param is set, the new kwarg should limit per view requests. - """ - 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]) + 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 == range(1, 101) -class TestMaxPaginateByParam(TestCase): +class TestDeprecatedStylePagination: """ - Tests for list views with max_paginate_by kwarg + Integration tests for deprecated style of setting pagination + attributes on the view. """ - 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 setup(self): + class PassThroughSerializer(serializers.BaseSerializer): + def to_representation(self, item): + return item - 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]) + class ExampleView(generics.ListAPIView): + serializer_class = PassThroughSerializer + queryset = range(1, 101) + pagination_class = pagination.PageNumberPagination + paginate_by = 20 + page_query_param = 'page_number' - 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]) + self.view = ExampleView.as_view() + + 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: + """ + Unit tests for `pagination.PageNumberPagination`. + """ + + 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: + """ + Unit tests for `pagination.LimitOffsetPagination`. + """ + def setup(self): - self.pagination = pagination.LimitOffsetPagination() + class ExamplePagination(pagination.LimitOffsetPagination): + default_limit = 10 + self.pagination = ExamplePagination() self.queryset = range(1, 101) def paginate_queryset(self, request): @@ -379,6 +300,37 @@ class TestLimitOffset: 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): + """ + 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. + """ + 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})) @@ -452,3 +404,72 @@ class TestLimitOffset: PageLink('http://testserver/?limit=5&offset=95', 20, True, False), ] } + + def test_invalid_offset(self): + """ + An invalid offset query param should be treated as 0. + """ + request = Request(factory.get('/', {'limit': 5, 'offset': 'invalid'})) + queryset = self.paginate_queryset(request) + assert queryset == [1, 2, 3, 4, 5] + + def test_invalid_limit(self): + """ + An invalid limit query param should be ignored in favor of the default. + """ + 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] + + +def test_get_displayed_page_numbers(): + """ + Test our contextual page display function. + + 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] From 86d2774cf30351fd4174e97501532056ed0d8f95 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 16 Jan 2015 20:30:46 +0000 Subject: [PATCH 8/8] Fix compat issues --- rest_framework/pagination.py | 8 ++--- rest_framework/templatetags/rest_framework.py | 34 ++----------------- rest_framework/utils/urls.py | 25 ++++++++++++++ tests/test_pagination.py | 4 +-- 4 files changed, 34 insertions(+), 37 deletions(-) create mode 100644 rest_framework/utils/urls.py diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index c5a364f0a..1b7524c6c 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -12,7 +12,7 @@ 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 ( +from rest_framework.utils.urls import ( replace_query_param, remove_query_param ) @@ -34,8 +34,8 @@ 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 + return (a // b) + 1 + return a // b def _get_count(queryset): @@ -70,7 +70,7 @@ def _get_displayed_page_numbers(current, final): assert final >= current if final <= 5: - return range(1, final + 1) + 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. diff --git a/rest_framework/templatetags/rest_framework.py b/rest_framework/templatetags/rest_framework.py index bf159d8b1..a969836fd 100644 --- a/rest_framework/templatetags/rest_framework.py +++ b/rest_framework/templatetags/rest_framework.py @@ -1,41 +1,19 @@ 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)) - - -def remove_query_param(url, key): - """ - 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.pop(key, None) - 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=["\'])(.*)(?=["\'])') @register.simple_tag @@ -43,12 +21,6 @@ def get_pagination_html(pager): return pager.to_html() -# Regex for adding classes to html snippets -class_re = re.compile(r'(?<=class=["\'])(.*)(?=["\'])') - - -# And the template tags themselves... - @register.simple_tag def render_field(field, style=None): style = style or {} diff --git a/rest_framework/utils/urls.py b/rest_framework/utils/urls.py new file mode 100644 index 000000000..880ef9ed7 --- /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_pagination.py b/tests/test_pagination.py index b3436b359..7cc923472 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -117,7 +117,7 @@ class TestPaginationDisabledIntegration: request = factory.get('/', {'page': 2}) response = self.view(request) assert response.status_code == status.HTTP_200_OK - assert response.data == range(1, 101) + assert response.data == list(range(1, 101)) class TestDeprecatedStylePagination: @@ -268,7 +268,7 @@ class TestLimitOffset: self.queryset = range(1, 101) def paginate_queryset(self, request): - return self.pagination.paginate_queryset(self.queryset, request) + return list(self.pagination.paginate_queryset(self.queryset, request)) def get_paginated_content(self, queryset): response = self.pagination.get_paginated_response(queryset)