Merge pull request #2419 from tomchristie/include-pagination-in-browsable-api

Include pagination control in browsable API.
This commit is contained in:
Tom Christie 2015-01-16 20:36:09 +00:00
commit dc18040ba4
12 changed files with 773 additions and 378 deletions

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@ -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

View File

@ -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 = request.query_params.get(self.page_query_param, 1)
if page_number in self.last_page_strings:
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)
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)

View File

@ -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],

View File

@ -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;
}

View File

@ -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>

View File

@ -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">&laquo;</span></a></li>
{% else %}
<li class="disabled"><a href="#" aria-label="Previous"><span aria-hidden="true">&laquo;</span></a></li>
{% endif %}
{% for page_link in page_links %}
{% if page_link.is_break %}
<li class="disabled">
<a href="#"><span aria-hidden="true">&hellip;</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">&raquo;</span></a></li>
{% else %}
<li class="disabled"><a href="#" aria-label="Next"><span aria-hidden="true">&raquo;</span></a></li>
{% endif %}
</ul>

View File

@ -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):

View 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))

View File

@ -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']

View File

@ -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):
def setup(self):
class PassThroughSerializer(serializers.BaseSerializer):
def to_representation(self, item):
return item
self.view = generics.ListAPIView.as_view(
serializer_class=PassThroughSerializer,
queryset=range(1, 101),
pagination_class=None
)
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 TestDeprecatedStylePagination:
"""
Create 13 BasicModel instances.
Integration tests for deprecated style of setting pagination
attributes on the view.
"""
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()
def setup(self):
class PassThroughSerializer(serializers.BaseSerializer):
def to_representation(self, item):
return item
class ExampleView(generics.ListAPIView):
serializer_class = PassThroughSerializer
queryset = range(1, 101)
pagination_class = pagination.PageNumberPagination
paginate_by = 20
page_query_param = 'page_number'
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),
]
self.view = PaginateByParamView.as_view()
}
assert self.pagination.display_page_controls
assert isinstance(self.pagination.to_html(), type(''))
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)
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])
class TestMaxPaginateByParam(TestCase):
"""
Tests for list views with max_paginate_by kwarg
"""
def setUp(self):
"""
Create 13 BasicModel instances.
"""
for i in range(13):
BasicModel(text=i).save()
self.objects = BasicModel.objects
self.data = [
{'id': obj.id, 'text': obj.text}
for obj in self.objects.all()
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),
]
self.view = MaxPaginateByView.as_view()
}
def test_max_paginate_by(self):
"""
If max_paginate_by is set, it should limit page size for the view.
"""
request = factory.get('/', data={'page_size': 10})
response = self.view(request).render()
self.assertEqual(response.data['count'], 13)
self.assertEqual(response.data['results'], self.data[:5])
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_max_paginate_by_without_page_size_param(self):
def test_invalid_page(self):
request = Request(factory.get('/', {'page': 'invalid'}))
with pytest.raises(exceptions.NotFound):
self.paginate_queryset(request)
class TestLimitOffset:
"""
If max_paginate_by is set, but client does not specifiy page_size,
standard `paginate_by` behavior should be used.
Unit tests for `pagination.LimitOffsetPagination`.
"""
request = factory.get('/')
response = self.view(request).render()
self.assertEqual(response.data['results'], self.data[:3])
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):
"""
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}))
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]