Improvements to ViewSet extra actions (#5605)

* View suffix already set by initializer

* Add 'name' and 'description' attributes to ViewSet

ViewSets may now provide their `name` and `description` attributes
directly, instead of relying on view introspection to derive them.
These attributes may also be provided with the view's initkwargs.

The ViewSet `name` and `suffix` initkwargs are mutually exclusive.

The `action` decorator now provides the `name` and `description` to
the view's initkwargs. By default, these values are derived from the
method name and its docstring. The `name` may be overridden by providing
it as an argument to the decorator.

The `get_view_name` and `get_view_description` hooks now provide the
view instance to the handler, instead of the view class. The default
implementations of these handlers now respect the `name`/`description`.

* Add 'extra actions' to ViewSet & browsable APIs

* Update simple router tests

Removed old test logic around link/action decorators from `v2.3`. Also
simplified the test by making the results explicit instead of computed.

* Add method mapping to ViewSet actions

* Document extra action method mapping
This commit is contained in:
Ryan P Kilby 2018-07-06 04:33:10 -04:00 committed by Carlton Gibson
parent 56967dbd90
commit 0148a9f8da
17 changed files with 466 additions and 73 deletions

View File

@ -398,10 +398,15 @@ A string representing the function that should be used when generating view name
This should be a function with the following signature: This should be a function with the following signature:
view_name(cls, suffix=None) view_name(self)
* `cls`: The view class. Typically the name function would inspect the name of the class when generating a descriptive name, by accessing `cls.__name__`. * `self`: The view instance. Typically the name function would inspect the name of the class when generating a descriptive name, by accessing `self.__class__.__name__`.
* `suffix`: The optional suffix used when differentiating individual views in a viewset.
If the view instance inherits `ViewSet`, it may have been initialized with several optional arguments:
* `name`: A name expliticly provided to a view in the viewset. Typically, this value should be used as-is when provided.
* `suffix`: Text used when differentiating individual views in a viewset. This argument is mutually exclusive to `name`.
* `detail`: Boolean that differentiates an individual view in a viewset as either being a 'list' or 'detail' view.
Default: `'rest_framework.views.get_view_name'` Default: `'rest_framework.views.get_view_name'`
@ -413,11 +418,15 @@ This setting can be changed to support markup styles other than the default mark
This should be a function with the following signature: This should be a function with the following signature:
view_description(cls, html=False) view_description(self, html=False)
* `cls`: The view class. Typically the description function would inspect the docstring of the class when generating a description, by accessing `cls.__doc__` * `self`: The view instance. Typically the description function would inspect the docstring of the class when generating a description, by accessing `self.__class__.__doc__`
* `html`: A boolean indicating if HTML output is required. `True` when used in the browsable API, and `False` when used in generating `OPTIONS` responses. * `html`: A boolean indicating if HTML output is required. `True` when used in the browsable API, and `False` when used in generating `OPTIONS` responses.
If the view instance inherits `ViewSet`, it may have been initialized with several optional arguments:
* `description`: A description explicitly provided to the view in the viewset. Typically, this is set by extra viewset `action`s, and should be used as-is.
Default: `'rest_framework.views.get_view_description'` Default: `'rest_framework.views.get_view_description'`
## HTML Select Field cutoffs ## HTML Select Field cutoffs

View File

@ -110,6 +110,8 @@ During dispatch, the following attributes are available on the `ViewSet`.
* `action` - the name of the current action (e.g., `list`, `create`). * `action` - the name of the current action (e.g., `list`, `create`).
* `detail` - boolean indicating if the current action is configured for a list or detail view. * `detail` - boolean indicating if the current action is configured for a list or detail view.
* `suffix` - the display suffix for the viewset type - mirrors the `detail` attribute. * `suffix` - the display suffix for the viewset type - mirrors the `detail` attribute.
* `name` - the display name for the viewset. This argument is mutually exclusive to `suffix`.
* `description` - the display description for the individual view of a viewset.
You may inspect these attributes to adjust behaviour based on the current action. For example, you could restrict permissions to everything except the `list` action similar to this: You may inspect these attributes to adjust behaviour based on the current action. For example, you could restrict permissions to everything except the `list` action similar to this:
@ -142,7 +144,7 @@ A more complete example of extra actions:
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = UserSerializer serializer_class = UserSerializer
@action(methods=['post'], detail=True) @action(detail=True, methods=['post'])
def set_password(self, request, pk=None): def set_password(self, request, pk=None):
user = self.get_object() user = self.get_object()
serializer = PasswordSerializer(data=request.data) serializer = PasswordSerializer(data=request.data)
@ -168,13 +170,13 @@ A more complete example of extra actions:
The decorator can additionally take extra arguments that will be set for the routed view only. For example: The decorator can additionally take extra arguments that will be set for the routed view only. For example:
@action(methods=['post'], detail=True, permission_classes=[IsAdminOrIsSelf]) @action(detail=True, methods=['post'], permission_classes=[IsAdminOrIsSelf])
def set_password(self, request, pk=None): def set_password(self, request, pk=None):
... ...
These decorator will route `GET` requests by default, but may also accept other HTTP methods by setting the `methods` argument. For example: These decorator will route `GET` requests by default, but may also accept other HTTP methods by setting the `methods` argument. For example:
@action(methods=['post', 'delete'], detail=True) @action(detail=True, methods=['post', 'delete'])
def unset_password(self, request, pk=None): def unset_password(self, request, pk=None):
... ...
@ -182,6 +184,22 @@ The two new actions will then be available at the urls `^users/{pk}/set_password
To view all extra actions, call the `.get_extra_actions()` method. To view all extra actions, call the `.get_extra_actions()` method.
### Routing additional HTTP methods for extra actions
Extra actions can be mapped to different `ViewSet` methods. For example, the above password set/unset methods could be consolidated into a single route. Note that additional mappings do not accept arguments.
```python
@action(detail=True, methods=['put'], name='Change Password')
def password(self, request, pk=None):
"""Update the user's password."""
...
@password.mapping.delete
def delete_password(self, request, pk=None):
"""Delete the user's password."""
...
```
## Reversing action URLs ## Reversing action URLs
If you need to get the URL of an action, use the `.reverse_action()` method. This is a convenience wrapper for `reverse()`, automatically passing the view's `request` object and prepending the `url_name` with the `.basename` attribute. If you need to get the URL of an action, use the `.reverse_action()` method. This is a convenience wrapper for `reverse()`, automatically passing the view's `request` object and prepending the `url_name` with the `.basename` attribute.

View File

@ -11,6 +11,7 @@ from __future__ import unicode_literals
import types import types
import warnings import warnings
from django.forms.utils import pretty_name
from django.utils import six from django.utils import six
from rest_framework.views import APIView from rest_framework.views import APIView
@ -130,7 +131,7 @@ def schema(view_inspector):
return decorator return decorator
def action(methods=None, detail=None, url_path=None, url_name=None, **kwargs): def action(methods=None, detail=None, name=None, url_path=None, url_name=None, **kwargs):
""" """
Mark a ViewSet method as a routable action. Mark a ViewSet method as a routable action.
@ -145,15 +146,81 @@ def action(methods=None, detail=None, url_path=None, url_name=None, **kwargs):
) )
def decorator(func): def decorator(func):
func.bind_to_methods = methods func.mapping = MethodMapper(func, methods)
func.detail = detail func.detail = detail
func.name = name if name else pretty_name(func.__name__)
func.url_path = url_path if url_path else func.__name__ func.url_path = url_path if url_path else func.__name__
func.url_name = url_name if url_name else func.__name__.replace('_', '-') func.url_name = url_name if url_name else func.__name__.replace('_', '-')
func.kwargs = kwargs func.kwargs = kwargs
func.kwargs.update({
'name': func.name,
'description': func.__doc__ or None
})
return func return func
return decorator return decorator
class MethodMapper(dict):
"""
Enables mapping HTTP methods to different ViewSet methods for a single,
logical action.
Example usage:
class MyViewSet(ViewSet):
@action(detail=False)
def example(self, request, **kwargs):
...
@example.mapping.post
def create_example(self, request, **kwargs):
...
"""
def __init__(self, action, methods):
self.action = action
for method in methods:
self[method] = self.action.__name__
def _map(self, method, func):
assert method not in self, (
"Method '%s' has already been mapped to '.%s'." % (method, self[method]))
assert func.__name__ != self.action.__name__, (
"Method mapping does not behave like the property decorator. You "
"cannot use the same method name for each mapping declaration.")
self[method] = func.__name__
return func
def get(self, func):
return self._map('get', func)
def post(self, func):
return self._map('post', func)
def put(self, func):
return self._map('put', func)
def patch(self, func):
return self._map('patch', func)
def delete(self, func):
return self._map('delete', func)
def head(self, func):
return self._map('head', func)
def options(self, func):
return self._map('options', func)
def trace(self, func):
return self._map('trace', func)
def detail_route(methods=None, **kwargs): def detail_route(methods=None, **kwargs):
""" """
Used to mark a method on a ViewSet that should be routed for detail requests. Used to mark a method on a ViewSet that should be routed for detail requests.

View File

@ -612,6 +612,11 @@ class BrowsableAPIRenderer(BaseRenderer):
def get_breadcrumbs(self, request): def get_breadcrumbs(self, request):
return get_breadcrumbs(request.path, request) return get_breadcrumbs(request.path, request)
def get_extra_actions(self, view):
if hasattr(view, 'get_extra_action_url_map'):
return view.get_extra_action_url_map()
return None
def get_filter_form(self, data, view, request): def get_filter_form(self, data, view, request):
if not hasattr(view, 'get_queryset') or not hasattr(view, 'filter_backends'): if not hasattr(view, 'get_queryset') or not hasattr(view, 'filter_backends'):
return return
@ -698,6 +703,8 @@ class BrowsableAPIRenderer(BaseRenderer):
'delete_form': self.get_rendered_html_form(data, view, 'DELETE', request), 'delete_form': self.get_rendered_html_form(data, view, 'DELETE', request),
'options_form': self.get_rendered_html_form(data, view, 'OPTIONS', request), 'options_form': self.get_rendered_html_form(data, view, 'OPTIONS', request),
'extra_actions': self.get_extra_actions(view),
'filter_form': self.get_filter_form(data, view, request), 'filter_form': self.get_filter_form(data, view, request),
'raw_data_put_form': raw_data_put_form, 'raw_data_put_form': raw_data_put_form,

View File

@ -208,8 +208,7 @@ class SimpleRouter(BaseRouter):
return Route( return Route(
url=route.url.replace('{url_path}', url_path), url=route.url.replace('{url_path}', url_path),
mapping={http_method: action.__name__ mapping=action.mapping,
for http_method in action.bind_to_methods},
name=route.name.replace('{url_name}', action.url_name), name=route.name.replace('{url_name}', action.url_name),
detail=route.detail, detail=route.detail,
initkwargs=initkwargs, initkwargs=initkwargs,

View File

@ -110,6 +110,20 @@
</form> </form>
{% endif %} {% endif %}
{% if extra_actions %}
<div class="dropdown" style="float: right; margin-right: 10px">
<button class="btn btn-default" id="extra-actions-menu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
{% trans "Extra Actions" %}
<span class="caret"></span>
</button>
<ul class="dropdown-menu" aria-labelledby="extra-actions-menu">
{% for action_name, url in extra_actions|items %}
<li><a href="{{ url }}">{{ action_name }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if filter_form %} {% if filter_form %}
<button style="float: right; margin-right: 10px" data-toggle="modal" data-target="#filtersModal" class="btn btn-default"> <button style="float: right; margin-right: 10px" data-toggle="modal" data-target="#filtersModal" class="btn btn-default">
<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span> <span class="glyphicon glyphicon-wrench" aria-hidden="true"></span>

View File

@ -128,6 +128,20 @@
</div> </div>
{% endif %} {% endif %}
{% if extra_actions %}
<div class="dropdown" style="float: right; margin-right: 10px">
<button class="btn btn-default" id="extra-actions-menu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
{% trans "Extra Actions" %}
<span class="caret"></span>
</button>
<ul class="dropdown-menu" aria-labelledby="extra-actions-menu">
{% for action_name, url in extra_actions|items %}
<li><a href="{{ url }}">{{ action_name }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if filter_form %} {% if filter_form %}
<button style="float: right; margin-right: 10px" data-toggle="modal" data-target="#filtersModal" class="btn btn-default"> <button style="float: right; margin-right: 10px" data-toggle="modal" data-target="#filtersModal" class="btn btn-default">
<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span> <span class="glyphicon glyphicon-wrench" aria-hidden="true"></span>

View File

@ -30,7 +30,6 @@ def get_breadcrumbs(url, request=None):
# Probably an optional trailing slash. # Probably an optional trailing slash.
if not seen or seen[-1] != view: if not seen or seen[-1] != view:
c = cls(**initkwargs) c = cls(**initkwargs)
c.suffix = getattr(view, 'suffix', None)
name = c.get_view_name() name = c.get_view_name()
insert_url = preserve_builtin_query_params(prefix + url, request) insert_url = preserve_builtin_query_params(prefix + url, request)
breadcrumbs_list.insert(0, (name, insert_url)) breadcrumbs_list.insert(0, (name, insert_url))

View File

@ -21,31 +21,43 @@ from rest_framework.settings import api_settings
from rest_framework.utils import formatting from rest_framework.utils import formatting
def get_view_name(view_cls, suffix=None): def get_view_name(view):
""" """
Given a view class, return a textual name to represent the view. Given a view class, return a textual name to represent the view.
This name is used in the browsable API, and in OPTIONS responses. This name is used in the browsable API, and in OPTIONS responses.
This function is the default for the `VIEW_NAME_FUNCTION` setting. This function is the default for the `VIEW_NAME_FUNCTION` setting.
""" """
name = view_cls.__name__ # Name may be set by some Views, such as a ViewSet.
name = getattr(view, 'name', None)
if name is not None:
return name
name = view.__class__.__name__
name = formatting.remove_trailing_string(name, 'View') name = formatting.remove_trailing_string(name, 'View')
name = formatting.remove_trailing_string(name, 'ViewSet') name = formatting.remove_trailing_string(name, 'ViewSet')
name = formatting.camelcase_to_spaces(name) name = formatting.camelcase_to_spaces(name)
# Suffix may be set by some Views, such as a ViewSet.
suffix = getattr(view, 'suffix', None)
if suffix: if suffix:
name += ' ' + suffix name += ' ' + suffix
return name return name
def get_view_description(view_cls, html=False): def get_view_description(view, html=False):
""" """
Given a view class, return a textual description to represent the view. Given a view class, return a textual description to represent the view.
This name is used in the browsable API, and in OPTIONS responses. This name is used in the browsable API, and in OPTIONS responses.
This function is the default for the `VIEW_DESCRIPTION_FUNCTION` setting. This function is the default for the `VIEW_DESCRIPTION_FUNCTION` setting.
""" """
description = view_cls.__doc__ or '' # Description may be set by some Views, such as a ViewSet.
description = getattr(view, 'description', None)
if description is None:
description = view.__class__.__doc__ or ''
description = formatting.dedent(smart_text(description)) description = formatting.dedent(smart_text(description))
if html: if html:
return formatting.markup_description(description) return formatting.markup_description(description)
@ -224,7 +236,7 @@ class APIView(View):
browsable API. browsable API.
""" """
func = self.settings.VIEW_NAME_FUNCTION func = self.settings.VIEW_NAME_FUNCTION
return func(self.__class__, getattr(self, 'suffix', None)) return func(self)
def get_view_description(self, html=False): def get_view_description(self, html=False):
""" """
@ -232,7 +244,7 @@ class APIView(View):
and in the browsable API. and in the browsable API.
""" """
func = self.settings.VIEW_DESCRIPTION_FUNCTION func = self.settings.VIEW_DESCRIPTION_FUNCTION
return func(self.__class__, html) return func(self, html)
# API policy instantiation methods # API policy instantiation methods

View File

@ -18,9 +18,11 @@ automatically.
""" """
from __future__ import unicode_literals from __future__ import unicode_literals
from collections import OrderedDict
from functools import update_wrapper from functools import update_wrapper
from inspect import getmembers from inspect import getmembers
from django.urls import NoReverseMatch
from django.utils.decorators import classonlymethod from django.utils.decorators import classonlymethod
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
@ -29,7 +31,7 @@ from rest_framework.reverse import reverse
def _is_extra_action(attr): def _is_extra_action(attr):
return hasattr(attr, 'bind_to_methods') return hasattr(attr, 'mapping')
class ViewSetMixin(object): class ViewSetMixin(object):
@ -52,7 +54,13 @@ class ViewSetMixin(object):
instantiated view, we need to totally reimplement `.as_view`, instantiated view, we need to totally reimplement `.as_view`,
and slightly modify the view function that is created and returned. and slightly modify the view function that is created and returned.
""" """
# The name and description initkwargs may be explicitly overridden for
# certain route confiugurations. eg, names of extra actions.
cls.name = None
cls.description = None
# The suffix initkwarg is reserved for displaying the viewset type. # The suffix initkwarg is reserved for displaying the viewset type.
# This initkwarg should have no effect if the name is provided.
# eg. 'List' or 'Instance'. # eg. 'List' or 'Instance'.
cls.suffix = None cls.suffix = None
@ -79,6 +87,11 @@ class ViewSetMixin(object):
raise TypeError("%s() received an invalid keyword %r" % ( raise TypeError("%s() received an invalid keyword %r" % (
cls.__name__, key)) cls.__name__, key))
# name and suffix are mutually exclusive
if 'name' in initkwargs and 'suffix' in initkwargs:
raise TypeError("%s() received both `name` and `suffix`, which are "
"mutually exclusive arguments." % (cls.__name__))
def view(request, *args, **kwargs): def view(request, *args, **kwargs):
self = cls(**initkwargs) self = cls(**initkwargs)
# We also store the mapping of request methods to actions, # We also store the mapping of request methods to actions,
@ -114,7 +127,6 @@ class ViewSetMixin(object):
# resolved URL. # resolved URL.
view.cls = cls view.cls = cls
view.initkwargs = initkwargs view.initkwargs = initkwargs
view.suffix = initkwargs.get('suffix', None)
view.actions = actions view.actions = actions
return csrf_exempt(view) return csrf_exempt(view)
@ -149,6 +161,34 @@ class ViewSetMixin(object):
""" """
return [method for _, method in getmembers(cls, _is_extra_action)] return [method for _, method in getmembers(cls, _is_extra_action)]
def get_extra_action_url_map(self):
"""
Build a map of {names: urls} for the extra actions.
This method will noop if `detail` was not provided as a view initkwarg.
"""
action_urls = OrderedDict()
# exit early if `detail` has not been provided
if self.detail is None:
return action_urls
# filter for the relevant extra actions
actions = [
action for action in self.get_extra_actions()
if action.detail == self.detail
]
for action in actions:
try:
url_name = '%s-%s' % (self.basename, action.url_name)
url = reverse(url_name, self.args, self.kwargs, request=self.request)
action_urls[action.name] = url
except NoReverseMatch:
pass # URL requires additional arguments, ignore
return action_urls
class ViewSet(ViewSetMixin, views.APIView): class ViewSet(ViewSetMixin, views.APIView):
""" """

View File

@ -175,26 +175,85 @@ class ActionDecoratorTestCase(TestCase):
def test_defaults(self): def test_defaults(self):
@action(detail=True) @action(detail=True)
def test_action(request): def test_action(request):
pass """Description"""
assert test_action.bind_to_methods == ['get'] assert test_action.mapping == {'get': 'test_action'}
assert test_action.detail is True assert test_action.detail is True
assert test_action.name == 'Test action'
assert test_action.url_path == 'test_action' assert test_action.url_path == 'test_action'
assert test_action.url_name == 'test-action' assert test_action.url_name == 'test-action'
assert test_action.kwargs == {
'name': 'Test action',
'description': 'Description',
}
def test_detail_required(self): def test_detail_required(self):
with pytest.raises(AssertionError) as excinfo: with pytest.raises(AssertionError) as excinfo:
@action() @action()
def test_action(request): def test_action(request):
pass raise NotImplementedError
assert str(excinfo.value) == "@action() missing required argument: 'detail'" assert str(excinfo.value) == "@action() missing required argument: 'detail'"
def test_method_mapping_http_methods(self):
# All HTTP methods should be mappable
@action(detail=False, methods=[])
def test_action():
raise NotImplementedError
for name in APIView.http_method_names:
def method():
raise NotImplementedError
# Python 2.x compatibility - cast __name__ to str
method.__name__ = str(name)
getattr(test_action.mapping, name)(method)
# ensure the mapping returns the correct method name
for name in APIView.http_method_names:
assert test_action.mapping[name] == name
def test_method_mapping(self):
@action(detail=False)
def test_action(request):
raise NotImplementedError
@test_action.mapping.post
def test_action_post(request):
raise NotImplementedError
# The secondary handler methods should not have the action attributes
for name in ['mapping', 'detail', 'name', 'url_path', 'url_name', 'kwargs']:
assert hasattr(test_action, name) and not hasattr(test_action_post, name)
def test_method_mapping_already_mapped(self):
@action(detail=True)
def test_action(request):
raise NotImplementedError
msg = "Method 'get' has already been mapped to '.test_action'."
with self.assertRaisesMessage(AssertionError, msg):
@test_action.mapping.get
def test_action_get(request):
raise NotImplementedError
def test_method_mapping_overwrite(self):
@action(detail=True)
def test_action():
raise NotImplementedError
msg = ("Method mapping does not behave like the property decorator. You "
"cannot use the same method name for each mapping declaration.")
with self.assertRaisesMessage(AssertionError, msg):
@test_action.mapping.post
def test_action():
raise NotImplementedError
def test_detail_route_deprecation(self): def test_detail_route_deprecation(self):
with pytest.warns(PendingDeprecationWarning) as record: with pytest.warns(PendingDeprecationWarning) as record:
@detail_route() @detail_route()
def view(request): def view(request):
pass raise NotImplementedError
assert len(record) == 1 assert len(record) == 1
assert str(record[0].message) == ( assert str(record[0].message) == (
@ -207,7 +266,7 @@ class ActionDecoratorTestCase(TestCase):
with pytest.warns(PendingDeprecationWarning) as record: with pytest.warns(PendingDeprecationWarning) as record:
@list_route() @list_route()
def view(request): def view(request):
pass raise NotImplementedError
assert len(record) == 1 assert len(record) == 1
assert str(record[0].message) == ( assert str(record[0].message) == (
@ -221,7 +280,7 @@ class ActionDecoratorTestCase(TestCase):
with pytest.warns(PendingDeprecationWarning): with pytest.warns(PendingDeprecationWarning):
@list_route(url_path='foo_bar') @list_route(url_path='foo_bar')
def view(request): def view(request):
pass raise NotImplementedError
assert view.url_path == 'foo_bar' assert view.url_path == 'foo_bar'
assert view.url_name == 'foo-bar' assert view.url_name == 'foo-bar'

View File

@ -85,6 +85,22 @@ class TestViewNamesAndDescriptions(TestCase):
pass pass
assert MockView().get_view_name() == 'Mock' assert MockView().get_view_name() == 'Mock'
def test_view_name_uses_name_attribute(self):
class MockView(APIView):
name = 'Foo'
assert MockView().get_view_name() == 'Foo'
def test_view_name_uses_suffix_attribute(self):
class MockView(APIView):
suffix = 'List'
assert MockView().get_view_name() == 'Mock List'
def test_view_name_preferences_name_over_suffix(self):
class MockView(APIView):
name = 'Foo'
suffix = 'List'
assert MockView().get_view_name() == 'Foo'
def test_view_description_uses_docstring(self): def test_view_description_uses_docstring(self):
"""Ensure view descriptions are based on the docstring.""" """Ensure view descriptions are based on the docstring."""
class MockView(APIView): class MockView(APIView):
@ -112,6 +128,17 @@ class TestViewNamesAndDescriptions(TestCase):
assert MockView().get_view_description() == DESCRIPTION assert MockView().get_view_description() == DESCRIPTION
def test_view_description_uses_description_attribute(self):
class MockView(APIView):
description = 'Foo'
assert MockView().get_view_description() == 'Foo'
def test_view_description_allows_empty_description(self):
class MockView(APIView):
"""Description."""
description = ''
assert MockView().get_view_description() == ''
def test_view_description_can_be_empty(self): def test_view_description_can_be_empty(self):
""" """
Ensure that if a view has no docstring, Ensure that if a view has no docstring,

View File

@ -16,16 +16,19 @@ from django.utils.translation import ugettext_lazy as _
from rest_framework import permissions, serializers, status from rest_framework import permissions, serializers, status
from rest_framework.compat import coreapi from rest_framework.compat import coreapi
from rest_framework.decorators import action
from rest_framework.renderers import ( from rest_framework.renderers import (
AdminRenderer, BaseRenderer, BrowsableAPIRenderer, DocumentationRenderer, AdminRenderer, BaseRenderer, BrowsableAPIRenderer, DocumentationRenderer,
HTMLFormRenderer, JSONRenderer, SchemaJSRenderer, StaticHTMLRenderer HTMLFormRenderer, JSONRenderer, SchemaJSRenderer, StaticHTMLRenderer
) )
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.routers import SimpleRouter
from rest_framework.settings import api_settings from rest_framework.settings import api_settings
from rest_framework.test import APIRequestFactory from rest_framework.test import APIRequestFactory, URLPatternsTestCase
from rest_framework.utils import json from rest_framework.utils import json
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.viewsets import ViewSet
DUMMYSTATUS = status.HTTP_200_OK DUMMYSTATUS = status.HTTP_200_OK
DUMMYCONTENT = 'dummycontent' DUMMYCONTENT = 'dummycontent'
@ -622,7 +625,18 @@ class StaticHTMLRendererTests(TestCase):
assert result == '500 Internal Server Error' assert result == '500 Internal Server Error'
class BrowsableAPIRendererTests(TestCase): class BrowsableAPIRendererTests(URLPatternsTestCase):
class ExampleViewSet(ViewSet):
def list(self, request):
return Response()
@action(detail=False, name="Extra list action")
def list_action(self, request):
raise NotImplementedError
router = SimpleRouter()
router.register('examples', ExampleViewSet, base_name='example')
urlpatterns = [url(r'^api/', include(router.urls))]
def setUp(self): def setUp(self):
self.renderer = BrowsableAPIRenderer() self.renderer = BrowsableAPIRenderer()
@ -640,6 +654,12 @@ class BrowsableAPIRendererTests(TestCase):
view=DummyView(), request={}) view=DummyView(), request={})
assert result is None assert result is None
def test_extra_actions_dropdown(self):
resp = self.client.get('/api/examples/', HTTP_ACCEPT='text/html')
assert 'id="extra-actions-menu"' in resp.content.decode('utf-8')
assert '/api/examples/list_action/' in resp.content.decode('utf-8')
assert '>Extra list action<' in resp.content.decode('utf-8')
class AdminRendererTests(TestCase): class AdminRendererTests(TestCase):

View File

@ -7,7 +7,7 @@ from django.conf.urls import include, url
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.db import models from django.db import models
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.urls import resolve from django.urls import resolve, reverse
from rest_framework import permissions, serializers, viewsets from rest_framework import permissions, serializers, viewsets
from rest_framework.compat import get_regex_pattern from rest_framework.compat import get_regex_pattern
@ -103,44 +103,59 @@ class BasicViewSet(viewsets.ViewSet):
def action1(self, request, *args, **kwargs): def action1(self, request, *args, **kwargs):
return Response({'method': 'action1'}) return Response({'method': 'action1'})
@action(methods=['post'], detail=True) @action(methods=['post', 'delete'], detail=True)
def action2(self, request, *args, **kwargs): def action2(self, request, *args, **kwargs):
return Response({'method': 'action2'}) return Response({'method': 'action2'})
@action(methods=['post', 'delete'], detail=True) @action(methods=['post'], detail=True)
def action3(self, request, *args, **kwargs): def action3(self, request, pk, *args, **kwargs):
return Response({'method': 'action2'}) return Response({'post': pk})
@action(detail=True) @action3.mapping.delete
def link1(self, request, *args, **kwargs): def action3_delete(self, request, pk, *args, **kwargs):
return Response({'method': 'link1'}) return Response({'delete': pk})
@action(detail=True)
def link2(self, request, *args, **kwargs):
return Response({'method': 'link2'})
class TestSimpleRouter(TestCase): class TestSimpleRouter(URLPatternsTestCase, TestCase):
router = SimpleRouter()
router.register('basics', BasicViewSet, base_name='basic')
urlpatterns = [
url(r'^api/', include(router.urls)),
]
def setUp(self): def setUp(self):
self.router = SimpleRouter() self.router = SimpleRouter()
def test_link_and_action_decorator(self): def test_action_routes(self):
routes = self.router.get_routes(BasicViewSet) # Get action routes (first two are list/detail)
decorator_routes = routes[2:] routes = self.router.get_routes(BasicViewSet)[2:]
# Make sure all these endpoints exist and none have been clobbered
for i, endpoint in enumerate(['action1', 'action2', 'action3', 'link1', 'link2']): assert routes[0].url == '^{prefix}/{lookup}/action1{trailing_slash}$'
route = decorator_routes[i] assert routes[0].mapping == {
# check url listing 'post': 'action1',
assert route.url == '^{{prefix}}/{{lookup}}/{0}{{trailing_slash}}$'.format(endpoint) }
# check method to function mapping
if endpoint == 'action3': assert routes[1].url == '^{prefix}/{lookup}/action2{trailing_slash}$'
methods_map = ['post', 'delete'] assert routes[1].mapping == {
elif endpoint.startswith('action'): 'post': 'action2',
methods_map = ['post'] 'delete': 'action2',
else: }
methods_map = ['get']
for method in methods_map: assert routes[2].url == '^{prefix}/{lookup}/action3{trailing_slash}$'
assert route.mapping[method] == endpoint assert routes[2].mapping == {
'post': 'action3',
'delete': 'action3_delete',
}
def test_multiple_action_handlers(self):
# Standard action
response = self.client.post(reverse('basic-action3', args=[1]))
assert response.data == {'post': '1'}
# Additional handler registered with MethodMapper
response = self.client.delete(reverse('basic-action3', args=[1]))
assert response.data == {'delete': '1'}
class TestRootView(URLPatternsTestCase, TestCase): class TestRootView(URLPatternsTestCase, TestCase):

View File

@ -75,29 +75,35 @@ class ExampleViewSet(ModelViewSet):
""" """
A description of custom action. A description of custom action.
""" """
return super(ExampleSerializer, self).retrieve(self, request) raise NotImplementedError
@action(methods=['post'], detail=True, serializer_class=AnotherSerializerWithDictField) @action(methods=['post'], detail=True, serializer_class=AnotherSerializerWithDictField)
def custom_action_with_dict_field(self, request, pk): def custom_action_with_dict_field(self, request, pk):
""" """
A custom action using a dict field in the serializer. A custom action using a dict field in the serializer.
""" """
return super(ExampleSerializer, self).retrieve(self, request) raise NotImplementedError
@action(methods=['post'], detail=True, serializer_class=AnotherSerializerWithListFields) @action(methods=['post'], detail=True, serializer_class=AnotherSerializerWithListFields)
def custom_action_with_list_fields(self, request, pk): def custom_action_with_list_fields(self, request, pk):
""" """
A custom action using both list field and list serializer in the serializer. A custom action using both list field and list serializer in the serializer.
""" """
return super(ExampleSerializer, self).retrieve(self, request) raise NotImplementedError
@action(detail=False) @action(detail=False)
def custom_list_action(self, request): def custom_list_action(self, request):
return super(ExampleViewSet, self).list(self, request) raise NotImplementedError
@action(methods=['post', 'get'], detail=False, serializer_class=EmptySerializer) @action(methods=['post', 'get'], detail=False, serializer_class=EmptySerializer)
def custom_list_action_multiple_methods(self, request): def custom_list_action_multiple_methods(self, request):
return super(ExampleViewSet, self).list(self, request) """Custom description."""
raise NotImplementedError
@custom_list_action_multiple_methods.mapping.delete
def custom_list_action_multiple_methods_delete(self, request):
"""Deletion description."""
raise NotImplementedError
def get_serializer(self, *args, **kwargs): def get_serializer(self, *args, **kwargs):
assert self.request assert self.request
@ -147,7 +153,8 @@ class TestRouterGeneratedSchema(TestCase):
'custom_list_action_multiple_methods': { 'custom_list_action_multiple_methods': {
'read': coreapi.Link( 'read': coreapi.Link(
url='/example/custom_list_action_multiple_methods/', url='/example/custom_list_action_multiple_methods/',
action='get' action='get',
description='Custom description.',
) )
}, },
'read': coreapi.Link( 'read': coreapi.Link(
@ -238,12 +245,19 @@ class TestRouterGeneratedSchema(TestCase):
'custom_list_action_multiple_methods': { 'custom_list_action_multiple_methods': {
'read': coreapi.Link( 'read': coreapi.Link(
url='/example/custom_list_action_multiple_methods/', url='/example/custom_list_action_multiple_methods/',
action='get' action='get',
description='Custom description.',
), ),
'create': coreapi.Link( 'create': coreapi.Link(
url='/example/custom_list_action_multiple_methods/', url='/example/custom_list_action_multiple_methods/',
action='post' action='post',
) description='Custom description.',
),
'delete': coreapi.Link(
url='/example/custom_list_action_multiple_methods/',
action='delete',
description='Deletion description.',
),
}, },
'update': coreapi.Link( 'update': coreapi.Link(
url='/example/{id}/', url='/example/{id}/',
@ -526,7 +540,8 @@ class TestSchemaGeneratorWithMethodLimitedViewSets(TestCase):
'custom_list_action_multiple_methods': { 'custom_list_action_multiple_methods': {
'read': coreapi.Link( 'read': coreapi.Link(
url='/example1/custom_list_action_multiple_methods/', url='/example1/custom_list_action_multiple_methods/',
action='get' action='get',
description='Custom description.',
) )
}, },
'read': coreapi.Link( 'read': coreapi.Link(

View File

@ -4,6 +4,7 @@ from __future__ import unicode_literals
from django.conf.urls import url from django.conf.urls import url
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from rest_framework.decorators import action
from rest_framework.routers import SimpleRouter from rest_framework.routers import SimpleRouter
from rest_framework.serializers import ModelSerializer from rest_framework.serializers import ModelSerializer
from rest_framework.utils import json from rest_framework.utils import json
@ -43,6 +44,14 @@ class ResourceViewSet(ModelViewSet):
serializer_class = ModelSerializer serializer_class = ModelSerializer
queryset = BasicModel.objects.all() queryset = BasicModel.objects.all()
@action(detail=False)
def list_action(self, request, *args, **kwargs):
raise NotImplementedError
@action(detail=True)
def detail_action(self, request, *args, **kwargs):
raise NotImplementedError
router = SimpleRouter() router = SimpleRouter()
router.register(r'resources', ResourceViewSet) router.register(r'resources', ResourceViewSet)
@ -119,6 +128,23 @@ class BreadcrumbTests(TestCase):
('Resource Instance', '/resources/1/') ('Resource Instance', '/resources/1/')
] ]
def test_modelviewset_list_action_breadcrumbs(self):
url = '/resources/list_action/'
assert get_breadcrumbs(url) == [
('Root', '/'),
('Resource List', '/resources/'),
('List action', '/resources/list_action/'),
]
def test_modelviewset_detail_action_breadcrumbs(self):
url = '/resources/1/detail_action/'
assert get_breadcrumbs(url) == [
('Root', '/'),
('Resource List', '/resources/'),
('Resource Instance', '/resources/1/'),
('Detail action', '/resources/1/detail_action/'),
]
class JsonFloatTests(TestCase): class JsonFloatTests(TestCase):
""" """

View File

@ -1,3 +1,5 @@
from collections import OrderedDict
import pytest import pytest
from django.conf.urls import include, url from django.conf.urls import include, url
from django.db import models from django.db import models
@ -35,10 +37,10 @@ class ActionViewSet(GenericViewSet):
queryset = Action.objects.all() queryset = Action.objects.all()
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
raise NotImplementedError return Response()
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
raise NotImplementedError return Response()
@action(detail=False) @action(detail=False)
def list_action(self, request, *args, **kwargs): def list_action(self, request, *args, **kwargs):
@ -56,6 +58,10 @@ class ActionViewSet(GenericViewSet):
def custom_detail_action(self, request, *args, **kwargs): def custom_detail_action(self, request, *args, **kwargs):
raise NotImplementedError raise NotImplementedError
@action(detail=True, url_path=r'unresolvable/(?P<arg>\w+)', url_name='unresolvable')
def unresolvable_detail_action(self, request, *args, **kwargs):
raise NotImplementedError
router = SimpleRouter() router = SimpleRouter()
router.register(r'actions', ActionViewSet) router.register(r'actions', ActionViewSet)
@ -96,6 +102,16 @@ class InitializeViewSetsTestCase(TestCase):
"when calling `.as_view()` on a ViewSet. " "when calling `.as_view()` on a ViewSet. "
"For example `.as_view({'get': 'list'})`") "For example `.as_view({'get': 'list'})`")
def test_initialize_view_set_with_both_name_and_suffix(self):
with pytest.raises(TypeError) as excinfo:
BasicViewSet.as_view(name='', suffix='', actions={
'get': 'list',
})
assert str(excinfo.value) == (
"BasicViewSet() received both `name` and `suffix`, "
"which are mutually exclusive arguments.")
def test_args_kwargs_request_action_map_on_self(self): def test_args_kwargs_request_action_map_on_self(self):
""" """
Test a view only has args, kwargs, request, action_map Test a view only has args, kwargs, request, action_map
@ -111,16 +127,52 @@ class InitializeViewSetsTestCase(TestCase):
self.assertIn(attribute, dir(view)) self.assertIn(attribute, dir(view))
class GetExtraActionTests(TestCase): class GetExtraActionsTests(TestCase):
def test_extra_actions(self): def test_extra_actions(self):
view = ActionViewSet() view = ActionViewSet()
actual = [action.__name__ for action in view.get_extra_actions()] actual = [action.__name__ for action in view.get_extra_actions()]
expected = ['custom_detail_action', 'custom_list_action', 'detail_action', 'list_action'] expected = [
'custom_detail_action',
'custom_list_action',
'detail_action',
'list_action',
'unresolvable_detail_action',
]
self.assertEqual(actual, expected) self.assertEqual(actual, expected)
@override_settings(ROOT_URLCONF='tests.test_viewsets')
class GetExtraActionUrlMapTests(TestCase):
def test_list_view(self):
response = self.client.get('/api/actions/')
view = response.renderer_context['view']
expected = OrderedDict([
('Custom list action', 'http://testserver/api/actions/custom_list_action/'),
('List action', 'http://testserver/api/actions/list_action/'),
])
self.assertEqual(view.get_extra_action_url_map(), expected)
def test_detail_view(self):
response = self.client.get('/api/actions/1/')
view = response.renderer_context['view']
expected = OrderedDict([
('Custom detail action', 'http://testserver/api/actions/1/custom_detail_action/'),
('Detail action', 'http://testserver/api/actions/1/detail_action/'),
# "Unresolvable detail action" excluded, since it's not resolvable
])
self.assertEqual(view.get_extra_action_url_map(), expected)
def test_uninitialized_view(self):
self.assertEqual(ActionViewSet().get_extra_action_url_map(), OrderedDict())
@override_settings(ROOT_URLCONF='tests.test_viewsets') @override_settings(ROOT_URLCONF='tests.test_viewsets')
class ReverseActionTests(TestCase): class ReverseActionTests(TestCase):
def test_default_basename(self): def test_default_basename(self):