Skip to content

Commit 0148a9f

Browse files
Ryan P Kilbycarltongibson
authored andcommitted
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
1 parent 56967db commit 0148a9f

File tree

17 files changed

+465
-72
lines changed

17 files changed

+465
-72
lines changed

docs/api-guide/settings.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -398,10 +398,15 @@ A string representing the function that should be used when generating view name
398398

399399
This should be a function with the following signature:
400400

401-
view_name(cls, suffix=None)
401+
view_name(self)
402402

403-
* `cls`: The view class. Typically the name function would inspect the name of the class when generating a descriptive name, by accessing `cls.__name__`.
404-
* `suffix`: The optional suffix used when differentiating individual views in a viewset.
403+
* `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__`.
404+
405+
If the view instance inherits `ViewSet`, it may have been initialized with several optional arguments:
406+
407+
* `name`: A name expliticly provided to a view in the viewset. Typically, this value should be used as-is when provided.
408+
* `suffix`: Text used when differentiating individual views in a viewset. This argument is mutually exclusive to `name`.
409+
* `detail`: Boolean that differentiates an individual view in a viewset as either being a 'list' or 'detail' view.
405410

406411
Default: `'rest_framework.views.get_view_name'`
407412

@@ -413,11 +418,15 @@ This setting can be changed to support markup styles other than the default mark
413418

414419
This should be a function with the following signature:
415420

416-
view_description(cls, html=False)
421+
view_description(self, html=False)
417422

418-
* `cls`: The view class. Typically the description function would inspect the docstring of the class when generating a description, by accessing `cls.__doc__`
423+
* `self`: The view instance. Typically the description function would inspect the docstring of the class when generating a description, by accessing `self.__class__.__doc__`
419424
* `html`: A boolean indicating if HTML output is required. `True` when used in the browsable API, and `False` when used in generating `OPTIONS` responses.
420425

426+
If the view instance inherits `ViewSet`, it may have been initialized with several optional arguments:
427+
428+
* `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.
429+
421430
Default: `'rest_framework.views.get_view_description'`
422431

423432
## HTML Select Field cutoffs

docs/api-guide/viewsets.md

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ During dispatch, the following attributes are available on the `ViewSet`.
110110
* `action` - the name of the current action (e.g., `list`, `create`).
111111
* `detail` - boolean indicating if the current action is configured for a list or detail view.
112112
* `suffix` - the display suffix for the viewset type - mirrors the `detail` attribute.
113+
* `name` - the display name for the viewset. This argument is mutually exclusive to `suffix`.
114+
* `description` - the display description for the individual view of a viewset.
113115

114116
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:
115117

@@ -142,7 +144,7 @@ A more complete example of extra actions:
142144
queryset = User.objects.all()
143145
serializer_class = UserSerializer
144146

145-
@action(methods=['post'], detail=True)
147+
@action(detail=True, methods=['post'])
146148
def set_password(self, request, pk=None):
147149
user = self.get_object()
148150
serializer = PasswordSerializer(data=request.data)
@@ -168,20 +170,36 @@ A more complete example of extra actions:
168170

169171
The decorator can additionally take extra arguments that will be set for the routed view only. For example:
170172

171-
@action(methods=['post'], detail=True, permission_classes=[IsAdminOrIsSelf])
173+
@action(detail=True, methods=['post'], permission_classes=[IsAdminOrIsSelf])
172174
def set_password(self, request, pk=None):
173175
...
174176

175177
These decorator will route `GET` requests by default, but may also accept other HTTP methods by setting the `methods` argument. For example:
176178

177-
@action(methods=['post', 'delete'], detail=True)
179+
@action(detail=True, methods=['post', 'delete'])
178180
def unset_password(self, request, pk=None):
179181
...
180182

181183
The two new actions will then be available at the urls `^users/{pk}/set_password/$` and `^users/{pk}/unset_password/$`
182184

183185
To view all extra actions, call the `.get_extra_actions()` method.
184186

187+
### Routing additional HTTP methods for extra actions
188+
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.
190+
191+
```python
192+
@action(detail=True, methods=['put'], name='Change Password')
193+
def password(self, request, pk=None):
194+
"""Update the user's password."""
195+
...
196+
197+
@password.mapping.delete
198+
def delete_password(self, request, pk=None):
199+
"""Delete the user's password."""
200+
...
201+
```
202+
185203
## Reversing action URLs
186204

187205
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.

rest_framework/decorators.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import types
1212
import warnings
1313

14+
from django.forms.utils import pretty_name
1415
from django.utils import six
1516

1617
from rest_framework.views import APIView
@@ -130,7 +131,7 @@ def decorator(func):
130131
return decorator
131132

132133

133-
def action(methods=None, detail=None, url_path=None, url_name=None, **kwargs):
134+
def action(methods=None, detail=None, name=None, url_path=None, url_name=None, **kwargs):
134135
"""
135136
Mark a ViewSet method as a routable action.
136137
@@ -145,15 +146,81 @@ def action(methods=None, detail=None, url_path=None, url_name=None, **kwargs):
145146
)
146147

147148
def decorator(func):
148-
func.bind_to_methods = methods
149+
func.mapping = MethodMapper(func, methods)
150+
149151
func.detail = detail
152+
func.name = name if name else pretty_name(func.__name__)
150153
func.url_path = url_path if url_path else func.__name__
151154
func.url_name = url_name if url_name else func.__name__.replace('_', '-')
152155
func.kwargs = kwargs
156+
func.kwargs.update({
157+
'name': func.name,
158+
'description': func.__doc__ or None
159+
})
160+
153161
return func
154162
return decorator
155163

156164

165+
class MethodMapper(dict):
166+
"""
167+
Enables mapping HTTP methods to different ViewSet methods for a single,
168+
logical action.
169+
170+
Example usage:
171+
172+
class MyViewSet(ViewSet):
173+
174+
@action(detail=False)
175+
def example(self, request, **kwargs):
176+
...
177+
178+
@example.mapping.post
179+
def create_example(self, request, **kwargs):
180+
...
181+
"""
182+
183+
def __init__(self, action, methods):
184+
self.action = action
185+
for method in methods:
186+
self[method] = self.action.__name__
187+
188+
def _map(self, method, func):
189+
assert method not in self, (
190+
"Method '%s' has already been mapped to '.%s'." % (method, self[method]))
191+
assert func.__name__ != self.action.__name__, (
192+
"Method mapping does not behave like the property decorator. You "
193+
"cannot use the same method name for each mapping declaration.")
194+
195+
self[method] = func.__name__
196+
197+
return func
198+
199+
def get(self, func):
200+
return self._map('get', func)
201+
202+
def post(self, func):
203+
return self._map('post', func)
204+
205+
def put(self, func):
206+
return self._map('put', func)
207+
208+
def patch(self, func):
209+
return self._map('patch', func)
210+
211+
def delete(self, func):
212+
return self._map('delete', func)
213+
214+
def head(self, func):
215+
return self._map('head', func)
216+
217+
def options(self, func):
218+
return self._map('options', func)
219+
220+
def trace(self, func):
221+
return self._map('trace', func)
222+
223+
157224
def detail_route(methods=None, **kwargs):
158225
"""
159226
Used to mark a method on a ViewSet that should be routed for detail requests.

rest_framework/renderers.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,11 @@ def get_description(self, view, status_code):
612612
def get_breadcrumbs(self, request):
613613
return get_breadcrumbs(request.path, request)
614614

615+
def get_extra_actions(self, view):
616+
if hasattr(view, 'get_extra_action_url_map'):
617+
return view.get_extra_action_url_map()
618+
return None
619+
615620
def get_filter_form(self, data, view, request):
616621
if not hasattr(view, 'get_queryset') or not hasattr(view, 'filter_backends'):
617622
return
@@ -698,6 +703,8 @@ def get_context(self, data, accepted_media_type, renderer_context):
698703
'delete_form': self.get_rendered_html_form(data, view, 'DELETE', request),
699704
'options_form': self.get_rendered_html_form(data, view, 'OPTIONS', request),
700705

706+
'extra_actions': self.get_extra_actions(view),
707+
701708
'filter_form': self.get_filter_form(data, view, request),
702709

703710
'raw_data_put_form': raw_data_put_form,

rest_framework/routers.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,8 +208,7 @@ def _get_dynamic_route(self, route, action):
208208

209209
return Route(
210210
url=route.url.replace('{url_path}', url_path),
211-
mapping={http_method: action.__name__
212-
for http_method in action.bind_to_methods},
211+
mapping=action.mapping,
213212
name=route.name.replace('{url_name}', action.url_name),
214213
detail=route.detail,
215214
initkwargs=initkwargs,

rest_framework/templates/rest_framework/admin.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,20 @@
110110
</form>
111111
{% endif %}
112112

113+
{% if extra_actions %}
114+
<div class="dropdown" style="float: right; margin-right: 10px">
115+
<button class="btn btn-default" id="extra-actions-menu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
116+
{% trans "Extra Actions" %}
117+
<span class="caret"></span>
118+
</button>
119+
<ul class="dropdown-menu" aria-labelledby="extra-actions-menu">
120+
{% for action_name, url in extra_actions|items %}
121+
<li><a href="{{ url }}">{{ action_name }}</a></li>
122+
{% endfor %}
123+
</ul>
124+
</div>
125+
{% endif %}
126+
113127
{% if filter_form %}
114128
<button style="float: right; margin-right: 10px" data-toggle="modal" data-target="#filtersModal" class="btn btn-default">
115129
<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span>

rest_framework/templates/rest_framework/base.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,20 @@ <h4 class="text-center">Are you sure you want to delete this {{ name }}?</h4>
128128
</div>
129129
{% endif %}
130130

131+
{% if extra_actions %}
132+
<div class="dropdown" style="float: right; margin-right: 10px">
133+
<button class="btn btn-default" id="extra-actions-menu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
134+
{% trans "Extra Actions" %}
135+
<span class="caret"></span>
136+
</button>
137+
<ul class="dropdown-menu" aria-labelledby="extra-actions-menu">
138+
{% for action_name, url in extra_actions|items %}
139+
<li><a href="{{ url }}">{{ action_name }}</a></li>
140+
{% endfor %}
141+
</ul>
142+
</div>
143+
{% endif %}
144+
131145
{% if filter_form %}
132146
<button style="float: right; margin-right: 10px" data-toggle="modal" data-target="#filtersModal" class="btn btn-default">
133147
<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span>

rest_framework/utils/breadcrumbs.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ def breadcrumbs_recursive(url, breadcrumbs_list, prefix, seen):
3030
# Probably an optional trailing slash.
3131
if not seen or seen[-1] != view:
3232
c = cls(**initkwargs)
33-
c.suffix = getattr(view, 'suffix', None)
3433
name = c.get_view_name()
3534
insert_url = preserve_builtin_query_params(prefix + url, request)
3635
breadcrumbs_list.insert(0, (name, insert_url))

rest_framework/views.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,31 +21,43 @@
2121
from rest_framework.utils import formatting
2222

2323

24-
def get_view_name(view_cls, suffix=None):
24+
def get_view_name(view):
2525
"""
2626
Given a view class, 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.
3030
"""
31-
name = view_cls.__name__
31+
# Name may be set by some Views, such as a ViewSet.
32+
name = getattr(view, 'name', None)
33+
if name is not None:
34+
return name
35+
36+
name = view.__class__.__name__
3237
name = formatting.remove_trailing_string(name, 'View')
3338
name = formatting.remove_trailing_string(name, 'ViewSet')
3439
name = formatting.camelcase_to_spaces(name)
40+
41+
# Suffix may be set by some Views, such as a ViewSet.
42+
suffix = getattr(view, 'suffix', None)
3543
if suffix:
3644
name += ' ' + suffix
3745

3846
return name
3947

4048

41-
def get_view_description(view_cls, html=False):
49+
def get_view_description(view, html=False):
4250
"""
4351
Given a view class, return a textual description to represent the view.
4452
This name is used in the browsable API, and in OPTIONS responses.
4553
4654
This function is the default for the `VIEW_DESCRIPTION_FUNCTION` setting.
4755
"""
48-
description = view_cls.__doc__ or ''
56+
# Description may be set by some Views, such as a ViewSet.
57+
description = getattr(view, 'description', None)
58+
if description is None:
59+
description = view.__class__.__doc__ or ''
60+
4961
description = formatting.dedent(smart_text(description))
5062
if html:
5163
return formatting.markup_description(description)
@@ -224,15 +236,15 @@ def get_view_name(self):
224236
browsable API.
225237
"""
226238
func = self.settings.VIEW_NAME_FUNCTION
227-
return func(self.__class__, getattr(self, 'suffix', None))
239+
return func(self)
228240

229241
def get_view_description(self, html=False):
230242
"""
231243
Return some descriptive text for the view, as used in OPTIONS responses
232244
and in the browsable API.
233245
"""
234246
func = self.settings.VIEW_DESCRIPTION_FUNCTION
235-
return func(self.__class__, html)
247+
return func(self, html)
236248

237249
# API policy instantiation methods
238250

0 commit comments

Comments
 (0)