Skip to content

Commit

Permalink
Improvements to ViewSet extra actions (#5605)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Ryan P Kilby authored and carltongibson committed Jul 6, 2018
1 parent 56967db commit 0148a9f
Show file tree
Hide file tree
Showing 17 changed files with 465 additions and 72 deletions.
19 changes: 14 additions & 5 deletions docs/api-guide/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'`

Expand All @@ -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
Expand Down
24 changes: 21 additions & 3 deletions docs/api-guide/viewsets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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)
Expand All @@ -168,20 +170,36 @@ 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):
...

The two new actions will then be available at the urls `^users/{pk}/set_password/$` and `^users/{pk}/unset_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.
Expand Down
71 changes: 69 additions & 2 deletions rest_framework/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import types
import warnings

from django.forms.utils import pretty_name
from django.utils import six

from rest_framework.views import APIView
Expand Down Expand Up @@ -130,7 +131,7 @@ def decorator(func):
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.
Expand All @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions rest_framework/renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,11 @@ def get_description(self, view, status_code):
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
Expand Down Expand Up @@ -698,6 +703,8 @@ def get_context(self, data, accepted_media_type, renderer_context):
'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,
Expand Down
3 changes: 1 addition & 2 deletions rest_framework/routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,7 @@ def _get_dynamic_route(self, route, action):

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,
Expand Down
14 changes: 14 additions & 0 deletions rest_framework/templates/rest_framework/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
14 changes: 14 additions & 0 deletions rest_framework/templates/rest_framework/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,20 @@ <h4 class="text-center">Are you sure you want to delete this {{ name }}?</h4>
</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>
Expand Down
1 change: 0 additions & 1 deletion rest_framework/utils/breadcrumbs.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ def breadcrumbs_recursive(url, breadcrumbs_list, prefix, seen):
# 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))
Expand Down
24 changes: 18 additions & 6 deletions rest_framework/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,31 +21,43 @@
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)
Expand Down Expand Up @@ -224,15 +236,15 @@ def get_view_name(self):
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):
"""
Return some descriptive text for the view, as used in OPTIONS responses
and in the browsable API.
"""
func = self.settings.VIEW_DESCRIPTION_FUNCTION
return func(self.__class__, html)
return func(self, html)

# API policy instantiation methods

Expand Down

0 comments on commit 0148a9f

Please sign in to comment.