Skip to content

Commit 903204c

Browse files
rpkilbycarltongibson
authored andcommitted
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
1 parent 20a7734 commit 903204c

File tree

7 files changed

+117
-14
lines changed

7 files changed

+117
-14
lines changed

docs/api-guide/viewsets.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ You may inspect these attributes to adjust behaviour based on the current action
127127

128128
## Marking extra actions for routing
129129

130-
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.
130+
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.
131131

132132
A more complete example of extra actions:
133133

@@ -174,7 +174,7 @@ The decorator can additionally take extra arguments that will be set for the rou
174174
def set_password(self, request, pk=None):
175175
...
176176

177-
These decorator will route `GET` requests by default, but may also accept other HTTP methods by setting the `methods` argument. For example:
177+
The `action` decorator will route `GET` requests by default, but may also accept other HTTP methods by setting the `methods` argument. For example:
178178

179179
@action(detail=True, methods=['post', 'delete'])
180180
def unset_password(self, request, pk=None):
@@ -186,7 +186,7 @@ To view all extra actions, call the `.get_extra_actions()` method.
186186

187187
### Routing additional HTTP methods for extra actions
188188

189-
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.
189+
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.
190190

191191
```python
192192
@action(detail=True, methods=['put'], name='Change Password')

rest_framework/decorators.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ def decorator(func):
131131
return decorator
132132

133133

134-
def action(methods=None, detail=None, name=None, url_path=None, url_name=None, **kwargs):
134+
def action(methods=None, detail=None, url_path=None, url_name=None, **kwargs):
135135
"""
136136
Mark a ViewSet method as a routable action.
137137
@@ -145,18 +145,22 @@ def action(methods=None, detail=None, name=None, url_path=None, url_name=None, *
145145
"@action() missing required argument: 'detail'"
146146
)
147147

148+
# name and suffix are mutually exclusive
149+
if 'name' in kwargs and 'suffix' in kwargs:
150+
raise TypeError("`name` and `suffix` are mutually exclusive arguments.")
151+
148152
def decorator(func):
149153
func.mapping = MethodMapper(func, methods)
150154

151155
func.detail = detail
152-
func.name = name if name else pretty_name(func.__name__)
153156
func.url_path = url_path if url_path else func.__name__
154157
func.url_name = url_name if url_name else func.__name__.replace('_', '-')
155158
func.kwargs = kwargs
156-
func.kwargs.update({
157-
'name': func.name,
158-
'description': func.__doc__ or None
159-
})
159+
160+
# Set descriptive arguments for viewsets
161+
if 'name' not in kwargs and 'suffix' not in kwargs:
162+
func.kwargs['name'] = pretty_name(func.__name__)
163+
func.kwargs['description'] = func.__doc__ or None
160164

161165
return func
162166
return decorator

rest_framework/views.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
def get_view_name(view):
2525
"""
26-
Given a view class, return a textual name to represent the view.
26+
Given a view instance, return a textual name to represent the view.
2727
This name is used in the browsable API, and in OPTIONS responses.
2828
2929
This function is the default for the `VIEW_NAME_FUNCTION` setting.
@@ -48,7 +48,7 @@ def get_view_name(view):
4848

4949
def get_view_description(view, html=False):
5050
"""
51-
Given a view class, return a textual description to represent the view.
51+
Given a view instance, return a textual description to represent the view.
5252
This name is used in the browsable API, and in OPTIONS responses.
5353
5454
This function is the default for the `VIEW_DESCRIPTION_FUNCTION` setting.

rest_framework/viewsets.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,8 @@ def get_extra_action_url_map(self):
183183
try:
184184
url_name = '%s-%s' % (self.basename, action.url_name)
185185
url = reverse(url_name, self.args, self.kwargs, request=self.request)
186-
action_urls[action.name] = url
186+
view = self.__class__(**action.kwargs)
187+
action_urls[view.get_view_name()] = url
187188
except NoReverseMatch:
188189
pass # URL requires additional arguments, ignore
189190

tests/test_decorators.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,6 @@ def test_action(request):
179179

180180
assert test_action.mapping == {'get': 'test_action'}
181181
assert test_action.detail is True
182-
assert test_action.name == 'Test action'
183182
assert test_action.url_path == 'test_action'
184183
assert test_action.url_name == 'test-action'
185184
assert test_action.kwargs == {
@@ -213,6 +212,47 @@ def method():
213212
for name in APIView.http_method_names:
214213
assert test_action.mapping[name] == name
215214

215+
def test_view_name_kwargs(self):
216+
"""
217+
'name' and 'suffix' are mutually exclusive kwargs used for generating
218+
a view's display name.
219+
"""
220+
# by default, generate name from method
221+
@action(detail=True)
222+
def test_action(request):
223+
raise NotImplementedError
224+
225+
assert test_action.kwargs == {
226+
'description': None,
227+
'name': 'Test action',
228+
}
229+
230+
# name kwarg supersedes name generation
231+
@action(detail=True, name='test name')
232+
def test_action(request):
233+
raise NotImplementedError
234+
235+
assert test_action.kwargs == {
236+
'description': None,
237+
'name': 'test name',
238+
}
239+
240+
# suffix kwarg supersedes name generation
241+
@action(detail=True, suffix='Suffix')
242+
def test_action(request):
243+
raise NotImplementedError
244+
245+
assert test_action.kwargs == {
246+
'description': None,
247+
'suffix': 'Suffix',
248+
}
249+
250+
# name + suffix is a conflict.
251+
with pytest.raises(TypeError) as excinfo:
252+
action(detail=True, name='test name', suffix='Suffix')
253+
254+
assert str(excinfo.value) == "`name` and `suffix` are mutually exclusive arguments."
255+
216256
def test_method_mapping(self):
217257
@action(detail=False)
218258
def test_action(request):
@@ -223,7 +263,7 @@ def test_action_post(request):
223263
raise NotImplementedError
224264

225265
# The secondary handler methods should not have the action attributes
226-
for name in ['mapping', 'detail', 'name', 'url_path', 'url_name', 'kwargs']:
266+
for name in ['mapping', 'detail', 'url_path', 'url_name', 'kwargs']:
227267
assert hasattr(test_action, name) and not hasattr(test_action_post, name)
228268

229269
def test_method_mapping_already_mapped(self):

tests/test_utils.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ def list_action(self, request, *args, **kwargs):
5252
def detail_action(self, request, *args, **kwargs):
5353
raise NotImplementedError
5454

55+
@action(detail=True, name='Custom Name')
56+
def named_action(self, request, *args, **kwargs):
57+
raise NotImplementedError
58+
59+
@action(detail=True, suffix='Custom Suffix')
60+
def suffixed_action(self, request, *args, **kwargs):
61+
raise NotImplementedError
62+
5563

5664
router = SimpleRouter()
5765
router.register(r'resources', ResourceViewSet)
@@ -145,6 +153,24 @@ def test_modelviewset_detail_action_breadcrumbs(self):
145153
('Detail action', '/resources/1/detail_action/'),
146154
]
147155

156+
def test_modelviewset_action_name_kwarg(self):
157+
url = '/resources/1/named_action/'
158+
assert get_breadcrumbs(url) == [
159+
('Root', '/'),
160+
('Resource List', '/resources/'),
161+
('Resource Instance', '/resources/1/'),
162+
('Custom Name', '/resources/1/named_action/'),
163+
]
164+
165+
def test_modelviewset_action_suffix_kwarg(self):
166+
url = '/resources/1/suffixed_action/'
167+
assert get_breadcrumbs(url) == [
168+
('Root', '/'),
169+
('Resource List', '/resources/'),
170+
('Resource Instance', '/resources/1/'),
171+
('Resource Custom Suffix', '/resources/1/suffixed_action/'),
172+
]
173+
148174

149175
class JsonFloatTests(TestCase):
150176
"""

tests/test_viewsets.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,28 @@ def unresolvable_detail_action(self, request, *args, **kwargs):
6363
raise NotImplementedError
6464

6565

66+
class ActionNamesViewSet(GenericViewSet):
67+
68+
def retrieve(self, request, *args, **kwargs):
69+
return Response()
70+
71+
@action(detail=True)
72+
def unnamed_action(self, request, *args, **kwargs):
73+
raise NotImplementedError
74+
75+
@action(detail=True, name='Custom Name')
76+
def named_action(self, request, *args, **kwargs):
77+
raise NotImplementedError
78+
79+
@action(detail=True, suffix='Custom Suffix')
80+
def suffixed_action(self, request, *args, **kwargs):
81+
raise NotImplementedError
82+
83+
6684
router = SimpleRouter()
6785
router.register(r'actions', ActionViewSet)
6886
router.register(r'actions-alt', ActionViewSet, basename='actions-alt')
87+
router.register(r'names', ActionNamesViewSet, basename='names')
6988

7089

7190
urlpatterns = [
@@ -172,6 +191,19 @@ def test_detail_view(self):
172191
def test_uninitialized_view(self):
173192
self.assertEqual(ActionViewSet().get_extra_action_url_map(), OrderedDict())
174193

194+
def test_action_names(self):
195+
# Action 'name' and 'suffix' kwargs should be respected
196+
response = self.client.get('/api/names/1/')
197+
view = response.renderer_context['view']
198+
199+
expected = OrderedDict([
200+
('Custom Name', 'http://testserver/api/names/1/named_action/'),
201+
('Action Names Custom Suffix', 'http://testserver/api/names/1/suffixed_action/'),
202+
('Unnamed action', 'http://testserver/api/names/1/unnamed_action/'),
203+
])
204+
205+
self.assertEqual(view.get_extra_action_url_map(), expected)
206+
175207

176208
@override_settings(ROOT_URLCONF='tests.test_viewsets')
177209
class ReverseActionTests(TestCase):

0 commit comments

Comments
 (0)