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.
# 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
......
......@@ -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
......
......@@ -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>
......
<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 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):
......
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 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']
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