diff --git a/docs/api-guide/metadata.md b/docs/api-guide/metadata.md
index de28ffd8a9..1ee97d91ba 100644
--- a/docs/api-guide/metadata.md
+++ b/docs/api-guide/metadata.md
@@ -67,7 +67,7 @@ If you have specific requirements for creating schema endpoints that are accesse
For example, the following additional route could be used on a viewset to provide a linkable schema endpoint.
- @list_route(methods=['GET'])
+ @action(methods=['GET'], detail=False)
def schema(self, request):
meta = self.metadata_class()
data = meta.determine_metadata(request, self)
diff --git a/docs/api-guide/routers.md b/docs/api-guide/routers.md
index 84ca82a3f7..905fb5e2da 100644
--- a/docs/api-guide/routers.md
+++ b/docs/api-guide/routers.md
@@ -81,81 +81,62 @@ Router URL patterns can also be namespaces.
If using namespacing with hyperlinked serializers you'll also need to ensure that any `view_name` parameters on the serializers correctly reflect the namespace. In the example above you'd need to include a parameter such as `view_name='api:user-detail'` for serializer fields hyperlinked to the user detail view.
-### Extra link and actions
+### Routing for extra actions
-Any methods on the viewset decorated with `@detail_route` or `@list_route` will also be routed.
-For example, given a method like this on the `UserViewSet` class:
+A viewset may [mark extra actions for routing][route-decorators] by decorating a method with the `@action` decorator. These extra actions will be included in the generated routes. For example, given the `set_password` method on the `UserViewSet` class:
from myapp.permissions import IsAdminOrIsSelf
- from rest_framework.decorators import detail_route
+ from rest_framework.decorators import action
class UserViewSet(ModelViewSet):
...
- @detail_route(methods=['post'], permission_classes=[IsAdminOrIsSelf])
+ @action(methods=['post'], detail=True, permission_classes=[IsAdminOrIsSelf])
def set_password(self, request, pk=None):
...
-The following URL pattern would additionally be generated:
+The following route would be generated:
-* URL pattern: `^users/{pk}/set_password/$` Name: `'user-set-password'`
+* URL pattern: `^users/{pk}/set_password/$`
+* URL name: `'user-set-password'`
-If you do not want to use the default URL generated for your custom action, you can instead use the url_path parameter to customize it.
+By default, the URL pattern is based on the method name, and the URL name is the combination of the `ViewSet.basename` and the hyphenated method name.
+If you don't want to use the defaults for either of these values, you can instead provide the `url_path` and `url_name` arguments to the `@action` decorator.
For example, if you want to change the URL for our custom action to `^users/{pk}/change-password/$`, you could write:
from myapp.permissions import IsAdminOrIsSelf
- from rest_framework.decorators import detail_route
+ from rest_framework.decorators import action
class UserViewSet(ModelViewSet):
...
- @detail_route(methods=['post'], permission_classes=[IsAdminOrIsSelf], url_path='change-password')
+ @action(methods=['post'], detail=True, permission_classes=[IsAdminOrIsSelf],
+ url_path='change-password', url_name='change_password')
def set_password(self, request, pk=None):
...
The above example would now generate the following URL pattern:
-* URL pattern: `^users/{pk}/change-password/$` Name: `'user-change-password'`
-
-In the case you do not want to use the default name generated for your custom action, you can use the url_name parameter to customize it.
-
-For example, if you want to change the name of our custom action to `'user-change-password'`, you could write:
-
- from myapp.permissions import IsAdminOrIsSelf
- from rest_framework.decorators import detail_route
-
- class UserViewSet(ModelViewSet):
- ...
-
- @detail_route(methods=['post'], permission_classes=[IsAdminOrIsSelf], url_name='change-password')
- def set_password(self, request, pk=None):
- ...
-
-The above example would now generate the following URL pattern:
-
-* URL pattern: `^users/{pk}/set_password/$` Name: `'user-change-password'`
-
-You can also use url_path and url_name parameters together to obtain extra control on URL generation for custom views.
-
-For more information see the viewset documentation on [marking extra actions for routing][route-decorators].
+* URL path: `^users/{pk}/change-password/$`
+* URL name: `'user-change_password'`
# API Guide
## SimpleRouter
-This router includes routes for the standard set of `list`, `create`, `retrieve`, `update`, `partial_update` and `destroy` actions. The viewset can also mark additional methods to be routed, using the `@detail_route` or `@list_route` decorators.
+This router includes routes for the standard set of `list`, `create`, `retrieve`, `update`, `partial_update` and `destroy` actions. The viewset can also mark additional methods to be routed, using the `@action` decorator.
URL Style | HTTP Method | Action | URL Name |
{prefix}/ | GET | list | {basename}-list |
POST | create |
- {prefix}/{methodname}/ | GET, or as specified by `methods` argument | `@list_route` decorated method | {basename}-{methodname} |
+ {prefix}/{url_path}/ | GET, or as specified by `methods` argument | `@action(detail=False)` decorated method | {basename}-{url_name} |
{prefix}/{lookup}/ | GET | retrieve | {basename}-detail |
PUT | update |
PATCH | partial_update |
DELETE | destroy |
- {prefix}/{lookup}/{methodname}/ | GET, or as specified by `methods` argument | `@detail_route` decorated method | {basename}-{methodname} |
+ {prefix}/{lookup}/{url_path}/ | GET, or as specified by `methods` argument | `@action(detail=True)` decorated method | {basename}-{url_name} |
By default the URLs created by `SimpleRouter` are appended with a trailing slash.
@@ -180,12 +161,12 @@ This router is similar to `SimpleRouter` as above, but additionally includes a d
[.format] | GET | automatically generated root view | api-root |
{prefix}/[.format] | GET | list | {basename}-list |
POST | create |
- {prefix}/{methodname}/[.format] | GET, or as specified by `methods` argument | `@list_route` decorated method | {basename}-{methodname} |
+ {prefix}/{url_path}/[.format] | GET, or as specified by `methods` argument | `@action(detail=False)` decorated method | {basename}-{url_name} |
{prefix}/{lookup}/[.format] | GET | retrieve | {basename}-detail |
PUT | update |
PATCH | partial_update |
DELETE | destroy |
- {prefix}/{lookup}/{methodname}/[.format] | GET, or as specified by `methods` argument | `@detail_route` decorated method | {basename}-{methodname} |
+ {prefix}/{lookup}/{url_path}/[.format] | GET, or as specified by `methods` argument | `@action(detail=True)` decorated method | {basename}-{url_name} |
As with `SimpleRouter` the trailing slashes on the URL routes can be removed by setting the `trailing_slash` argument to `False` when instantiating the router.
@@ -212,18 +193,18 @@ The arguments to the `Route` named tuple are:
* `{basename}` - The base to use for the URL names that are created.
-**initkwargs**: A dictionary of any additional arguments that should be passed when instantiating the view. Note that the `suffix` argument is reserved for identifying the viewset type, used when generating the view name and breadcrumb links.
+**initkwargs**: A dictionary of any additional arguments that should be passed when instantiating the view. Note that the `detail`, `basename`, and `suffix` arguments are reserved for viewset introspection and are also used by the browsable API to generate the view name and breadcrumb links.
## Customizing dynamic routes
-You can also customize how the `@list_route` and `@detail_route` decorators are routed.
-To route either or both of these decorators, include a `DynamicListRoute` and/or `DynamicDetailRoute` named tuple in the `.routes` list.
+You can also customize how the `@action` decorator is routed. Include the `DynamicRoute` named tuple in the `.routes` list, setting the `detail` argument as appropriate for the list-based and detail-based routes. In addition to `detail`, the arguments to `DynamicRoute` are:
-The arguments to `DynamicListRoute` and `DynamicDetailRoute` are:
+**url**: A string representing the URL to be routed. May include the same format strings as `Route`, and additionally accepts the `{url_path}` format string.
-**url**: A string representing the URL to be routed. May include the same format strings as `Route`, and additionally accepts the `{methodname}` and `{methodnamehyphen}` format strings.
+**name**: The name of the URL as used in `reverse` calls. May include the following format strings:
-**name**: The name of the URL as used in `reverse` calls. May include the following format strings: `{basename}`, `{methodname}` and `{methodnamehyphen}`.
+* `{basename}` - The base to use for the URL names that are created.
+* `{url_name}` - The `url_name` provided to the `@action`.
**initkwargs**: A dictionary of any additional arguments that should be passed when instantiating the view.
@@ -231,7 +212,7 @@ The arguments to `DynamicListRoute` and `DynamicDetailRoute` are:
The following example will only route to the `list` and `retrieve` actions, and does not use the trailing slash convention.
- from rest_framework.routers import Route, DynamicDetailRoute, SimpleRouter
+ from rest_framework.routers import Route, DynamicRoute, SimpleRouter
class CustomReadOnlyRouter(SimpleRouter):
"""
@@ -239,22 +220,23 @@ The following example will only route to the `list` and `retrieve` actions, and
"""
routes = [
Route(
- url=r'^{prefix}$',
- mapping={'get': 'list'},
- name='{basename}-list',
- initkwargs={'suffix': 'List'}
+ url=r'^{prefix}$',
+ mapping={'get': 'list'},
+ name='{basename}-list',
+ initkwargs={'suffix': 'List'}
),
Route(
- url=r'^{prefix}/{lookup}$',
+ url=r'^{prefix}/{lookup}$',
mapping={'get': 'retrieve'},
name='{basename}-detail',
initkwargs={'suffix': 'Detail'}
),
- DynamicDetailRoute(
- url=r'^{prefix}/{lookup}/{methodnamehyphen}$',
- name='{basename}-{methodnamehyphen}',
- initkwargs={}
- )
+ DynamicRoute(
+ url=r'^{prefix}/{lookup}/{url_path}$',
+ name='{basename}-{url_name}',
+ detail=True,
+ initkwargs={}
+ )
]
Let's take a look at the routes our `CustomReadOnlyRouter` would generate for a simple viewset.
@@ -269,7 +251,7 @@ Let's take a look at the routes our `CustomReadOnlyRouter` would generate for a
serializer_class = UserSerializer
lookup_field = 'username'
- @detail_route()
+ @action(detail=True)
def group_names(self, request, pk=None):
"""
Returns a list of all the group names that the given
@@ -283,7 +265,7 @@ Let's take a look at the routes our `CustomReadOnlyRouter` would generate for a
router = CustomReadOnlyRouter()
router.register('users', UserViewSet)
- urlpatterns = router.urls
+ urlpatterns = router.urls
The following mappings would be generated...
diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md
index 27fb1d7805..503459a963 100644
--- a/docs/api-guide/viewsets.md
+++ b/docs/api-guide/viewsets.md
@@ -102,10 +102,16 @@ The default routers included with REST framework will provide routes for a stand
def destroy(self, request, pk=None):
pass
-During dispatch the name of the current action is available via the `.action` attribute.
-You may inspect `.action` to adjust behaviour based on the current action.
+## Introspecting ViewSet actions
-For example, you could restrict permissions to everything except the `list` action similar to this:
+During dispatch, the following attributes are available on the `ViewSet`.
+
+* `basename` - the base to use for the URL names that are created.
+* `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.
+
+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:
def get_permissions(self):
"""
@@ -119,16 +125,13 @@ For example, you could restrict permissions to everything except the `list` acti
## Marking extra actions for routing
-If you have ad-hoc methods that you need to be routed to, you can mark them as requiring routing using the `@detail_route` or `@list_route` decorators.
-
-The `@detail_route` decorator contains `pk` in its URL pattern and is intended for methods which require a single instance. The `@list_route` decorator is intended for methods which operate on a list of objects.
+If you have ad-hoc methods that should be routable, you can mark them as such with the `@action` decorator. Like regular actions, extra actions may be intended for either a list of objects, or a single instance. To indicate this, set the `detail` argument to `True` or `False`. The router will configure its URL patterns accordingly. e.g., the `DefaultRouter` will configure detail actions to contain `pk` in their URL patterns.
-For example:
+A more complete example of extra actions:
from django.contrib.auth.models import User
- from rest_framework import status
- from rest_framework import viewsets
- from rest_framework.decorators import detail_route, list_route
+ from rest_framework import status, viewsets
+ from rest_framework.decorators import action
from rest_framework.response import Response
from myapp.serializers import UserSerializer, PasswordSerializer
@@ -139,7 +142,7 @@ For example:
queryset = User.objects.all()
serializer_class = UserSerializer
- @detail_route(methods=['post'])
+ @action(methods=['post'], detail=True)
def set_password(self, request, pk=None):
user = self.get_object()
serializer = PasswordSerializer(data=request.data)
@@ -151,7 +154,7 @@ For example:
return Response(serializer.errors,
status=status.HTTP_400_BAD_REQUEST)
- @list_route()
+ @action(detail=False)
def recent_users(self, request):
recent_users = User.objects.all().order('-last_login')
@@ -163,20 +166,22 @@ For example:
serializer = self.get_serializer(recent_users, many=True)
return Response(serializer.data)
-The decorators can additionally take extra arguments that will be set for the routed view only. For example...
+The decorator can additionally take extra arguments that will be set for the routed view only. For example:
- @detail_route(methods=['post'], permission_classes=[IsAdminOrIsSelf])
+ @action(methods=['post'], detail=True, permission_classes=[IsAdminOrIsSelf])
def set_password(self, request, pk=None):
...
-These decorators will route `GET` requests by default, but may also accept other HTTP methods, by using the `methods` argument. For example:
+These decorator will route `GET` requests by default, but may also accept other HTTP methods by setting the `methods` argument. For example:
- @detail_route(methods=['post', 'delete'])
+ @action(methods=['post', 'delete'], detail=True)
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.
+
## 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.
@@ -190,7 +195,14 @@ Using the example from the previous section:
'http://localhost:8000/api/users/1/set_password'
```
-The `url_name` argument should match the same argument to the `@list_route` and `@detail_route` decorators. Additionally, this can be used to reverse the default `list` and `detail` routes.
+Alternatively, you can use the `url_name` attribute set by the `@action` decorator.
+
+```python
+>>> view.reverse_action(view.set_password.url_name, args=['1'])
+'http://localhost:8000/api/users/1/set_password'
+```
+
+The `url_name` argument for `.reverse_action()` should match the same argument to the `@action` decorator. Additionally, this method can be used to reverse the default actions, such as `list` and `create`.
---
diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md
index 244adef0b3..dc37611d02 100644
--- a/docs/topics/release-notes.md
+++ b/docs/topics/release-notes.md
@@ -38,6 +38,31 @@ You can determine your currently installed version using `pip freeze`:
---
+## 3.8.x series
+
+### 3.8.0
+
+**Date**: [unreleased][3.8.0-milestone]
+
+* Refactor dynamic route generation and improve viewset action introspectibility. [#5705][gh5705]
+
+ `ViewSet`s have been provided with new attributes and methods that allow
+ it to introspect its set of actions and the details of the current action.
+
+ * Merged `list_route` and `detail_route` into a single `action` decorator.
+ * Get all extra actions on a `ViewSet` with `.get_extra_actions()`.
+ * Extra actions now set the `url_name` and `url_path` on the decorated method.
+ * Enable action url reversing through `.reverse_action()` method (added in 3.7.4)
+ * Example reverse call: `self.reverse_action(self.custom_action.url_name)`
+ * Add `detail` initkwarg to indicate if the current action is operating on a
+ collection or a single instance.
+
+ Additional changes:
+
+ * Deprecated `list_route` & `detail_route` in favor of `action` decorator with `detail` boolean.
+ * Deprecated dynamic list/detail route variants in favor of `DynamicRoute` with `detail` boolean.
+ * Refactored the router's dynamic route generation.
+
## 3.7.x series
### 3.7.7
@@ -947,6 +972,7 @@ For older release notes, [please see the version 2.x documentation][old-release-
[3.7.5-milestone]: https://github.com/encode/django-rest-framework/milestone/63?closed=1
[3.7.6-milestone]: https://github.com/encode/django-rest-framework/milestone/64?closed=1
[3.7.7-milestone]: https://github.com/encode/django-rest-framework/milestone/65?closed=1
+[3.8.0-milestone]: https://github.com/encode/django-rest-framework/milestone/61?closed=1
[gh2013]: https://github.com/encode/django-rest-framework/issues/2013
@@ -1760,3 +1786,6 @@ For older release notes, [please see the version 2.x documentation][old-release-
[gh5695]: https://github.com/encode/django-rest-framework/issues/5695
[gh5696]: https://github.com/encode/django-rest-framework/issues/5696
[gh5697]: https://github.com/encode/django-rest-framework/issues/5697
+
+
+[gh5705]: https://github.com/encode/django-rest-framework/issues/5705
diff --git a/docs/tutorial/6-viewsets-and-routers.md b/docs/tutorial/6-viewsets-and-routers.md
index 7d87c02129..9452b49472 100644
--- a/docs/tutorial/6-viewsets-and-routers.md
+++ b/docs/tutorial/6-viewsets-and-routers.md
@@ -25,7 +25,7 @@ Here we've used the `ReadOnlyModelViewSet` class to automatically provide the de
Next we're going to replace the `SnippetList`, `SnippetDetail` and `SnippetHighlight` view classes. We can remove the three views, and again replace them with a single class.
- from rest_framework.decorators import detail_route
+ from rest_framework.decorators import action
from rest_framework.response import Response
class SnippetViewSet(viewsets.ModelViewSet):
@@ -40,7 +40,7 @@ Next we're going to replace the `SnippetList`, `SnippetDetail` and `SnippetHighl
permission_classes = (permissions.IsAuthenticatedOrReadOnly,
IsOwnerOrReadOnly,)
- @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+ @action(detail=True, renderer_classes=[renderers.StaticHTMLRenderer])
def highlight(self, request, *args, **kwargs):
snippet = self.get_object()
return Response(snippet.highlighted)
@@ -50,11 +50,11 @@ Next we're going to replace the `SnippetList`, `SnippetDetail` and `SnippetHighl
This time we've used the `ModelViewSet` class in order to get the complete set of default read and write operations.
-Notice that we've also used the `@detail_route` decorator to create a custom action, named `highlight`. This decorator can be used to add any custom endpoints that don't fit into the standard `create`/`update`/`delete` style.
+Notice that we've also used the `@action` decorator to create a custom action, named `highlight`. This decorator can be used to add any custom endpoints that don't fit into the standard `create`/`update`/`delete` style.
-Custom actions which use the `@detail_route` decorator will respond to `GET` requests by default. We can use the `methods` argument if we wanted an action that responded to `POST` requests.
+Custom actions which use the `@action` decorator will respond to `GET` requests by default. We can use the `methods` argument if we wanted an action that responded to `POST` requests.
-The URLs for custom actions by default depend on the method name itself. If you want to change the way url should be constructed, you can include url_path as a decorator keyword argument.
+The URLs for custom actions by default depend on the method name itself. If you want to change the way url should be constructed, you can include `url_path` as a decorator keyword argument.
## Binding ViewSets to URLs explicitly
diff --git a/rest_framework/decorators.py b/rest_framework/decorators.py
index 2f93fdd976..62afa05979 100644
--- a/rest_framework/decorators.py
+++ b/rest_framework/decorators.py
@@ -130,29 +130,49 @@ def decorator(func):
return decorator
-def detail_route(methods=None, **kwargs):
+def action(methods=None, detail=None, url_path=None, url_name=None, **kwargs):
"""
- Used to mark a method on a ViewSet that should be routed for detail requests.
+ Mark a ViewSet method as a routable action.
+
+ Set the `detail` boolean to determine if this action should apply to
+ instance/detail requests or collection/list requests.
"""
methods = ['get'] if (methods is None) else methods
+ methods = [method.lower() for method in methods]
+
+ assert detail is not None, (
+ "@action() missing required argument: 'detail'"
+ )
def decorator(func):
func.bind_to_methods = methods
- func.detail = True
+ func.detail = detail
+ func.url_path = url_path or func.__name__
+ func.url_name = url_name or func.__name__.replace('_', '-')
func.kwargs = kwargs
return func
return decorator
+def detail_route(methods=None, **kwargs):
+ """
+ Used to mark a method on a ViewSet that should be routed for detail requests.
+ """
+ warnings.warn(
+ "`detail_route` is pending deprecation and will be removed in 3.10 in favor of "
+ "`action`, which accepts a `detail` bool. Use `@action(detail=True)` instead.",
+ PendingDeprecationWarning, stacklevel=2
+ )
+ return action(methods, detail=True, **kwargs)
+
+
def list_route(methods=None, **kwargs):
"""
Used to mark a method on a ViewSet that should be routed for list requests.
"""
- methods = ['get'] if (methods is None) else methods
-
- def decorator(func):
- func.bind_to_methods = methods
- func.detail = False
- func.kwargs = kwargs
- return func
- return decorator
+ warnings.warn(
+ "`list_route` is pending deprecation and will be removed in 3.10 in favor of "
+ "`action`, which accepts a `detail` bool. Use `@action(detail=False)` instead.",
+ PendingDeprecationWarning, stacklevel=2
+ )
+ return action(methods, detail=False, **kwargs)
diff --git a/rest_framework/routers.py b/rest_framework/routers.py
index f4d2fab383..9007788f85 100644
--- a/rest_framework/routers.py
+++ b/rest_framework/routers.py
@@ -16,6 +16,7 @@
from __future__ import unicode_literals
import itertools
+import warnings
from collections import OrderedDict, namedtuple
from django.conf.urls import url
@@ -30,9 +31,30 @@
from rest_framework.settings import api_settings
from rest_framework.urlpatterns import format_suffix_patterns
-Route = namedtuple('Route', ['url', 'mapping', 'name', 'initkwargs'])
-DynamicDetailRoute = namedtuple('DynamicDetailRoute', ['url', 'name', 'initkwargs'])
-DynamicListRoute = namedtuple('DynamicListRoute', ['url', 'name', 'initkwargs'])
+Route = namedtuple('Route', ['url', 'mapping', 'name', 'detail', 'initkwargs'])
+DynamicRoute = namedtuple('DynamicRoute', ['url', 'name', 'detail', 'initkwargs'])
+
+
+class DynamicDetailRoute(object):
+ def __new__(cls, url, name, initkwargs):
+ warnings.warn(
+ "`DynamicDetailRoute` is pending deprecation and will be removed in 3.10 "
+ "in favor of `DynamicRoute`, which accepts a `detail` boolean. Use "
+ "`DynamicRoute(url, name, True, initkwargs)` instead.",
+ PendingDeprecationWarning, stacklevel=2
+ )
+ return DynamicRoute(url, name, True, initkwargs)
+
+
+class DynamicListRoute(object):
+ def __new__(cls, url, name, initkwargs):
+ warnings.warn(
+ "`DynamicListRoute` is pending deprecation and will be removed in 3.10 in "
+ "favor of `DynamicRoute`, which accepts a `detail` boolean. Use "
+ "`DynamicRoute(url, name, False, initkwargs)` instead.",
+ PendingDeprecationWarning, stacklevel=2
+ )
+ return DynamicRoute(url, name, False, initkwargs)
def escape_curly_brackets(url_path):
@@ -44,18 +66,6 @@ def escape_curly_brackets(url_path):
return url_path
-def replace_methodname(format_string, methodname):
- """
- Partially format a format_string, swapping out any
- '{methodname}' or '{methodnamehyphen}' components.
- """
- methodnamehyphen = methodname.replace('_', '-')
- ret = format_string
- ret = ret.replace('{methodname}', methodname)
- ret = ret.replace('{methodnamehyphen}', methodnamehyphen)
- return ret
-
-
def flatten(list_of_lists):
"""
Takes an iterable of iterables, returns a single iterable containing all items
@@ -103,14 +113,15 @@ class SimpleRouter(BaseRouter):
'post': 'create'
},
name='{basename}-list',
+ detail=False,
initkwargs={'suffix': 'List'}
),
- # Dynamically generated list routes.
- # Generated using @list_route decorator
- # on methods of the viewset.
- DynamicListRoute(
- url=r'^{prefix}/{methodname}{trailing_slash}$',
- name='{basename}-{methodnamehyphen}',
+ # Dynamically generated list routes. Generated using
+ # @action(detail=False) decorator on methods of the viewset.
+ DynamicRoute(
+ url=r'^{prefix}/{url_path}{trailing_slash}$',
+ name='{basename}-{url_name}',
+ detail=False,
initkwargs={}
),
# Detail route.
@@ -123,13 +134,15 @@ class SimpleRouter(BaseRouter):
'delete': 'destroy'
},
name='{basename}-detail',
+ detail=True,
initkwargs={'suffix': 'Instance'}
),
- # Dynamically generated detail routes.
- # Generated using @detail_route decorator on methods of the viewset.
- DynamicDetailRoute(
- url=r'^{prefix}/{lookup}/{methodname}{trailing_slash}$',
- name='{basename}-{methodnamehyphen}',
+ # Dynamically generated detail routes. Generated using
+ # @action(detail=True) decorator on methods of the viewset.
+ DynamicRoute(
+ url=r'^{prefix}/{lookup}/{url_path}{trailing_slash}$',
+ name='{basename}-{url_name}',
+ detail=True,
initkwargs={}
),
]
@@ -160,57 +173,47 @@ def get_routes(self, viewset):
# converting to list as iterables are good for one pass, known host needs to be checked again and again for
# different functions.
known_actions = list(flatten([route.mapping.values() for route in self.routes if isinstance(route, Route)]))
-
- # Determine any `@detail_route` or `@list_route` decorated methods on the viewset
- detail_routes = []
- list_routes = []
- for methodname in dir(viewset):
- attr = getattr(viewset, methodname)
- httpmethods = getattr(attr, 'bind_to_methods', None)
- detail = getattr(attr, 'detail', True)
- if httpmethods:
- # checking method names against the known actions list
- if methodname in known_actions:
- raise ImproperlyConfigured('Cannot use @detail_route or @list_route '
- 'decorators on method "%s" '
- 'as it is an existing route' % methodname)
- httpmethods = [method.lower() for method in httpmethods]
- if detail:
- detail_routes.append((httpmethods, methodname))
- else:
- list_routes.append((httpmethods, methodname))
-
- def _get_dynamic_routes(route, dynamic_routes):
- ret = []
- for httpmethods, methodname in dynamic_routes:
- method_kwargs = getattr(viewset, methodname).kwargs
- initkwargs = route.initkwargs.copy()
- initkwargs.update(method_kwargs)
- url_path = initkwargs.pop("url_path", None) or methodname
- url_path = escape_curly_brackets(url_path)
- url_name = initkwargs.pop("url_name", None) or url_path
- ret.append(Route(
- url=replace_methodname(route.url, url_path),
- mapping={httpmethod: methodname for httpmethod in httpmethods},
- name=replace_methodname(route.name, url_name),
- initkwargs=initkwargs,
- ))
-
- return ret
-
- ret = []
+ extra_actions = viewset.get_extra_actions()
+
+ # checking action names against the known actions list
+ not_allowed = [
+ action.__name__ for action in extra_actions
+ if action.__name__ in known_actions
+ ]
+ if not_allowed:
+ msg = ('Cannot use the @action decorator on the following '
+ 'methods, as they are existing routes: %s')
+ raise ImproperlyConfigured(msg % ', '.join(not_allowed))
+
+ # partition detail and list actions
+ detail_actions = [action for action in extra_actions if action.detail]
+ list_actions = [action for action in extra_actions if not action.detail]
+
+ routes = []
for route in self.routes:
- if isinstance(route, DynamicDetailRoute):
- # Dynamic detail routes (@detail_route decorator)
- ret += _get_dynamic_routes(route, detail_routes)
- elif isinstance(route, DynamicListRoute):
- # Dynamic list routes (@list_route decorator)
- ret += _get_dynamic_routes(route, list_routes)
+ if isinstance(route, DynamicRoute) and route.detail:
+ routes += [self._get_dynamic_route(route, action) for action in detail_actions]
+ elif isinstance(route, DynamicRoute) and not route.detail:
+ routes += [self._get_dynamic_route(route, action) for action in list_actions]
else:
- # Standard route
- ret.append(route)
+ routes.append(route)
- return ret
+ return routes
+
+ def _get_dynamic_route(self, route, action):
+ initkwargs = route.initkwargs.copy()
+ initkwargs.update(action.kwargs)
+
+ url_path = escape_curly_brackets(action.url_path)
+
+ return Route(
+ url=route.url.replace('{url_path}', url_path),
+ mapping={http_method: action.__name__
+ for http_method in action.bind_to_methods},
+ name=route.name.replace('{url_name}', action.url_name),
+ detail=route.detail,
+ initkwargs=initkwargs,
+ )
def get_method_map(self, viewset, method_map):
"""
@@ -281,6 +284,7 @@ def get_urls(self):
initkwargs = route.initkwargs.copy()
initkwargs.update({
'basename': basename,
+ 'detail': route.detail,
})
view = viewset.as_view(mapping, **initkwargs)
diff --git a/rest_framework/viewsets.py b/rest_framework/viewsets.py
index 4ee7cdaf86..9a85049bcc 100644
--- a/rest_framework/viewsets.py
+++ b/rest_framework/viewsets.py
@@ -19,6 +19,7 @@
from __future__ import unicode_literals
from functools import update_wrapper
+from inspect import getmembers
from django.utils.decorators import classonlymethod
from django.views.decorators.csrf import csrf_exempt
@@ -27,6 +28,10 @@
from rest_framework.reverse import reverse
+def _is_extra_action(attr):
+ return hasattr(attr, 'bind_to_methods')
+
+
class ViewSetMixin(object):
"""
This is the magic.
@@ -51,6 +56,9 @@ def as_view(cls, actions=None, **initkwargs):
# eg. 'List' or 'Instance'.
cls.suffix = None
+ # The detail initkwarg is reserved for introspecting the viewset type.
+ cls.detail = None
+
# Setting a basename allows a view to reverse its action urls. This
# value is provided by the router through the initkwargs.
cls.basename = None
@@ -112,8 +120,7 @@ def view(request, *args, **kwargs):
def initialize_request(self, request, *args, **kwargs):
"""
- Set the `.action` attribute on the view,
- depending on the request method.
+ Set the `.action` attribute on the view, depending on the request method.
"""
request = super(ViewSetMixin, self).initialize_request(request, *args, **kwargs)
method = request.method.lower()
@@ -135,6 +142,13 @@ def reverse_action(self, url_name, *args, **kwargs):
return reverse(url_name, *args, **kwargs)
+ @classmethod
+ def get_extra_actions(cls):
+ """
+ Get the methods that are marked as an extra ViewSet `@action`.
+ """
+ return [method for _, method in getmembers(cls, _is_extra_action)]
+
class ViewSet(ViewSetMixin, views.APIView):
"""
diff --git a/tests/test_decorators.py b/tests/test_decorators.py
index 6331742db2..674990730c 100644
--- a/tests/test_decorators.py
+++ b/tests/test_decorators.py
@@ -1,12 +1,14 @@
from __future__ import unicode_literals
+import pytest
from django.test import TestCase
from rest_framework import status
from rest_framework.authentication import BasicAuthentication
from rest_framework.decorators import (
- api_view, authentication_classes, parser_classes, permission_classes,
- renderer_classes, schema, throttle_classes
+ action, api_view, authentication_classes, detail_route, list_route,
+ parser_classes, permission_classes, renderer_classes, schema,
+ throttle_classes
)
from rest_framework.parsers import JSONParser
from rest_framework.permissions import IsAuthenticated
@@ -166,3 +168,50 @@ def view(request):
return Response({})
assert isinstance(view.cls.schema, CustomSchema)
+
+
+class ActionDecoratorTestCase(TestCase):
+
+ def test_defaults(self):
+ @action(detail=True)
+ def test_action(request):
+ pass
+
+ assert test_action.bind_to_methods == ['get']
+ assert test_action.detail is True
+ assert test_action.url_path == 'test_action'
+ assert test_action.url_name == 'test-action'
+
+ def test_detail_required(self):
+ with pytest.raises(AssertionError) as excinfo:
+ @action()
+ def test_action(request):
+ pass
+
+ assert str(excinfo.value) == "@action() missing required argument: 'detail'"
+
+ def test_detail_route_deprecation(self):
+ with pytest.warns(PendingDeprecationWarning) as record:
+ @detail_route()
+ def view(request):
+ pass
+
+ assert len(record) == 1
+ assert str(record[0].message) == (
+ "`detail_route` is pending deprecation and will be removed in "
+ "3.10 in favor of `action`, which accepts a `detail` bool. Use "
+ "`@action(detail=True)` instead."
+ )
+
+ def test_list_route_deprecation(self):
+ with pytest.warns(PendingDeprecationWarning) as record:
+ @list_route()
+ def view(request):
+ pass
+
+ assert len(record) == 1
+ assert str(record[0].message) == (
+ "`list_route` is pending deprecation and will be removed in "
+ "3.10 in favor of `action`, which accepts a `detail` bool. Use "
+ "`@action(detail=False)` instead."
+ )
diff --git a/tests/test_routers.py b/tests/test_routers.py
index 5a1cfe8f40..128bff3dd9 100644
--- a/tests/test_routers.py
+++ b/tests/test_routers.py
@@ -11,7 +11,7 @@
from rest_framework import permissions, serializers, viewsets
from rest_framework.compat import get_regex_pattern
-from rest_framework.decorators import detail_route, list_route
+from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.routers import DefaultRouter, SimpleRouter
from rest_framework.test import APIRequestFactory
@@ -67,12 +67,12 @@ def get_object(self, *args, **kwargs):
class RegexUrlPathViewSet(viewsets.ViewSet):
- @list_route(url_path='list/(?P[0-9]{4})')
+ @action(detail=False, url_path='list/(?P[0-9]{4})')
def regex_url_path_list(self, request, *args, **kwargs):
kwarg = self.kwargs.get('kwarg', '')
return Response({'kwarg': kwarg})
- @detail_route(url_path='detail/(?P[0-9]{4})')
+ @action(detail=True, url_path='detail/(?P[0-9]{4})')
def regex_url_path_detail(self, request, *args, **kwargs):
pk = self.kwargs.get('pk', '')
kwarg = self.kwargs.get('kwarg', '')
@@ -112,23 +112,23 @@ class BasicViewSet(viewsets.ViewSet):
def list(self, request, *args, **kwargs):
return Response({'method': 'list'})
- @detail_route(methods=['post'])
+ @action(methods=['post'], detail=True)
def action1(self, request, *args, **kwargs):
return Response({'method': 'action1'})
- @detail_route(methods=['post'])
+ @action(methods=['post'], detail=True)
def action2(self, request, *args, **kwargs):
return Response({'method': 'action2'})
- @detail_route(methods=['post', 'delete'])
+ @action(methods=['post', 'delete'], detail=True)
def action3(self, request, *args, **kwargs):
return Response({'method': 'action2'})
- @detail_route()
+ @action(detail=True)
def link1(self, request, *args, **kwargs):
return Response({'method': 'link1'})
- @detail_route()
+ @action(detail=True)
def link2(self, request, *args, **kwargs):
return Response({'method': 'link2'})
@@ -297,7 +297,7 @@ def setUp(self):
class TestViewSet(viewsets.ModelViewSet):
permission_classes = []
- @detail_route(methods=['post'], permission_classes=[permissions.AllowAny])
+ @action(methods=['post'], detail=True, permission_classes=[permissions.AllowAny])
def custom(self, request, *args, **kwargs):
return Response({
'permission_classes': self.permission_classes
@@ -315,14 +315,14 @@ def test_action_kwargs(self):
class TestActionAppliedToExistingRoute(TestCase):
"""
- Ensure `@detail_route` decorator raises an except when applied
+ Ensure `@action` decorator raises an except when applied
to an existing route
"""
def test_exception_raised_when_action_applied_to_existing_route(self):
class TestViewSet(viewsets.ModelViewSet):
- @detail_route(methods=['post'])
+ @action(methods=['post'], detail=True)
def retrieve(self, request, *args, **kwargs):
return Response({
'hello': 'world'
@@ -339,27 +339,27 @@ class DynamicListAndDetailViewSet(viewsets.ViewSet):
def list(self, request, *args, **kwargs):
return Response({'method': 'list'})
- @list_route(methods=['post'])
+ @action(methods=['post'], detail=False)
def list_route_post(self, request, *args, **kwargs):
return Response({'method': 'action1'})
- @detail_route(methods=['post'])
+ @action(methods=['post'], detail=True)
def detail_route_post(self, request, *args, **kwargs):
return Response({'method': 'action2'})
- @list_route()
+ @action(detail=False)
def list_route_get(self, request, *args, **kwargs):
return Response({'method': 'link1'})
- @detail_route()
+ @action(detail=True)
def detail_route_get(self, request, *args, **kwargs):
return Response({'method': 'link2'})
- @list_route(url_path="list_custom-route")
+ @action(detail=False, url_path="list_custom-route")
def list_custom_route_get(self, request, *args, **kwargs):
return Response({'method': 'link1'})
- @detail_route(url_path="detail_custom-route")
+ @action(detail=True, url_path="detail_custom-route")
def detail_custom_route_get(self, request, *args, **kwargs):
return Response({'method': 'link2'})
@@ -446,6 +446,12 @@ def test_suffix(self):
assert initkwargs['suffix'] == 'List'
+ def test_detail(self):
+ match = resolve('/example/notes/')
+ initkwargs = match.func.initkwargs
+
+ assert not initkwargs['detail']
+
def test_basename(self):
match = resolve('/example/notes/')
initkwargs = match.func.initkwargs
diff --git a/tests/test_schemas.py b/tests/test_schemas.py
index 34cb20798a..1cbee0695c 100644
--- a/tests/test_schemas.py
+++ b/tests/test_schemas.py
@@ -10,9 +10,7 @@
filters, generics, pagination, permissions, serializers
)
from rest_framework.compat import coreapi, coreschema, get_regex_pattern, path
-from rest_framework.decorators import (
- api_view, detail_route, list_route, schema
-)
+from rest_framework.decorators import action, api_view, schema
from rest_framework.request import Request
from rest_framework.routers import DefaultRouter, SimpleRouter
from rest_framework.schemas import (
@@ -67,25 +65,25 @@ class ExampleViewSet(ModelViewSet):
filter_backends = [filters.OrderingFilter]
serializer_class = ExampleSerializer
- @detail_route(methods=['post'], serializer_class=AnotherSerializer)
+ @action(methods=['post'], detail=True, serializer_class=AnotherSerializer)
def custom_action(self, request, pk):
"""
A description of custom action.
"""
return super(ExampleSerializer, self).retrieve(self, request)
- @detail_route(methods=['post'], serializer_class=AnotherSerializerWithListFields)
+ @action(methods=['post'], detail=True, serializer_class=AnotherSerializerWithListFields)
def custom_action_with_list_fields(self, request, pk):
"""
A custom action using both list field and list serializer in the serializer.
"""
return super(ExampleSerializer, self).retrieve(self, request)
- @list_route()
+ @action(detail=False)
def custom_list_action(self, request):
return super(ExampleViewSet, self).list(self, request)
- @list_route(methods=['post', 'get'], serializer_class=EmptySerializer)
+ @action(methods=['post', 'get'], detail=False, serializer_class=EmptySerializer)
def custom_list_action_multiple_methods(self, request):
return super(ExampleViewSet, self).list(self, request)
@@ -865,11 +863,11 @@ class NamingCollisionViewSet(GenericViewSet):
"""
permision_class = ()
- @list_route()
+ @action(detail=False)
def detail(self, request):
return {}
- @list_route(url_path='detail/export')
+ @action(detail=False, url_path='detail/export')
def detail_export(self, request):
return {}
@@ -949,7 +947,10 @@ def test_from_router(self):
generator = SchemaGenerator(title='Naming Colisions', patterns=patterns)
schema = generator.get_schema()
- desc = schema['detail_0'].description # not important here
+
+ # not important here
+ desc_0 = schema['detail']['detail_export'].description
+ desc_1 = schema['detail_0'].description
expected = coreapi.Document(
url='',
@@ -959,12 +960,12 @@ def test_from_router(self):
'detail_export': coreapi.Link(
url='/from-routercollision/detail/export/',
action='get',
- description=desc)
+ description=desc_0)
},
'detail_0': coreapi.Link(
url='/from-routercollision/detail/',
action='get',
- description=desc
+ description=desc_1
)
}
)
@@ -1046,7 +1047,7 @@ def options(self, request, *args, **kwargs):
class AViewSet(ModelViewSet):
- @detail_route(methods=['options', 'get'])
+ @action(methods=['options', 'get'], detail=True)
def custom_action(self, request, pk):
pass
diff --git a/tests/test_viewsets.py b/tests/test_viewsets.py
index beff42cb89..25feb0f372 100644
--- a/tests/test_viewsets.py
+++ b/tests/test_viewsets.py
@@ -3,7 +3,7 @@
from django.test import TestCase, override_settings
from rest_framework import status
-from rest_framework.decorators import detail_route, list_route
+from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.routers import SimpleRouter
from rest_framework.test import APIRequestFactory
@@ -39,19 +39,19 @@ def list(self, request, *args, **kwargs):
def retrieve(self, request, *args, **kwargs):
pass
- @list_route()
+ @action(detail=False)
def list_action(self, request, *args, **kwargs):
pass
- @list_route(url_name='list-custom')
+ @action(detail=False, url_name='list-custom')
def custom_list_action(self, request, *args, **kwargs):
pass
- @detail_route()
+ @action(detail=True)
def detail_action(self, request, *args, **kwargs):
pass
- @detail_route(url_name='detail-custom')
+ @action(detail=True, url_name='detail-custom')
def custom_detail_action(self, request, *args, **kwargs):
pass
@@ -111,6 +111,16 @@ def test_args_kwargs_request_action_map_on_self(self):
self.assertIn(attribute, dir(view))
+class GetExtraActionTests(TestCase):
+
+ def test_extra_actions(self):
+ view = ActionViewSet()
+ actual = [action.__name__ for action in view.get_extra_actions()]
+ expected = ['custom_detail_action', 'custom_list_action', 'detail_action', 'list_action']
+
+ self.assertEqual(actual, expected)
+
+
@override_settings(ROOT_URLCONF='tests.test_viewsets')
class ReverseActionTests(TestCase):
def test_default_basename(self):
pFad - Phonifier reborn
Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies:
Alternative Proxy
pFad Proxy
pFad v3 Proxy
pFad v4 Proxy