diff --git a/docs/api-guide/settings.md b/docs/api-guide/settings.md
index 304d35412..85e38185e 100644
--- a/docs/api-guide/settings.md
+++ b/docs/api-guide/settings.md
@@ -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'`
@@ -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
diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md
index 503459a96..9be62bf16 100644
--- a/docs/api-guide/viewsets.md
+++ b/docs/api-guide/viewsets.md
@@ -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:
@@ -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)
@@ -168,13 +170,13 @@ 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):
...
@@ -182,6 +184,22 @@ The two new actions will then be available at the urls `^users/{pk}/set_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.
diff --git a/rest_framework/decorators.py b/rest_framework/decorators.py
index c9b6f89c7..60078947f 100644
--- a/rest_framework/decorators.py
+++ b/rest_framework/decorators.py
@@ -11,6 +11,7 @@ from __future__ import unicode_literals
import types
import warnings
+from django.forms.utils import pretty_name
from django.utils import six
from rest_framework.views import APIView
@@ -130,7 +131,7 @@ def schema(view_inspector):
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.
@@ -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.
diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py
index 14a371852..ca4844321 100644
--- a/rest_framework/renderers.py
+++ b/rest_framework/renderers.py
@@ -612,6 +612,11 @@ class BrowsableAPIRenderer(BaseRenderer):
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
@@ -698,6 +703,8 @@ class BrowsableAPIRenderer(BaseRenderer):
'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,
diff --git a/rest_framework/routers.py b/rest_framework/routers.py
index 281bbde8a..52b2b7cc6 100644
--- a/rest_framework/routers.py
+++ b/rest_framework/routers.py
@@ -208,8 +208,7 @@ class SimpleRouter(BaseRouter):
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,
diff --git a/rest_framework/templates/rest_framework/admin.html b/rest_framework/templates/rest_framework/admin.html
index 4fb6480c5..faa37a586 100644
--- a/rest_framework/templates/rest_framework/admin.html
+++ b/rest_framework/templates/rest_framework/admin.html
@@ -110,6 +110,20 @@
{% endif %}
+ {% if extra_actions %}
+
+
+
+ {% for action_name, url in extra_actions|items %}
+