diff --git a/docs/api-guide/routers.md b/docs/api-guide/routers.md
index fb48197e9..ac0b4a584 100644
--- a/docs/api-guide/routers.md
+++ b/docs/api-guide/routers.md
@@ -39,7 +39,7 @@ The example above would generate the following URL patterns:
### Extra link and actions
-Any methods on the viewset decorated with `@link` or `@action` will also be routed.
+Any methods on the viewset decorated with `@link`, `@global_link`, `@action` or `@global_action` will also be routed.
For example, given a method like this on the `UserViewSet` class:
from myapp.permissions import IsAdminOrIsSelf
@@ -49,20 +49,32 @@ For example, given a method like this on the `UserViewSet` class:
def set_password(self, request, pk=None):
...
-The following URL pattern would additionally be generated:
+ @global_action()
+ def login(self, request):
+ ...
+
+The following URL pattern would additionally be generated for `@link` and `@action`:
* URL pattern: `^users/{pk}/set_password/$` Name: `'user-set-password'`
+The following URL pattern would additionally be generated for `@global_link` and `@global_action`:
+
+* URL pattern: `^users/login/$` Name: `'user-global-login'`
+
+**Note**: The `@global_action` and `@global_link` decorators generate urls with greater precedence than routes with `{pk}` params, take care to not hide valid instance urls with an action or link url (For example, on resources with a slug as pk).
+
# 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 `@link` or `@action` 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 `@link`, `@global_link`, `@action` or `@global_action` decorators.
URL Style | HTTP Method | Action | URL Name |
{prefix}/ | GET | list | {basename}-list |
POST | create |
+ {prefix}/{methodname}/[.format] | GET | @global_link decorated method | {basename}-global-{methodname} |
+ POST | @global_action decorated method |
{prefix}/{lookup}/ | GET | retrieve | {basename}-detail |
PUT | update |
PATCH | partial_update |
@@ -87,6 +99,8 @@ 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 | @global_link decorated method | {basename}-global-{methodname} |
+ POST | @global_action decorated method |
{prefix}/{lookup}/[.format] | GET | retrieve | {basename}-detail |
PUT | update |
PATCH | partial_update |
diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md
index 1062cb32c..82202d0a4 100644
--- a/docs/api-guide/viewsets.md
+++ b/docs/api-guide/viewsets.md
@@ -101,7 +101,7 @@ The default routers included with REST framework will provide routes for a stand
def destroy(self, request, pk=None):
pass
-If you have ad-hoc methods that you need to be routed to, you can mark them as requiring routing using the `@link` or `@action` decorators. The `@link` decorator will route `GET` requests, and the `@action` decorator will route `POST` requests.
+If you have ad-hoc methods that you need to be routed to, you can mark them as requiring routing using the `@link`, `@global_link`, `@action` or `@global_action` decorators. The `@link` and `@global_link` decorators will route `GET` requests, and the `@action` and `@global_action` decorators will route `POST` requests.
For example:
@@ -131,13 +131,13 @@ For example:
return Response(serializer.errors,
status=status.HTTP_400_BAD_REQUEST)
-The `@action` and `@link` decorators can additionally take extra arguments that will be set for the routed view only. For example...
+The `@action`, `@global_action`, `@link` and `@global_link` decorators can additionally take extra arguments that will be set for the routed view only. For example...
@action(permission_classes=[IsAdminOrIsSelf])
def set_password(self, request, pk=None):
...
-The `@action` decorator will route `POST` requests by default, but may also accept other HTTP methods, by using the `method` argument. For example:
+The `@action` and `@global_action` decorators will route `POST` requests by default, but may also accept other HTTP methods, by using the `method` argument. For example:
@action(methods=['POST', 'DELETE'])
def unset_password(self, request, pk=None):
@@ -145,6 +145,17 @@ The `@action` decorator will route `POST` requests by default, but may also acce
The two new actions will then be available at the urls `^users/{pk}/set_password/$` and `^users/{pk}/unset_password/$`
+The global actions allow to generate actions and links without the `{pk}` route part. For example:
+
+ @global_action()
+ def login(self, request):
+ ...
+
+ @global_link()
+ def logout(self, request):
+ ...
+
+This action and link will then be available at the urls `^users/login/$` and `^users/logout/$`
---
diff --git a/docs/tutorial/6-viewsets-and-routers.md b/docs/tutorial/6-viewsets-and-routers.md
index 870632f1b..fd7f5078e 100644
--- a/docs/tutorial/6-viewsets-and-routers.md
+++ b/docs/tutorial/6-viewsets-and-routers.md
@@ -51,7 +51,7 @@ This time we've used the `ModelViewSet` class in order to get the complete set o
Notice that we've also used the `@link` 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 `@link` decorator will respond to `GET` requests. We could have instead used the `@action` decorator if we wanted an action that responded to `POST` requests.
+Custom actions which use the `@link` or `@global_link` decorators will respond to `GET` requests. We could have instead used the `@action` or `@global_action` decorators if we wanted an action that responded to `POST` requests.
## Binding ViewSets to URLs explicitly
diff --git a/rest_framework/decorators.py b/rest_framework/decorators.py
index c69756a43..095f5253a 100644
--- a/rest_framework/decorators.py
+++ b/rest_framework/decorators.py
@@ -127,3 +127,25 @@ def action(methods=['post'], **kwargs):
func.kwargs = kwargs
return func
return decorator
+
+
+def global_link(**kwargs):
+ """
+ Used to mark a method on a ViewSet that should be routed for GET requests.
+ """
+ def decorator(func):
+ func.global_bind_to_methods = ['get']
+ func.global_kwargs = kwargs
+ return func
+ return decorator
+
+
+def global_action(methods=['post'], **kwargs):
+ """
+ Used to mark a method on a ViewSet that should be routed for POST requests.
+ """
+ def decorator(func):
+ func.global_bind_to_methods = methods
+ func.global_kwargs = kwargs
+ return func
+ return decorator
diff --git a/rest_framework/routers.py b/rest_framework/routers.py
index 3fee1e494..d766b4763 100644
--- a/rest_framework/routers.py
+++ b/rest_framework/routers.py
@@ -88,6 +88,16 @@ class SimpleRouter(BaseRouter):
name='{basename}-list',
initkwargs={'suffix': 'List'}
),
+ # Dynamically generated routes.
+ # Generated using @global_action or @global_link decorators on methods of the viewset.
+ Route(
+ url=r'^{prefix}/{methodname}{trailing_slash}$',
+ mapping={
+ '{httpmethod}': '{globalmethodname}',
+ },
+ name='{basename}-global-{methodnamehyphen}',
+ initkwargs={}
+ ),
# Detail route.
Route(
url=r'^{prefix}/{lookup}{trailing_slash}$',
@@ -141,10 +151,13 @@ class SimpleRouter(BaseRouter):
known_actions = flatten([route.mapping.values() for route in self.routes])
- # Determine any `@action` or `@link` decorated methods on the viewset
+ # Determine any `@action`, `@global_action`, `@link` or `@global_link`
+ # decorated methods on the viewset
dynamic_routes = []
+ global_dynamic_routes = []
for methodname in dir(viewset):
attr = getattr(viewset, methodname)
+
httpmethods = getattr(attr, 'bind_to_methods', None)
if httpmethods:
if methodname in known_actions:
@@ -153,6 +166,14 @@ class SimpleRouter(BaseRouter):
httpmethods = [method.lower() for method in httpmethods]
dynamic_routes.append((httpmethods, methodname))
+ httpmethods = getattr(attr, 'global_bind_to_methods', None)
+ if httpmethods:
+ if methodname in known_actions:
+ raise ImproperlyConfigured('Cannot use @global_action or @global_link decorator on '
+ 'method "%s" as it is an existing route' % methodname)
+ httpmethods = [method.lower() for method in httpmethods]
+ global_dynamic_routes.append((httpmethods, methodname))
+
ret = []
for route in self.routes:
if route.mapping == {'{httpmethod}': '{methodname}'}:
@@ -166,6 +187,17 @@ class SimpleRouter(BaseRouter):
name=replace_methodname(route.name, methodname),
initkwargs=initkwargs,
))
+ elif route.mapping == {'{httpmethod}': '{globalmethodname}'}:
+ # Dynamic routes (@global_link or @global_action decorator)
+ for httpmethods, methodname in global_dynamic_routes:
+ initkwargs = route.initkwargs.copy()
+ initkwargs.update(getattr(viewset, methodname).global_kwargs)
+ ret.append(Route(
+ url=replace_methodname(route.url, methodname),
+ mapping=dict((httpmethod, methodname) for httpmethod in httpmethods),
+ name=replace_methodname(route.name, methodname),
+ initkwargs=initkwargs,
+ ))
else:
# Standard route
ret.append(route)
diff --git a/rest_framework/tests/test_routers.py b/rest_framework/tests/test_routers.py
index e723f7d45..910e2b8d6 100644
--- a/rest_framework/tests/test_routers.py
+++ b/rest_framework/tests/test_routers.py
@@ -4,7 +4,7 @@ from django.test import TestCase
from django.core.exceptions import ImproperlyConfigured
from rest_framework import serializers, viewsets, permissions
from rest_framework.compat import include, patterns, url
-from rest_framework.decorators import link, action
+from rest_framework.decorators import link, action, global_link, global_action
from rest_framework.response import Response
from rest_framework.routers import SimpleRouter, DefaultRouter
from rest_framework.test import APIRequestFactory
@@ -38,6 +38,30 @@ class BasicViewSet(viewsets.ViewSet):
def link2(self, request, *args, **kwargs):
return Response({'method': 'link2'})
+class BasicWithGlobalsViewSet(viewsets.ViewSet):
+ def list(self, request, *args, **kwargs):
+ return Response({'method': 'list'})
+
+ @global_action()
+ def global_action1(self, request, *args, **kwargs):
+ return Response({'method': 'global_action1'})
+
+ @global_action()
+ def global_action2(self, request, *args, **kwargs):
+ return Response({'method': 'global_action2'})
+
+ @global_action(methods=['post', 'delete'])
+ def global_action3(self, request, *args, **kwargs):
+ return Response({'method': 'global_action3'})
+
+ @global_link()
+ def global_link1(self, request, *args, **kwargs):
+ return Response({'method': 'global_link1'})
+
+ @global_link()
+ def global_link2(self, request, *args, **kwargs):
+ return Response({'method': 'global_link2'})
+
class TestSimpleRouter(TestCase):
def setUp(self):
@@ -62,6 +86,25 @@ class TestSimpleRouter(TestCase):
for method in methods_map:
self.assertEqual(route.mapping[method], endpoint)
+ def test_global_link_and_global_action_decorator(self):
+ routes = self.router.get_routes(BasicWithGlobalsViewSet)
+ decorator_routes = routes[1:]
+ # Make sure all these endpoints exist and none have been clobbered
+ for i, endpoint in enumerate(['global_action1', 'global_action2', 'global_action3', 'global_link1', 'global_link2']):
+ route = decorator_routes[i]
+ # check url listing
+ self.assertEqual(route.url,
+ '^{{prefix}}/{0}{{trailing_slash}}$'.format(endpoint))
+ # check method to function mapping
+ if endpoint == 'global_action3':
+ methods_map = ['post', 'delete']
+ elif endpoint.startswith('global_action'):
+ methods_map = ['post']
+ else:
+ methods_map = ['get']
+ for method in methods_map:
+ self.assertEqual(route.mapping[method], endpoint)
+
class RouterTestModel(models.Model):
uuid = models.CharField(max_length=20)