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:
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__`.
* `suffix`: The optional suffix used when differentiating individual views in a viewset.
* `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__`.
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'`
@ -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:
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.
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'`
## 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`).
* `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.
* `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:
@ -142,7 +144,7 @@ A more complete example of extra actions:
queryset = User.objects.all()
serializer_class = UserSerializer
@action(methods=['post'], detail=True)
@action(detail=True, methods=['post'])
def set_password(self, request, pk=None):
user = self.get_object()
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:
@action(methods=['post'], detail=True, permission_classes=[IsAdminOrIsSelf])
@action(detail=True, methods=['post'], permission_classes=[IsAdminOrIsSelf])
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:
@action(methods=['post', 'delete'], detail=True)
@action(detail=True, methods=['post', 'delete'])
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.
### 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
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 warnings
from django.forms.utils import pretty_name
from django.utils import six
from rest_framework.views import APIView
@ -130,7 +131,7 @@ def schema(view_inspector):
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.
@ -145,15 +146,81 @@ def action(methods=None, detail=None, url_path=None, url_name=None, **kwargs):
)
def decorator(func):
func.bind_to_methods = methods
func.mapping = MethodMapper(func, methods)
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_name = url_name if url_name else func.__name__.replace('_', '-')
func.kwargs = kwargs
func.kwargs.update({
'name': func.name,
'description': func.__doc__ or None
})
return func
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):
"""
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):
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):
if not hasattr(view, 'get_queryset') or not hasattr(view, 'filter_backends'):
return
@ -698,6 +703,8 @@ class BrowsableAPIRenderer(BaseRenderer):
'delete_form': self.get_rendered_html_form(data, view, 'DELETE', 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),
'raw_data_put_form': raw_data_put_form,

View File

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

View File

@ -110,6 +110,20 @@
</form>
{% 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 %}
<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>

View File

@ -128,6 +128,20 @@
</div>
{% 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 %}
<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>

View File

@ -30,7 +30,6 @@ def get_breadcrumbs(url, request=None):
# Probably an optional trailing slash.
if not seen or seen[-1] != view:
c = cls(**initkwargs)
c.suffix = getattr(view, 'suffix', None)
name = c.get_view_name()
insert_url = preserve_builtin_query_params(prefix + url, request)
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
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.
This name is used in the browsable API, and in OPTIONS responses.
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, 'ViewSet')
name = formatting.camelcase_to_spaces(name)
# Suffix may be set by some Views, such as a ViewSet.
suffix = getattr(view, 'suffix', None)
if suffix:
name += ' ' + suffix
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.
This name is used in the browsable API, and in OPTIONS responses.
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))
if html:
return formatting.markup_description(description)
@ -224,7 +236,7 @@ class APIView(View):
browsable API.
"""
func = self.settings.VIEW_NAME_FUNCTION
return func(self.__class__, getattr(self, 'suffix', None))
return func(self)
def get_view_description(self, html=False):
"""
@ -232,7 +244,7 @@ class APIView(View):
and in the browsable API.
"""
func = self.settings.VIEW_DESCRIPTION_FUNCTION
return func(self.__class__, html)
return func(self, html)
# API policy instantiation methods

View File

@ -18,9 +18,11 @@ automatically.
"""
from __future__ import unicode_literals
from collections import OrderedDict
from functools import update_wrapper
from inspect import getmembers
from django.urls import NoReverseMatch
from django.utils.decorators import classonlymethod
from django.views.decorators.csrf import csrf_exempt
@ -29,7 +31,7 @@ from rest_framework.reverse import reverse
def _is_extra_action(attr):
return hasattr(attr, 'bind_to_methods')
return hasattr(attr, 'mapping')
class ViewSetMixin(object):
@ -52,7 +54,13 @@ class ViewSetMixin(object):
instantiated view, we need to totally reimplement `.as_view`,
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.
# This initkwarg should have no effect if the name is provided.
# eg. 'List' or 'Instance'.
cls.suffix = None
@ -79,6 +87,11 @@ class ViewSetMixin(object):
raise TypeError("%s() received an invalid keyword %r" % (
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):
self = cls(**initkwargs)
# We also store the mapping of request methods to actions,
@ -114,7 +127,6 @@ class ViewSetMixin(object):
# resolved URL.
view.cls = cls
view.initkwargs = initkwargs
view.suffix = initkwargs.get('suffix', None)
view.actions = actions
return csrf_exempt(view)
@ -149,6 +161,34 @@ class ViewSetMixin(object):
"""
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):
"""

View File

@ -175,26 +175,85 @@ class ActionDecoratorTestCase(TestCase):
def test_defaults(self):
@action(detail=True)
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.name == 'Test action'
assert test_action.url_path == 'test_action'
assert test_action.url_name == 'test-action'
assert test_action.kwargs == {
'name': 'Test action',
'description': 'Description',
}
def test_detail_required(self):
with pytest.raises(AssertionError) as excinfo:
@action()
def test_action(request):
pass
raise NotImplementedError
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):
with pytest.warns(PendingDeprecationWarning) as record:
@detail_route()
def view(request):
pass
raise NotImplementedError
assert len(record) == 1
assert str(record[0].message) == (
@ -207,7 +266,7 @@ class ActionDecoratorTestCase(TestCase):
with pytest.warns(PendingDeprecationWarning) as record:
@list_route()
def view(request):
pass
raise NotImplementedError
assert len(record) == 1
assert str(record[0].message) == (
@ -221,7 +280,7 @@ class ActionDecoratorTestCase(TestCase):
with pytest.warns(PendingDeprecationWarning):
@list_route(url_path='foo_bar')
def view(request):
pass
raise NotImplementedError
assert view.url_path == 'foo_bar'
assert view.url_name == 'foo-bar'

View File

@ -85,6 +85,22 @@ class TestViewNamesAndDescriptions(TestCase):
pass
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):
"""Ensure view descriptions are based on the docstring."""
class MockView(APIView):
@ -112,6 +128,17 @@ class TestViewNamesAndDescriptions(TestCase):
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):
"""
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.compat import coreapi
from rest_framework.decorators import action
from rest_framework.renderers import (
AdminRenderer, BaseRenderer, BrowsableAPIRenderer, DocumentationRenderer,
HTMLFormRenderer, JSONRenderer, SchemaJSRenderer, StaticHTMLRenderer
)
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.routers import SimpleRouter
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.views import APIView
from rest_framework.viewsets import ViewSet
DUMMYSTATUS = status.HTTP_200_OK
DUMMYCONTENT = 'dummycontent'
@ -622,7 +625,18 @@ class StaticHTMLRendererTests(TestCase):
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):
self.renderer = BrowsableAPIRenderer()
@ -640,6 +654,12 @@ class BrowsableAPIRendererTests(TestCase):
view=DummyView(), request={})
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):

View File

@ -7,7 +7,7 @@ from django.conf.urls import include, url
from django.core.exceptions import ImproperlyConfigured
from django.db import models
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.compat import get_regex_pattern
@ -103,44 +103,59 @@ class BasicViewSet(viewsets.ViewSet):
def action1(self, request, *args, **kwargs):
return Response({'method': 'action1'})
@action(methods=['post'], detail=True)
@action(methods=['post', 'delete'], detail=True)
def action2(self, request, *args, **kwargs):
return Response({'method': 'action2'})
@action(methods=['post', 'delete'], detail=True)
def action3(self, request, *args, **kwargs):
return Response({'method': 'action2'})
@action(methods=['post'], detail=True)
def action3(self, request, pk, *args, **kwargs):
return Response({'post': pk})
@action(detail=True)
def link1(self, request, *args, **kwargs):
return Response({'method': 'link1'})
@action(detail=True)
def link2(self, request, *args, **kwargs):
return Response({'method': 'link2'})
@action3.mapping.delete
def action3_delete(self, request, pk, *args, **kwargs):
return Response({'delete': pk})
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):
self.router = SimpleRouter()
def test_link_and_action_decorator(self):
routes = self.router.get_routes(BasicViewSet)
decorator_routes = routes[2:]
# Make sure all these endpoints exist and none have been clobbered
for i, endpoint in enumerate(['action1', 'action2', 'action3', 'link1', 'link2']):
route = decorator_routes[i]
# check url listing
assert route.url == '^{{prefix}}/{{lookup}}/{0}{{trailing_slash}}$'.format(endpoint)
# check method to function mapping
if endpoint == 'action3':
methods_map = ['post', 'delete']
elif endpoint.startswith('action'):
methods_map = ['post']
else:
methods_map = ['get']
for method in methods_map:
assert route.mapping[method] == endpoint
def test_action_routes(self):
# Get action routes (first two are list/detail)
routes = self.router.get_routes(BasicViewSet)[2:]
assert routes[0].url == '^{prefix}/{lookup}/action1{trailing_slash}$'
assert routes[0].mapping == {
'post': 'action1',
}
assert routes[1].url == '^{prefix}/{lookup}/action2{trailing_slash}$'
assert routes[1].mapping == {
'post': 'action2',
'delete': 'action2',
}
assert routes[2].url == '^{prefix}/{lookup}/action3{trailing_slash}$'
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):

View File

@ -75,29 +75,35 @@ class ExampleViewSet(ModelViewSet):
"""
A description of custom action.
"""
return super(ExampleSerializer, self).retrieve(self, request)
raise NotImplementedError
@action(methods=['post'], detail=True, serializer_class=AnotherSerializerWithDictField)
def custom_action_with_dict_field(self, request, pk):
"""
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)
def custom_action_with_list_fields(self, request, pk):
"""
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)
def custom_list_action(self, request):
return super(ExampleViewSet, self).list(self, request)
raise NotImplementedError
@action(methods=['post', 'get'], detail=False, serializer_class=EmptySerializer)
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):
assert self.request
@ -147,7 +153,8 @@ class TestRouterGeneratedSchema(TestCase):
'custom_list_action_multiple_methods': {
'read': coreapi.Link(
url='/example/custom_list_action_multiple_methods/',
action='get'
action='get',
description='Custom description.',
)
},
'read': coreapi.Link(
@ -238,12 +245,19 @@ class TestRouterGeneratedSchema(TestCase):
'custom_list_action_multiple_methods': {
'read': coreapi.Link(
url='/example/custom_list_action_multiple_methods/',
action='get'
action='get',
description='Custom description.',
),
'create': coreapi.Link(
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(
url='/example/{id}/',
@ -526,7 +540,8 @@ class TestSchemaGeneratorWithMethodLimitedViewSets(TestCase):
'custom_list_action_multiple_methods': {
'read': coreapi.Link(
url='/example1/custom_list_action_multiple_methods/',
action='get'
action='get',
description='Custom description.',
)
},
'read': coreapi.Link(

View File

@ -4,6 +4,7 @@ from __future__ import unicode_literals
from django.conf.urls import url
from django.test import TestCase, override_settings
from rest_framework.decorators import action
from rest_framework.routers import SimpleRouter
from rest_framework.serializers import ModelSerializer
from rest_framework.utils import json
@ -43,6 +44,14 @@ class ResourceViewSet(ModelViewSet):
serializer_class = ModelSerializer
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.register(r'resources', ResourceViewSet)
@ -119,6 +128,23 @@ class BreadcrumbTests(TestCase):
('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):
"""

View File

@ -1,3 +1,5 @@
from collections import OrderedDict
import pytest
from django.conf.urls import include, url
from django.db import models
@ -35,10 +37,10 @@ class ActionViewSet(GenericViewSet):
queryset = Action.objects.all()
def list(self, request, *args, **kwargs):
raise NotImplementedError
return Response()
def retrieve(self, request, *args, **kwargs):
raise NotImplementedError
return Response()
@action(detail=False)
def list_action(self, request, *args, **kwargs):
@ -56,6 +58,10 @@ class ActionViewSet(GenericViewSet):
def custom_detail_action(self, request, *args, **kwargs):
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.register(r'actions', ActionViewSet)
@ -96,6 +102,16 @@ class InitializeViewSetsTestCase(TestCase):
"when calling `.as_view()` on a ViewSet. "
"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):
"""
Test a view only has args, kwargs, request, action_map
@ -111,16 +127,52 @@ class InitializeViewSetsTestCase(TestCase):
self.assertIn(attribute, dir(view))
class GetExtraActionTests(TestCase):
class GetExtraActionsTests(TestCase):
def test_extra_actions(self):
view = ActionViewSet()
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)
@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')
class ReverseActionTests(TestCase):
def test_default_basename(self):