Commit dc18040b by Tom Christie

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

Include pagination control in browsable API.
parents f13fcba9 86d2774c
...@@ -63,7 +63,7 @@ Or apply the style globally, using the `DEFAULT_PAGINATION_CLASS` settings key. ...@@ -63,7 +63,7 @@ Or apply the style globally, using the `DEFAULT_PAGINATION_CLASS` settings key.
# Custom pagination styles # 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 `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. * 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 ...@@ -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]. 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): def get_paginated_response(self, data):
next_url = self.get_next_link() previous_url = self.get_previous_link() 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 ...@@ -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">' link = '<{next_url}; rel="next">, <{previous_url}; rel="prev">'
elif next_url is not None: elif next_url is not None:
link = '<{next_url}; rel="next">' link = '<{next_url}; rel="next">'
elif prev_url is not None: elif previous_url is not None:
link = '<{previous_url}; rel="prev">' link = '<{previous_url}; rel="prev">'
else: else:
link = '' link = ''
...@@ -97,10 +97,20 @@ Let's modify the built-in `PageNumberPagination` style, so that instead of inclu ...@@ -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: To have your custom pagination class be used by default, use the `DEFAULT_PAGINATION_CLASS` setting:
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'DEFAULT_PAGINATION_CLASS': 'my_project.apps.core.pagination.LinkHeaderPagination',
'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 # Third party packages
The following third party packages are also available. The following third party packages are also available.
...@@ -111,5 +121,6 @@ The [`DRF-extensions` package][drf-extensions] includes a [`PaginateByMaxMixin` ...@@ -111,5 +121,6 @@ The [`DRF-extensions` package][drf-extensions] includes a [`PaginateByMaxMixin`
[cite]: https://docs.djangoproject.com/en/dev/topics/pagination/ [cite]: https://docs.djangoproject.com/en/dev/topics/pagination/
[github-link-pagination]: https://developer.github.com/guides/traversing-with-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/ [drf-extensions]: http://chibisov.github.io/drf-extensions/docs/
[paginate-by-max-mixin]: http://chibisov.github.io/drf-extensions/docs/#paginatebymaxmixin [paginate-by-max-mixin]: http://chibisov.github.io/drf-extensions/docs/#paginatebymaxmixin
......
...@@ -150,21 +150,31 @@ class GenericAPIView(views.APIView): ...@@ -150,21 +150,31 @@ class GenericAPIView(views.APIView):
return queryset return queryset
@property @property
def pager(self): def paginator(self):
if not hasattr(self, '_pager'): """
The paginator instance associated with the view, or `None`.
"""
if not hasattr(self, '_paginator'):
if self.pagination_class is None: if self.pagination_class is None:
self._pager = None self._paginator = None
else: else:
self._pager = self.pagination_class() self._paginator = self.pagination_class()
return self._pager return self._paginator
def paginate_queryset(self, queryset): def paginate_queryset(self, queryset):
if self.pager is None: """
return queryset Return a single page of results, or `None` if pagination is disabled.
return self.pager.paginate_queryset(queryset, self.request, view=self) """
if self.paginator is None:
return None
return self.paginator.paginate_queryset(queryset, self.request, view=self)
def get_paginated_response(self, data): 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 # Concrete view classes that provide method handlers
......
...@@ -584,6 +584,11 @@ class BrowsableAPIRenderer(BaseRenderer): ...@@ -584,6 +584,11 @@ class BrowsableAPIRenderer(BaseRenderer):
renderer_content_type += ' ;%s' % renderer.charset renderer_content_type += ' ;%s' % renderer.charset
response_headers['Content-Type'] = renderer_content_type response_headers['Content-Type'] = renderer_content_type
if hasattr(view, 'paginator') and view.paginator.display_page_controls:
paginator = view.paginator
else:
paginator = None
context = { context = {
'content': self.get_content(renderer, data, accepted_media_type, renderer_context), 'content': self.get_content(renderer, data, accepted_media_type, renderer_context),
'view': view, 'view': view,
...@@ -592,6 +597,7 @@ class BrowsableAPIRenderer(BaseRenderer): ...@@ -592,6 +597,7 @@ class BrowsableAPIRenderer(BaseRenderer):
'description': self.get_description(view), 'description': self.get_description(view),
'name': self.get_name(view), 'name': self.get_name(view),
'version': VERSION, 'version': VERSION,
'paginator': paginator,
'breadcrumblist': self.get_breadcrumbs(request), 'breadcrumblist': self.get_breadcrumbs(request),
'allowed_methods': view.allowed_methods, 'allowed_methods': view.allowed_methods,
'available_formats': [renderer_cls.format for renderer_cls in view.renderer_classes], 'available_formats': [renderer_cls.format for renderer_cls in view.renderer_classes],
......
...@@ -60,6 +60,13 @@ a single block in the template. ...@@ -60,6 +60,13 @@ a single block in the template.
color: #C20000; color: #C20000;
} }
.pagination>.disabled>a,
.pagination>.disabled>a:hover,
.pagination>.disabled>a:focus {
cursor: default;
pointer-events: none;
}
/*=== dabapps bootstrap styles ====*/ /*=== dabapps bootstrap styles ====*/
html { html {
...@@ -185,10 +192,6 @@ body a:hover { ...@@ -185,10 +192,6 @@ body a:hover {
color: #c20000; color: #c20000;
} }
#content a span {
text-decoration: underline;
}
.request-info { .request-info {
clear:both; clear:both;
} }
...@@ -119,9 +119,18 @@ ...@@ -119,9 +119,18 @@
<div class="page-header"> <div class="page-header">
<h1>{{ name }}</h1> <h1>{{ name }}</h1>
</div> </div>
<div style="float:left">
{% block description %} {% block description %}
{{ description }} {{ description }}
{% endblock %} {% endblock %}
</div>
{% if paginator %}
<nav style="float: right">
{% get_pagination_html paginator %}
</nav>
{% endif %}
<div class="request-info" style="clear: both" > <div class="request-info" style="clear: both" >
<pre class="prettyprint"><b>{{ request.method }}</b> {{ request.get_full_path }}</pre> <pre class="prettyprint"><b>{{ request.method }}</b> {{ request.get_full_path }}</pre>
</div> </div>
......
<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>
from __future__ import unicode_literals, absolute_import from __future__ import unicode_literals, absolute_import
from django import template from django import template
from django.core.urlresolvers import reverse, NoReverseMatch from django.core.urlresolvers import reverse, NoReverseMatch
from django.http import QueryDict
from django.utils import six 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.encoding import iri_to_uri, force_text
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import SafeData, mark_safe from django.utils.safestring import SafeData, mark_safe
from django.utils.html import smart_urlquote from django.utils.html import smart_urlquote
from rest_framework.renderers import HTMLFormRenderer from rest_framework.renderers import HTMLFormRenderer
from rest_framework.utils.urls import replace_query_param
import re import re
register = template.Library() 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 # Regex for adding classes to html snippets
class_re = re.compile(r'(?<=class=["\'])(.*)(?=["\'])') 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 @register.simple_tag
def render_field(field, style=None): def render_field(field, style=None):
......
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))
from __future__ import unicode_literals from __future__ import unicode_literals
from rest_framework import exceptions, serializers, status, views
from rest_framework import exceptions, serializers, views
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.test import APIRequestFactory from rest_framework.test import APIRequestFactory
import pytest
request = Request(APIRequestFactory().options('/')) request = Request(APIRequestFactory().options('/'))
...@@ -17,7 +15,8 @@ class TestMetadata: ...@@ -17,7 +15,8 @@ class TestMetadata:
"""Example view.""" """Example view."""
pass pass
response = ExampleView().options(request=request) view = ExampleView.as_view()
response = view(request=request)
expected = { expected = {
'name': 'Example', 'name': 'Example',
'description': 'Example view.', 'description': 'Example view.',
...@@ -31,7 +30,7 @@ class TestMetadata: ...@@ -31,7 +30,7 @@ class TestMetadata:
'multipart/form-data' 'multipart/form-data'
] ]
} }
assert response.status_code == 200 assert response.status_code == status.HTTP_200_OK
assert response.data == expected assert response.data == expected
def test_none_metadata(self): def test_none_metadata(self):
...@@ -42,8 +41,10 @@ class TestMetadata: ...@@ -42,8 +41,10 @@ class TestMetadata:
class ExampleView(views.APIView): class ExampleView(views.APIView):
metadata_class = None metadata_class = None
with pytest.raises(exceptions.MethodNotAllowed): view = ExampleView.as_view()
ExampleView().options(request=request) 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): def test_actions(self):
""" """
...@@ -63,7 +64,8 @@ class TestMetadata: ...@@ -63,7 +64,8 @@ class TestMetadata:
def get_serializer(self): def get_serializer(self):
return ExampleSerializer() return ExampleSerializer()
response = ExampleView().options(request=request) view = ExampleView.as_view()
response = view(request=request)
expected = { expected = {
'name': 'Example', 'name': 'Example',
'description': 'Example view.', 'description': 'Example view.',
...@@ -104,7 +106,7 @@ class TestMetadata: ...@@ -104,7 +106,7 @@ class TestMetadata:
} }
} }
} }
assert response.status_code == 200 assert response.status_code == status.HTTP_200_OK
assert response.data == expected assert response.data == expected
def test_global_permissions(self): def test_global_permissions(self):
...@@ -132,8 +134,9 @@ class TestMetadata: ...@@ -132,8 +134,9 @@ class TestMetadata:
if request.method == 'POST': if request.method == 'POST':
raise exceptions.PermissionDenied() raise exceptions.PermissionDenied()
response = ExampleView().options(request=request) view = ExampleView.as_view()
assert response.status_code == 200 response = view(request=request)
assert response.status_code == status.HTTP_200_OK
assert list(response.data['actions'].keys()) == ['PUT'] assert list(response.data['actions'].keys()) == ['PUT']
def test_object_permissions(self): def test_object_permissions(self):
...@@ -161,6 +164,7 @@ class TestMetadata: ...@@ -161,6 +164,7 @@ class TestMetadata:
if self.request.method == 'PUT': if self.request.method == 'PUT':
raise exceptions.PermissionDenied() raise exceptions.PermissionDenied()
response = ExampleView().options(request=request) view = ExampleView.as_view()
assert response.status_code == 200 response = view(request=request)
assert response.status_code == status.HTTP_200_OK
assert list(response.data['actions'].keys()) == ['POST'] assert list(response.data['actions'].keys()) == ['POST']
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment