Rework dynamic list/detail actions (#5705)

* Merge list/detail route decorators into 'action'

* Merge dynamic routes, add 'detail' attribute

* Add 'ViewSet.get_extra_actions()'

* Refactor dynamic route checking & collection

* Refactor dynamic route generation

* Add 'ViewSet.detail' initkwarg

* Fixup schema test

* Add release notes for dynamic action changes

* Replace list/detail route decorators in tests

* Convert tabs to spaces in router docs

* Update docs

* Make 'detail' a required argument of 'action'

* Improve router docs
This commit is contained in:
Ryan P Kilby 2018-01-25 03:40:49 -05:00 committed by Carlton Gibson
parent a540acdc95
commit 73203e6b59
12 changed files with 333 additions and 206 deletions

View File

@ -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. 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): def schema(self, request):
meta = self.metadata_class() meta = self.metadata_class()
data = meta.determine_metadata(request, self) data = meta.determine_metadata(request, self)

View File

@ -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. 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. 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:
For example, given a method like this on the `UserViewSet` class:
from myapp.permissions import IsAdminOrIsSelf from myapp.permissions import IsAdminOrIsSelf
from rest_framework.decorators import detail_route from rest_framework.decorators import action
class UserViewSet(ModelViewSet): 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): 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: 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 myapp.permissions import IsAdminOrIsSelf
from rest_framework.decorators import detail_route from rest_framework.decorators import action
class UserViewSet(ModelViewSet): 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): def set_password(self, request, pk=None):
... ...
The above example would now generate the following URL pattern: The above example would now generate the following URL pattern:
* URL pattern: `^users/{pk}/change-password/$` Name: `'user-change-password'` * URL path: `^users/{pk}/change-password/$`
* URL 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].
# API Guide # API Guide
## SimpleRouter ## 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.
<table border=1> <table border=1>
<tr><th>URL Style</th><th>HTTP Method</th><th>Action</th><th>URL Name</th></tr> <tr><th>URL Style</th><th>HTTP Method</th><th>Action</th><th>URL Name</th></tr>
<tr><td rowspan=2>{prefix}/</td><td>GET</td><td>list</td><td rowspan=2>{basename}-list</td></tr></tr> <tr><td rowspan=2>{prefix}/</td><td>GET</td><td>list</td><td rowspan=2>{basename}-list</td></tr></tr>
<tr><td>POST</td><td>create</td></tr> <tr><td>POST</td><td>create</td></tr>
<tr><td>{prefix}/{methodname}/</td><td>GET, or as specified by `methods` argument</td><td>`@list_route` decorated method</td><td>{basename}-{methodname}</td></tr> <tr><td>{prefix}/{url_path}/</td><td>GET, or as specified by `methods` argument</td><td>`@action(detail=False)` decorated method</td><td>{basename}-{url_name}</td></tr>
<tr><td rowspan=4>{prefix}/{lookup}/</td><td>GET</td><td>retrieve</td><td rowspan=4>{basename}-detail</td></tr></tr> <tr><td rowspan=4>{prefix}/{lookup}/</td><td>GET</td><td>retrieve</td><td rowspan=4>{basename}-detail</td></tr></tr>
<tr><td>PUT</td><td>update</td></tr> <tr><td>PUT</td><td>update</td></tr>
<tr><td>PATCH</td><td>partial_update</td></tr> <tr><td>PATCH</td><td>partial_update</td></tr>
<tr><td>DELETE</td><td>destroy</td></tr> <tr><td>DELETE</td><td>destroy</td></tr>
<tr><td>{prefix}/{lookup}/{methodname}/</td><td>GET, or as specified by `methods` argument</td><td>`@detail_route` decorated method</td><td>{basename}-{methodname}</td></tr> <tr><td>{prefix}/{lookup}/{url_path}/</td><td>GET, or as specified by `methods` argument</td><td>`@action(detail=True)` decorated method</td><td>{basename}-{url_name}</td></tr>
</table> </table>
By default the URLs created by `SimpleRouter` are appended with a trailing slash. 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
<tr><td>[.format]</td><td>GET</td><td>automatically generated root view</td><td>api-root</td></tr></tr> <tr><td>[.format]</td><td>GET</td><td>automatically generated root view</td><td>api-root</td></tr></tr>
<tr><td rowspan=2>{prefix}/[.format]</td><td>GET</td><td>list</td><td rowspan=2>{basename}-list</td></tr></tr> <tr><td rowspan=2>{prefix}/[.format]</td><td>GET</td><td>list</td><td rowspan=2>{basename}-list</td></tr></tr>
<tr><td>POST</td><td>create</td></tr> <tr><td>POST</td><td>create</td></tr>
<tr><td>{prefix}/{methodname}/[.format]</td><td>GET, or as specified by `methods` argument</td><td>`@list_route` decorated method</td><td>{basename}-{methodname}</td></tr> <tr><td>{prefix}/{url_path}/[.format]</td><td>GET, or as specified by `methods` argument</td><td>`@action(detail=False)` decorated method</td><td>{basename}-{url_name}</td></tr>
<tr><td rowspan=4>{prefix}/{lookup}/[.format]</td><td>GET</td><td>retrieve</td><td rowspan=4>{basename}-detail</td></tr></tr> <tr><td rowspan=4>{prefix}/{lookup}/[.format]</td><td>GET</td><td>retrieve</td><td rowspan=4>{basename}-detail</td></tr></tr>
<tr><td>PUT</td><td>update</td></tr> <tr><td>PUT</td><td>update</td></tr>
<tr><td>PATCH</td><td>partial_update</td></tr> <tr><td>PATCH</td><td>partial_update</td></tr>
<tr><td>DELETE</td><td>destroy</td></tr> <tr><td>DELETE</td><td>destroy</td></tr>
<tr><td>{prefix}/{lookup}/{methodname}/[.format]</td><td>GET, or as specified by `methods` argument</td><td>`@detail_route` decorated method</td><td>{basename}-{methodname}</td></tr> <tr><td>{prefix}/{lookup}/{url_path}/[.format]</td><td>GET, or as specified by `methods` argument</td><td>`@action(detail=True)` decorated method</td><td>{basename}-{url_name}</td></tr>
</table> </table>
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. 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. * `{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 ## Customizing dynamic routes
You can also customize how the `@list_route` and `@detail_route` decorators are routed. 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:
To route either or both of these decorators, include a `DynamicListRoute` and/or `DynamicDetailRoute` named tuple in the `.routes` list.
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. **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. 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): class CustomReadOnlyRouter(SimpleRouter):
""" """
@ -239,22 +220,23 @@ The following example will only route to the `list` and `retrieve` actions, and
""" """
routes = [ routes = [
Route( Route(
url=r'^{prefix}$', url=r'^{prefix}$',
mapping={'get': 'list'}, mapping={'get': 'list'},
name='{basename}-list', name='{basename}-list',
initkwargs={'suffix': 'List'} initkwargs={'suffix': 'List'}
), ),
Route( Route(
url=r'^{prefix}/{lookup}$', url=r'^{prefix}/{lookup}$',
mapping={'get': 'retrieve'}, mapping={'get': 'retrieve'},
name='{basename}-detail', name='{basename}-detail',
initkwargs={'suffix': 'Detail'} initkwargs={'suffix': 'Detail'}
), ),
DynamicDetailRoute( DynamicRoute(
url=r'^{prefix}/{lookup}/{methodnamehyphen}$', url=r'^{prefix}/{lookup}/{url_path}$',
name='{basename}-{methodnamehyphen}', name='{basename}-{url_name}',
initkwargs={} detail=True,
) initkwargs={}
)
] ]
Let's take a look at the routes our `CustomReadOnlyRouter` would generate for a simple viewset. 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 serializer_class = UserSerializer
lookup_field = 'username' lookup_field = 'username'
@detail_route() @action(detail=True)
def group_names(self, request, pk=None): def group_names(self, request, pk=None):
""" """
Returns a list of all the group names that the given 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 = CustomReadOnlyRouter()
router.register('users', UserViewSet) router.register('users', UserViewSet)
urlpatterns = router.urls urlpatterns = router.urls
The following mappings would be generated... The following mappings would be generated...

View File

@ -102,10 +102,16 @@ The default routers included with REST framework will provide routes for a stand
def destroy(self, request, pk=None): def destroy(self, request, pk=None):
pass pass
During dispatch the name of the current action is available via the `.action` attribute. ## Introspecting ViewSet actions
You may inspect `.action` to adjust behaviour based on the current action.
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): 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 ## 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. 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.
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. A more complete example of extra actions:
For example:
from django.contrib.auth.models import User from django.contrib.auth.models import User
from rest_framework import status from rest_framework import status, viewsets
from rest_framework import viewsets from rest_framework.decorators import action
from rest_framework.decorators import detail_route, list_route
from rest_framework.response import Response from rest_framework.response import Response
from myapp.serializers import UserSerializer, PasswordSerializer from myapp.serializers import UserSerializer, PasswordSerializer
@ -139,7 +142,7 @@ For example:
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = UserSerializer serializer_class = UserSerializer
@detail_route(methods=['post']) @action(methods=['post'], detail=True)
def set_password(self, request, pk=None): def set_password(self, request, pk=None):
user = self.get_object() user = self.get_object()
serializer = PasswordSerializer(data=request.data) serializer = PasswordSerializer(data=request.data)
@ -151,7 +154,7 @@ For example:
return Response(serializer.errors, return Response(serializer.errors,
status=status.HTTP_400_BAD_REQUEST) status=status.HTTP_400_BAD_REQUEST)
@list_route() @action(detail=False)
def recent_users(self, request): def recent_users(self, request):
recent_users = User.objects.all().order('-last_login') recent_users = User.objects.all().order('-last_login')
@ -163,20 +166,22 @@ For example:
serializer = self.get_serializer(recent_users, many=True) serializer = self.get_serializer(recent_users, many=True)
return Response(serializer.data) 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): 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): 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/$` 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 ## 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. 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' '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`.
--- ---

View File

@ -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.x series
### 3.7.7 ### 3.7.7
@ -940,6 +965,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.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.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.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
<!-- 3.0.1 --> <!-- 3.0.1 -->
[gh2013]: https://github.com/encode/django-rest-framework/issues/2013 [gh2013]: https://github.com/encode/django-rest-framework/issues/2013
@ -1750,3 +1776,6 @@ For older release notes, [please see the version 2.x documentation][old-release-
[gh5695]: https://github.com/encode/django-rest-framework/issues/5695 [gh5695]: https://github.com/encode/django-rest-framework/issues/5695
[gh5696]: https://github.com/encode/django-rest-framework/issues/5696 [gh5696]: https://github.com/encode/django-rest-framework/issues/5696
[gh5697]: https://github.com/encode/django-rest-framework/issues/5697 [gh5697]: https://github.com/encode/django-rest-framework/issues/5697
<!-- 3.8.0 -->
[gh5705]: https://github.com/encode/django-rest-framework/issues/5705

View File

@ -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. 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 from rest_framework.response import Response
class SnippetViewSet(viewsets.ModelViewSet): class SnippetViewSet(viewsets.ModelViewSet):
@ -40,7 +40,7 @@ Next we're going to replace the `SnippetList`, `SnippetDetail` and `SnippetHighl
permission_classes = (permissions.IsAuthenticatedOrReadOnly, permission_classes = (permissions.IsAuthenticatedOrReadOnly,
IsOwnerOrReadOnly,) IsOwnerOrReadOnly,)
@detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) @action(detail=True, renderer_classes=[renderers.StaticHTMLRenderer])
def highlight(self, request, *args, **kwargs): def highlight(self, request, *args, **kwargs):
snippet = self.get_object() snippet = self.get_object()
return Response(snippet.highlighted) 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. 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 ## Binding ViewSets to URLs explicitly

View File

@ -130,29 +130,49 @@ def schema(view_inspector):
return decorator return decorator
def action(methods=None, detail=None, url_path=None, url_name=None, **kwargs):
"""
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 = 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): def detail_route(methods=None, **kwargs):
""" """
Used to mark a method on a ViewSet that should be routed for detail requests. Used to mark a method on a ViewSet that should be routed for detail requests.
""" """
methods = ['get'] if (methods is None) else methods warnings.warn(
"`detail_route` is pending deprecation and will be removed in 3.10 in favor of "
def decorator(func): "`action`, which accepts a `detail` bool. Use `@action(detail=True)` instead.",
func.bind_to_methods = methods PendingDeprecationWarning, stacklevel=2
func.detail = True )
func.kwargs = kwargs return action(methods, detail=True, **kwargs)
return func
return decorator
def list_route(methods=None, **kwargs): def list_route(methods=None, **kwargs):
""" """
Used to mark a method on a ViewSet that should be routed for list requests. Used to mark a method on a ViewSet that should be routed for list requests.
""" """
methods = ['get'] if (methods is None) else methods warnings.warn(
"`list_route` is pending deprecation and will be removed in 3.10 in favor of "
def decorator(func): "`action`, which accepts a `detail` bool. Use `@action(detail=False)` instead.",
func.bind_to_methods = methods PendingDeprecationWarning, stacklevel=2
func.detail = False )
func.kwargs = kwargs return action(methods, detail=False, **kwargs)
return func
return decorator

View File

@ -16,6 +16,7 @@ For example, you might have a `urls.py` that looks something like this:
from __future__ import unicode_literals from __future__ import unicode_literals
import itertools import itertools
import warnings
from collections import OrderedDict, namedtuple from collections import OrderedDict, namedtuple
from django.conf.urls import url from django.conf.urls import url
@ -30,9 +31,30 @@ from rest_framework.schemas.views import SchemaView
from rest_framework.settings import api_settings from rest_framework.settings import api_settings
from rest_framework.urlpatterns import format_suffix_patterns from rest_framework.urlpatterns import format_suffix_patterns
Route = namedtuple('Route', ['url', 'mapping', 'name', 'initkwargs']) Route = namedtuple('Route', ['url', 'mapping', 'name', 'detail', 'initkwargs'])
DynamicDetailRoute = namedtuple('DynamicDetailRoute', ['url', 'name', 'initkwargs']) DynamicRoute = namedtuple('DynamicRoute', ['url', 'name', 'detail', 'initkwargs'])
DynamicListRoute = namedtuple('DynamicListRoute', ['url', 'name', '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): def escape_curly_brackets(url_path):
@ -44,18 +66,6 @@ def escape_curly_brackets(url_path):
return 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): def flatten(list_of_lists):
""" """
Takes an iterable of iterables, returns a single iterable containing all items Takes an iterable of iterables, returns a single iterable containing all items
@ -103,14 +113,15 @@ class SimpleRouter(BaseRouter):
'post': 'create' 'post': 'create'
}, },
name='{basename}-list', name='{basename}-list',
detail=False,
initkwargs={'suffix': 'List'} initkwargs={'suffix': 'List'}
), ),
# Dynamically generated list routes. # Dynamically generated list routes. Generated using
# Generated using @list_route decorator # @action(detail=False) decorator on methods of the viewset.
# on methods of the viewset. DynamicRoute(
DynamicListRoute( url=r'^{prefix}/{url_path}{trailing_slash}$',
url=r'^{prefix}/{methodname}{trailing_slash}$', name='{basename}-{url_name}',
name='{basename}-{methodnamehyphen}', detail=False,
initkwargs={} initkwargs={}
), ),
# Detail route. # Detail route.
@ -123,13 +134,15 @@ class SimpleRouter(BaseRouter):
'delete': 'destroy' 'delete': 'destroy'
}, },
name='{basename}-detail', name='{basename}-detail',
detail=True,
initkwargs={'suffix': 'Instance'} initkwargs={'suffix': 'Instance'}
), ),
# Dynamically generated detail routes. # Dynamically generated detail routes. Generated using
# Generated using @detail_route decorator on methods of the viewset. # @action(detail=True) decorator on methods of the viewset.
DynamicDetailRoute( DynamicRoute(
url=r'^{prefix}/{lookup}/{methodname}{trailing_slash}$', url=r'^{prefix}/{lookup}/{url_path}{trailing_slash}$',
name='{basename}-{methodnamehyphen}', name='{basename}-{url_name}',
detail=True,
initkwargs={} initkwargs={}
), ),
] ]
@ -160,57 +173,47 @@ class SimpleRouter(BaseRouter):
# converting to list as iterables are good for one pass, known host needs to be checked again and again for # converting to list as iterables are good for one pass, known host needs to be checked again and again for
# different functions. # different functions.
known_actions = list(flatten([route.mapping.values() for route in self.routes if isinstance(route, Route)])) known_actions = list(flatten([route.mapping.values() for route in self.routes if isinstance(route, Route)]))
extra_actions = viewset.get_extra_actions()
# Determine any `@detail_route` or `@list_route` decorated methods on the viewset # checking action names against the known actions list
detail_routes = [] not_allowed = [
list_routes = [] action.__name__ for action in extra_actions
for methodname in dir(viewset): if action.__name__ in known_actions
attr = getattr(viewset, methodname) ]
httpmethods = getattr(attr, 'bind_to_methods', None) if not_allowed:
detail = getattr(attr, 'detail', True) msg = ('Cannot use the @action decorator on the following '
if httpmethods: 'methods, as they are existing routes: %s')
# checking method names against the known actions list raise ImproperlyConfigured(msg % ', '.join(not_allowed))
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): # partition detail and list actions
ret = [] detail_actions = [action for action in extra_actions if action.detail]
for httpmethods, methodname in dynamic_routes: list_actions = [action for action in extra_actions if not action.detail]
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 routes = []
ret = []
for route in self.routes: for route in self.routes:
if isinstance(route, DynamicDetailRoute): if isinstance(route, DynamicRoute) and route.detail:
# Dynamic detail routes (@detail_route decorator) routes += [self._get_dynamic_route(route, action) for action in detail_actions]
ret += _get_dynamic_routes(route, detail_routes) elif isinstance(route, DynamicRoute) and not route.detail:
elif isinstance(route, DynamicListRoute): routes += [self._get_dynamic_route(route, action) for action in list_actions]
# Dynamic list routes (@list_route decorator)
ret += _get_dynamic_routes(route, list_routes)
else: else:
# Standard route routes.append(route)
ret.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): def get_method_map(self, viewset, method_map):
""" """
@ -281,6 +284,7 @@ class SimpleRouter(BaseRouter):
initkwargs = route.initkwargs.copy() initkwargs = route.initkwargs.copy()
initkwargs.update({ initkwargs.update({
'basename': basename, 'basename': basename,
'detail': route.detail,
}) })
view = viewset.as_view(mapping, **initkwargs) view = viewset.as_view(mapping, **initkwargs)

View File

@ -19,6 +19,7 @@ automatically.
from __future__ import unicode_literals from __future__ import unicode_literals
from functools import update_wrapper from functools import update_wrapper
from inspect import getmembers
from django.utils.decorators import classonlymethod from django.utils.decorators import classonlymethod
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
@ -27,6 +28,10 @@ from rest_framework import generics, mixins, views
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
def _is_extra_action(attr):
return hasattr(attr, 'bind_to_methods')
class ViewSetMixin(object): class ViewSetMixin(object):
""" """
This is the magic. This is the magic.
@ -51,6 +56,9 @@ class ViewSetMixin(object):
# eg. 'List' or 'Instance'. # eg. 'List' or 'Instance'.
cls.suffix = None 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 # Setting a basename allows a view to reverse its action urls. This
# value is provided by the router through the initkwargs. # value is provided by the router through the initkwargs.
cls.basename = None cls.basename = None
@ -112,8 +120,7 @@ class ViewSetMixin(object):
def initialize_request(self, request, *args, **kwargs): def initialize_request(self, request, *args, **kwargs):
""" """
Set the `.action` attribute on the view, Set the `.action` attribute on the view, depending on the request method.
depending on the request method.
""" """
request = super(ViewSetMixin, self).initialize_request(request, *args, **kwargs) request = super(ViewSetMixin, self).initialize_request(request, *args, **kwargs)
method = request.method.lower() method = request.method.lower()
@ -135,6 +142,13 @@ class ViewSetMixin(object):
return reverse(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): class ViewSet(ViewSetMixin, views.APIView):
""" """

View File

@ -1,12 +1,14 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import pytest
from django.test import TestCase from django.test import TestCase
from rest_framework import status from rest_framework import status
from rest_framework.authentication import BasicAuthentication from rest_framework.authentication import BasicAuthentication
from rest_framework.decorators import ( from rest_framework.decorators import (
api_view, authentication_classes, parser_classes, permission_classes, action, api_view, authentication_classes, detail_route, list_route,
renderer_classes, schema, throttle_classes parser_classes, permission_classes, renderer_classes, schema,
throttle_classes
) )
from rest_framework.parsers import JSONParser from rest_framework.parsers import JSONParser
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
@ -166,3 +168,50 @@ class DecoratorTestCase(TestCase):
return Response({}) return Response({})
assert isinstance(view.cls.schema, CustomSchema) 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."
)

View File

@ -11,7 +11,7 @@ from django.urls import resolve
from rest_framework import permissions, serializers, viewsets from rest_framework import permissions, serializers, viewsets
from rest_framework.compat import get_regex_pattern 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.response import Response
from rest_framework.routers import DefaultRouter, SimpleRouter from rest_framework.routers import DefaultRouter, SimpleRouter
from rest_framework.test import APIRequestFactory, URLPatternsTestCase from rest_framework.test import APIRequestFactory, URLPatternsTestCase
@ -67,12 +67,12 @@ class EmptyPrefixViewSet(viewsets.ModelViewSet):
class RegexUrlPathViewSet(viewsets.ViewSet): class RegexUrlPathViewSet(viewsets.ViewSet):
@list_route(url_path='list/(?P<kwarg>[0-9]{4})') @action(detail=False, url_path='list/(?P<kwarg>[0-9]{4})')
def regex_url_path_list(self, request, *args, **kwargs): def regex_url_path_list(self, request, *args, **kwargs):
kwarg = self.kwargs.get('kwarg', '') kwarg = self.kwargs.get('kwarg', '')
return Response({'kwarg': kwarg}) return Response({'kwarg': kwarg})
@detail_route(url_path='detail/(?P<kwarg>[0-9]{4})') @action(detail=True, url_path='detail/(?P<kwarg>[0-9]{4})')
def regex_url_path_detail(self, request, *args, **kwargs): def regex_url_path_detail(self, request, *args, **kwargs):
pk = self.kwargs.get('pk', '') pk = self.kwargs.get('pk', '')
kwarg = self.kwargs.get('kwarg', '') kwarg = self.kwargs.get('kwarg', '')
@ -99,23 +99,23 @@ class BasicViewSet(viewsets.ViewSet):
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
return Response({'method': 'list'}) return Response({'method': 'list'})
@detail_route(methods=['post']) @action(methods=['post'], detail=True)
def action1(self, request, *args, **kwargs): def action1(self, request, *args, **kwargs):
return Response({'method': 'action1'}) return Response({'method': 'action1'})
@detail_route(methods=['post']) @action(methods=['post'], detail=True)
def action2(self, request, *args, **kwargs): def action2(self, request, *args, **kwargs):
return Response({'method': 'action2'}) return Response({'method': 'action2'})
@detail_route(methods=['post', 'delete']) @action(methods=['post', 'delete'], detail=True)
def action3(self, request, *args, **kwargs): def action3(self, request, *args, **kwargs):
return Response({'method': 'action2'}) return Response({'method': 'action2'})
@detail_route() @action(detail=True)
def link1(self, request, *args, **kwargs): def link1(self, request, *args, **kwargs):
return Response({'method': 'link1'}) return Response({'method': 'link1'})
@detail_route() @action(detail=True)
def link2(self, request, *args, **kwargs): def link2(self, request, *args, **kwargs):
return Response({'method': 'link2'}) return Response({'method': 'link2'})
@ -297,7 +297,7 @@ class TestActionKeywordArgs(TestCase):
class TestViewSet(viewsets.ModelViewSet): class TestViewSet(viewsets.ModelViewSet):
permission_classes = [] 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): def custom(self, request, *args, **kwargs):
return Response({ return Response({
'permission_classes': self.permission_classes 'permission_classes': self.permission_classes
@ -315,14 +315,14 @@ class TestActionKeywordArgs(TestCase):
class TestActionAppliedToExistingRoute(TestCase): class TestActionAppliedToExistingRoute(TestCase):
""" """
Ensure `@detail_route` decorator raises an except when applied Ensure `@action` decorator raises an except when applied
to an existing route to an existing route
""" """
def test_exception_raised_when_action_applied_to_existing_route(self): def test_exception_raised_when_action_applied_to_existing_route(self):
class TestViewSet(viewsets.ModelViewSet): class TestViewSet(viewsets.ModelViewSet):
@detail_route(methods=['post']) @action(methods=['post'], detail=True)
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
return Response({ return Response({
'hello': 'world' 'hello': 'world'
@ -339,27 +339,27 @@ class DynamicListAndDetailViewSet(viewsets.ViewSet):
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
return Response({'method': 'list'}) return Response({'method': 'list'})
@list_route(methods=['post']) @action(methods=['post'], detail=False)
def list_route_post(self, request, *args, **kwargs): def list_route_post(self, request, *args, **kwargs):
return Response({'method': 'action1'}) return Response({'method': 'action1'})
@detail_route(methods=['post']) @action(methods=['post'], detail=True)
def detail_route_post(self, request, *args, **kwargs): def detail_route_post(self, request, *args, **kwargs):
return Response({'method': 'action2'}) return Response({'method': 'action2'})
@list_route() @action(detail=False)
def list_route_get(self, request, *args, **kwargs): def list_route_get(self, request, *args, **kwargs):
return Response({'method': 'link1'}) return Response({'method': 'link1'})
@detail_route() @action(detail=True)
def detail_route_get(self, request, *args, **kwargs): def detail_route_get(self, request, *args, **kwargs):
return Response({'method': 'link2'}) 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): def list_custom_route_get(self, request, *args, **kwargs):
return Response({'method': 'link1'}) 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): def detail_custom_route_get(self, request, *args, **kwargs):
return Response({'method': 'link2'}) return Response({'method': 'link2'})
@ -455,6 +455,12 @@ class TestViewInitkwargs(URLPatternsTestCase, TestCase):
assert initkwargs['suffix'] == 'List' assert initkwargs['suffix'] == 'List'
def test_detail(self):
match = resolve('/example/notes/')
initkwargs = match.func.initkwargs
assert not initkwargs['detail']
def test_basename(self): def test_basename(self):
match = resolve('/example/notes/') match = resolve('/example/notes/')
initkwargs = match.func.initkwargs initkwargs = match.func.initkwargs

View File

@ -10,9 +10,7 @@ from rest_framework import (
filters, generics, pagination, permissions, serializers filters, generics, pagination, permissions, serializers
) )
from rest_framework.compat import coreapi, coreschema, get_regex_pattern, path from rest_framework.compat import coreapi, coreschema, get_regex_pattern, path
from rest_framework.decorators import ( from rest_framework.decorators import action, api_view, schema
api_view, detail_route, list_route, schema
)
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.routers import DefaultRouter, SimpleRouter from rest_framework.routers import DefaultRouter, SimpleRouter
from rest_framework.schemas import ( from rest_framework.schemas import (
@ -67,25 +65,25 @@ class ExampleViewSet(ModelViewSet):
filter_backends = [filters.OrderingFilter] filter_backends = [filters.OrderingFilter]
serializer_class = ExampleSerializer serializer_class = ExampleSerializer
@detail_route(methods=['post'], serializer_class=AnotherSerializer) @action(methods=['post'], detail=True, serializer_class=AnotherSerializer)
def custom_action(self, request, pk): def custom_action(self, request, pk):
""" """
A description of custom action. A description of custom action.
""" """
return super(ExampleSerializer, self).retrieve(self, request) 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): def custom_action_with_list_fields(self, request, pk):
""" """
A custom action using both list field and list serializer in the serializer. A custom action using both list field and list serializer in the serializer.
""" """
return super(ExampleSerializer, self).retrieve(self, request) return super(ExampleSerializer, self).retrieve(self, request)
@list_route() @action(detail=False)
def custom_list_action(self, request): def custom_list_action(self, request):
return super(ExampleViewSet, self).list(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): def custom_list_action_multiple_methods(self, request):
return super(ExampleViewSet, self).list(self, request) return super(ExampleViewSet, self).list(self, request)
@ -865,11 +863,11 @@ class NamingCollisionViewSet(GenericViewSet):
""" """
permision_class = () permision_class = ()
@list_route() @action(detail=False)
def detail(self, request): def detail(self, request):
return {} return {}
@list_route(url_path='detail/export') @action(detail=False, url_path='detail/export')
def detail_export(self, request): def detail_export(self, request):
return {} return {}
@ -949,7 +947,10 @@ class TestURLNamingCollisions(TestCase):
generator = SchemaGenerator(title='Naming Colisions', patterns=patterns) generator = SchemaGenerator(title='Naming Colisions', patterns=patterns)
schema = generator.get_schema() 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( expected = coreapi.Document(
url='', url='',
@ -959,12 +960,12 @@ class TestURLNamingCollisions(TestCase):
'detail_export': coreapi.Link( 'detail_export': coreapi.Link(
url='/from-routercollision/detail/export/', url='/from-routercollision/detail/export/',
action='get', action='get',
description=desc) description=desc_0)
}, },
'detail_0': coreapi.Link( 'detail_0': coreapi.Link(
url='/from-routercollision/detail/', url='/from-routercollision/detail/',
action='get', action='get',
description=desc description=desc_1
) )
} }
) )
@ -1046,7 +1047,7 @@ def test_head_and_options_methods_are_excluded():
class AViewSet(ModelViewSet): class AViewSet(ModelViewSet):
@detail_route(methods=['options', 'get']) @action(methods=['options', 'get'], detail=True)
def custom_action(self, request, pk): def custom_action(self, request, pk):
pass pass

View File

@ -3,7 +3,7 @@ from django.db import models
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from rest_framework import status 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.response import Response
from rest_framework.routers import SimpleRouter from rest_framework.routers import SimpleRouter
from rest_framework.test import APIRequestFactory from rest_framework.test import APIRequestFactory
@ -39,19 +39,19 @@ class ActionViewSet(GenericViewSet):
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
pass pass
@list_route() @action(detail=False)
def list_action(self, request, *args, **kwargs): def list_action(self, request, *args, **kwargs):
pass pass
@list_route(url_name='list-custom') @action(detail=False, url_name='list-custom')
def custom_list_action(self, request, *args, **kwargs): def custom_list_action(self, request, *args, **kwargs):
pass pass
@detail_route() @action(detail=True)
def detail_action(self, request, *args, **kwargs): def detail_action(self, request, *args, **kwargs):
pass pass
@detail_route(url_name='detail-custom') @action(detail=True, url_name='detail-custom')
def custom_detail_action(self, request, *args, **kwargs): def custom_detail_action(self, request, *args, **kwargs):
pass pass
@ -111,6 +111,16 @@ class InitializeViewSetsTestCase(TestCase):
self.assertIn(attribute, dir(view)) 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') @override_settings(ROOT_URLCONF='tests.test_viewsets')
class ReverseActionTests(TestCase): class ReverseActionTests(TestCase):
def test_default_basename(self): def test_default_basename(self):