mirror of
https://github.com/encode/django-rest-framework.git
synced 2025-01-23 15:54:16 +03:00
Merge pull request #2419 from tomchristie/include-pagination-in-browsable-api
Include pagination control in browsable API.
This commit is contained in:
commit
dc18040ba4
|
@ -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
|
||||
|
|
BIN
docs/img/link-header-pagination.png
Normal file
BIN
docs/img/link-header-pagination.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 35 KiB |
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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):
|
||||
|
|
25
rest_framework/utils/urls.py
Normal file
25
rest_framework/utils/urls.py
Normal file
|
@ -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))
|
|
@ -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']
|
||||
|
|
|
@ -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 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 <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,
|
||||
"""
|
||||
|
||||
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 == list(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):
|
||||
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):
|
||||
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):
|
||||
"""
|
||||
If max_paginate_by is set, but client does not specifiy page_size,
|
||||
standard `paginate_by` behavior should be used.
|
||||
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 = factory.get('/')
|
||||
response = self.view(request).render()
|
||||
self.assertEqual(response.data['results'], self.data[:3])
|
||||
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):
|
||||
"""
|
||||
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]
|
||||
|
|
Loading…
Reference in New Issue
Block a user