mirror of
https://github.com/encode/django-rest-framework.git
synced 2024-11-25 19:14:01 +03:00
Fix action support for ViewSet suffixes (#6081)
* Add suffix support for actions Removes the newly introduced `action.name` in favor of leveraging the View's `.get_view_name()` method, which supports both name and suffix. * Fix view description func docstrings * Test action decorator name & suffix kwargs * Adjust 'extra action' docs
This commit is contained in:
parent
20a7734dce
commit
903204cd79
|
@ -127,7 +127,7 @@ You may inspect these attributes to adjust behaviour based on the current action
|
||||||
|
|
||||||
## Marking extra actions for routing
|
## Marking extra actions for routing
|
||||||
|
|
||||||
If you have ad-hoc methods that should be routable, you can mark them as such with the `@action` decorator. Like regular actions, extra actions may be intended for either a list of objects, or a single instance. To indicate this, set the `detail` argument to `True` or `False`. The router will configure its URL patterns accordingly. e.g., the `DefaultRouter` will configure detail actions to contain `pk` in their URL patterns.
|
If you have ad-hoc methods that should be routable, you can mark them as such with the `@action` decorator. Like regular actions, extra actions may be intended for either a single object, or an entire collection. To indicate this, set the `detail` argument to `True` or `False`. The router will configure its URL patterns accordingly. e.g., the `DefaultRouter` will configure detail actions to contain `pk` in their URL patterns.
|
||||||
|
|
||||||
A more complete example of extra actions:
|
A more complete example of extra actions:
|
||||||
|
|
||||||
|
@ -174,7 +174,7 @@ The decorator can additionally take extra arguments that will be set for the rou
|
||||||
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:
|
The `action` decorator will route `GET` requests by default, but may also accept other HTTP methods by setting the `methods` argument. For example:
|
||||||
|
|
||||||
@action(detail=True, methods=['post', 'delete'])
|
@action(detail=True, methods=['post', 'delete'])
|
||||||
def unset_password(self, request, pk=None):
|
def unset_password(self, request, pk=None):
|
||||||
|
@ -186,7 +186,7 @@ To view all extra actions, call the `.get_extra_actions()` method.
|
||||||
|
|
||||||
### Routing additional HTTP methods for extra actions
|
### 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.
|
Extra actions can map additional HTTP methods to separate `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
|
```python
|
||||||
@action(detail=True, methods=['put'], name='Change Password')
|
@action(detail=True, methods=['put'], name='Change Password')
|
||||||
|
|
|
@ -131,7 +131,7 @@ def schema(view_inspector):
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
def action(methods=None, detail=None, name=None, url_path=None, url_name=None, **kwargs):
|
def action(methods=None, detail=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,18 +145,22 @@ def action(methods=None, detail=None, name=None, url_path=None, url_name=None, *
|
||||||
"@action() missing required argument: 'detail'"
|
"@action() missing required argument: 'detail'"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# name and suffix are mutually exclusive
|
||||||
|
if 'name' in kwargs and 'suffix' in kwargs:
|
||||||
|
raise TypeError("`name` and `suffix` are mutually exclusive arguments.")
|
||||||
|
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
func.mapping = MethodMapper(func, 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,
|
# Set descriptive arguments for viewsets
|
||||||
'description': func.__doc__ or None
|
if 'name' not in kwargs and 'suffix' not in kwargs:
|
||||||
})
|
func.kwargs['name'] = pretty_name(func.__name__)
|
||||||
|
func.kwargs['description'] = func.__doc__ or None
|
||||||
|
|
||||||
return func
|
return func
|
||||||
return decorator
|
return decorator
|
||||||
|
|
|
@ -23,7 +23,7 @@ from rest_framework.utils import formatting
|
||||||
|
|
||||||
def get_view_name(view):
|
def get_view_name(view):
|
||||||
"""
|
"""
|
||||||
Given a view class, return a textual name to represent the view.
|
Given a view instance, 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.
|
||||||
|
@ -48,7 +48,7 @@ def get_view_name(view):
|
||||||
|
|
||||||
def get_view_description(view, html=False):
|
def get_view_description(view, html=False):
|
||||||
"""
|
"""
|
||||||
Given a view class, return a textual description to represent the view.
|
Given a view instance, 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.
|
||||||
|
|
|
@ -183,7 +183,8 @@ class ViewSetMixin(object):
|
||||||
try:
|
try:
|
||||||
url_name = '%s-%s' % (self.basename, action.url_name)
|
url_name = '%s-%s' % (self.basename, action.url_name)
|
||||||
url = reverse(url_name, self.args, self.kwargs, request=self.request)
|
url = reverse(url_name, self.args, self.kwargs, request=self.request)
|
||||||
action_urls[action.name] = url
|
view = self.__class__(**action.kwargs)
|
||||||
|
action_urls[view.get_view_name()] = url
|
||||||
except NoReverseMatch:
|
except NoReverseMatch:
|
||||||
pass # URL requires additional arguments, ignore
|
pass # URL requires additional arguments, ignore
|
||||||
|
|
||||||
|
|
|
@ -179,7 +179,6 @@ class ActionDecoratorTestCase(TestCase):
|
||||||
|
|
||||||
assert test_action.mapping == {'get': 'test_action'}
|
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 == {
|
assert test_action.kwargs == {
|
||||||
|
@ -213,6 +212,47 @@ class ActionDecoratorTestCase(TestCase):
|
||||||
for name in APIView.http_method_names:
|
for name in APIView.http_method_names:
|
||||||
assert test_action.mapping[name] == name
|
assert test_action.mapping[name] == name
|
||||||
|
|
||||||
|
def test_view_name_kwargs(self):
|
||||||
|
"""
|
||||||
|
'name' and 'suffix' are mutually exclusive kwargs used for generating
|
||||||
|
a view's display name.
|
||||||
|
"""
|
||||||
|
# by default, generate name from method
|
||||||
|
@action(detail=True)
|
||||||
|
def test_action(request):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
assert test_action.kwargs == {
|
||||||
|
'description': None,
|
||||||
|
'name': 'Test action',
|
||||||
|
}
|
||||||
|
|
||||||
|
# name kwarg supersedes name generation
|
||||||
|
@action(detail=True, name='test name')
|
||||||
|
def test_action(request):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
assert test_action.kwargs == {
|
||||||
|
'description': None,
|
||||||
|
'name': 'test name',
|
||||||
|
}
|
||||||
|
|
||||||
|
# suffix kwarg supersedes name generation
|
||||||
|
@action(detail=True, suffix='Suffix')
|
||||||
|
def test_action(request):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
assert test_action.kwargs == {
|
||||||
|
'description': None,
|
||||||
|
'suffix': 'Suffix',
|
||||||
|
}
|
||||||
|
|
||||||
|
# name + suffix is a conflict.
|
||||||
|
with pytest.raises(TypeError) as excinfo:
|
||||||
|
action(detail=True, name='test name', suffix='Suffix')
|
||||||
|
|
||||||
|
assert str(excinfo.value) == "`name` and `suffix` are mutually exclusive arguments."
|
||||||
|
|
||||||
def test_method_mapping(self):
|
def test_method_mapping(self):
|
||||||
@action(detail=False)
|
@action(detail=False)
|
||||||
def test_action(request):
|
def test_action(request):
|
||||||
|
@ -223,7 +263,7 @@ class ActionDecoratorTestCase(TestCase):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
# The secondary handler methods should not have the action attributes
|
# The secondary handler methods should not have the action attributes
|
||||||
for name in ['mapping', 'detail', 'name', 'url_path', 'url_name', 'kwargs']:
|
for name in ['mapping', 'detail', 'url_path', 'url_name', 'kwargs']:
|
||||||
assert hasattr(test_action, name) and not hasattr(test_action_post, name)
|
assert hasattr(test_action, name) and not hasattr(test_action_post, name)
|
||||||
|
|
||||||
def test_method_mapping_already_mapped(self):
|
def test_method_mapping_already_mapped(self):
|
||||||
|
|
|
@ -52,6 +52,14 @@ class ResourceViewSet(ModelViewSet):
|
||||||
def detail_action(self, request, *args, **kwargs):
|
def detail_action(self, request, *args, **kwargs):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@action(detail=True, name='Custom Name')
|
||||||
|
def named_action(self, request, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@action(detail=True, suffix='Custom Suffix')
|
||||||
|
def suffixed_action(self, request, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
router = SimpleRouter()
|
router = SimpleRouter()
|
||||||
router.register(r'resources', ResourceViewSet)
|
router.register(r'resources', ResourceViewSet)
|
||||||
|
@ -145,6 +153,24 @@ class BreadcrumbTests(TestCase):
|
||||||
('Detail action', '/resources/1/detail_action/'),
|
('Detail action', '/resources/1/detail_action/'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def test_modelviewset_action_name_kwarg(self):
|
||||||
|
url = '/resources/1/named_action/'
|
||||||
|
assert get_breadcrumbs(url) == [
|
||||||
|
('Root', '/'),
|
||||||
|
('Resource List', '/resources/'),
|
||||||
|
('Resource Instance', '/resources/1/'),
|
||||||
|
('Custom Name', '/resources/1/named_action/'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_modelviewset_action_suffix_kwarg(self):
|
||||||
|
url = '/resources/1/suffixed_action/'
|
||||||
|
assert get_breadcrumbs(url) == [
|
||||||
|
('Root', '/'),
|
||||||
|
('Resource List', '/resources/'),
|
||||||
|
('Resource Instance', '/resources/1/'),
|
||||||
|
('Resource Custom Suffix', '/resources/1/suffixed_action/'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class JsonFloatTests(TestCase):
|
class JsonFloatTests(TestCase):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -63,9 +63,28 @@ class ActionViewSet(GenericViewSet):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class ActionNamesViewSet(GenericViewSet):
|
||||||
|
|
||||||
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
return Response()
|
||||||
|
|
||||||
|
@action(detail=True)
|
||||||
|
def unnamed_action(self, request, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@action(detail=True, name='Custom Name')
|
||||||
|
def named_action(self, request, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@action(detail=True, suffix='Custom Suffix')
|
||||||
|
def suffixed_action(self, request, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
router = SimpleRouter()
|
router = SimpleRouter()
|
||||||
router.register(r'actions', ActionViewSet)
|
router.register(r'actions', ActionViewSet)
|
||||||
router.register(r'actions-alt', ActionViewSet, basename='actions-alt')
|
router.register(r'actions-alt', ActionViewSet, basename='actions-alt')
|
||||||
|
router.register(r'names', ActionNamesViewSet, basename='names')
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
@ -172,6 +191,19 @@ class GetExtraActionUrlMapTests(TestCase):
|
||||||
def test_uninitialized_view(self):
|
def test_uninitialized_view(self):
|
||||||
self.assertEqual(ActionViewSet().get_extra_action_url_map(), OrderedDict())
|
self.assertEqual(ActionViewSet().get_extra_action_url_map(), OrderedDict())
|
||||||
|
|
||||||
|
def test_action_names(self):
|
||||||
|
# Action 'name' and 'suffix' kwargs should be respected
|
||||||
|
response = self.client.get('/api/names/1/')
|
||||||
|
view = response.renderer_context['view']
|
||||||
|
|
||||||
|
expected = OrderedDict([
|
||||||
|
('Custom Name', 'http://testserver/api/names/1/named_action/'),
|
||||||
|
('Action Names Custom Suffix', 'http://testserver/api/names/1/suffixed_action/'),
|
||||||
|
('Unnamed action', 'http://testserver/api/names/1/unnamed_action/'),
|
||||||
|
])
|
||||||
|
|
||||||
|
self.assertEqual(view.get_extra_action_url_map(), expected)
|
||||||
|
|
||||||
|
|
||||||
@override_settings(ROOT_URLCONF='tests.test_viewsets')
|
@override_settings(ROOT_URLCONF='tests.test_viewsets')
|
||||||
class ReverseActionTests(TestCase):
|
class ReverseActionTests(TestCase):
|
||||||
|
|
Loading…
Reference in New Issue
Block a user