Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improvements to ViewSet extra actions #5605

Merged
merged 6 commits into from
Jul 6, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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