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. + + @@ -87,6 +99,8 @@ This router is similar to `SimpleRouter` as above, but additionally includes a d + + 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)
URL StyleHTTP MethodActionURL Name
{prefix}/GETlist{basename}-list
POSTcreate
{prefix}/{methodname}/[.format]GET@global_link decorated method{basename}-global-{methodname}
POST@global_action decorated method
{prefix}/{lookup}/GETretrieve{basename}-detail
PUTupdate
PATCHpartial_update
[.format]GETautomatically generated root viewapi-root
{prefix}/[.format]GETlist{basename}-list
POSTcreate
{prefix}/{methodname}/[.format]GET@global_link decorated method{basename}-global-{methodname}
POST@global_action decorated method
{prefix}/{lookup}/[.format]GETretrieve{basename}-detail
PUTupdate
PATCHpartial_update