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 %} + + {% endif %} + {% if filter_form %} + + + {% endif %} + {% if filter_form %}