From f79cd591fac5c1dcf8cd59ac93d3c8bff8632d98 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 22 Dec 2017 01:18:00 -0500 Subject: [PATCH 01/13] Merge list/detail route decorators into 'action' --- rest_framework/decorators.py | 36 +++++++++++++++++++++--------- tests/test_decorators.py | 43 ++++++++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/rest_framework/decorators.py b/rest_framework/decorators.py index 2f93fdd976..41a1b1c521 100644 --- a/rest_framework/decorators.py +++ b/rest_framework/decorators.py @@ -130,29 +130,43 @@ def decorator(func): return decorator -def detail_route(methods=None, **kwargs): +def action(methods=None, detail=True, **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] def decorator(func): func.bind_to_methods = methods - func.detail = True + func.detail = detail 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/tests/test_decorators.py b/tests/test_decorators.py index 6331742db2..a41bf0da38 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,40 @@ def view(request): return Response({}) assert isinstance(view.cls.schema, CustomSchema) + + +class ActionDecoratorTestCase(TestCase): + + def test_defaults(self): + @action() + def test_action(request): + pass + + assert test_action.bind_to_methods == ['get'] + assert test_action.detail is True + + 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." + ) From e090ba2dd9a1c271d669415dbb1d6b7466ae35f5 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 22 Dec 2017 01:49:31 -0500 Subject: [PATCH 02/13] Merge dynamic routes, add 'detail' attribute --- rest_framework/routers.py | 50 +++++++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/rest_framework/routers.py b/rest_framework/routers.py index f4d2fab383..291b4e0642 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): @@ -103,14 +125,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( + # Dynamically generated list routes. Generated using + # @action(detail=False) decorator on methods of the viewset. + DynamicRoute( url=r'^{prefix}/{methodname}{trailing_slash}$', name='{basename}-{methodnamehyphen}', + detail=False, initkwargs={} ), # Detail route. @@ -123,13 +146,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( + # Dynamically generated detail routes. Generated using + # @action(detail=True) decorator on methods of the viewset. + DynamicRoute( url=r'^{prefix}/{lookup}/{methodname}{trailing_slash}$', name='{basename}-{methodnamehyphen}', + detail=True, initkwargs={} ), ] @@ -193,6 +218,7 @@ def _get_dynamic_routes(route, dynamic_routes): url=replace_methodname(route.url, url_path), mapping={httpmethod: methodname for httpmethod in httpmethods}, name=replace_methodname(route.name, url_name), + detail=route.detail, initkwargs=initkwargs, )) @@ -200,10 +226,10 @@ def _get_dynamic_routes(route, dynamic_routes): ret = [] for route in self.routes: - if isinstance(route, DynamicDetailRoute): + if isinstance(route, DynamicRoute) and route.detail: # Dynamic detail routes (@detail_route decorator) ret += _get_dynamic_routes(route, detail_routes) - elif isinstance(route, DynamicListRoute): + elif isinstance(route, DynamicRoute) and not route.detail: # Dynamic list routes (@list_route decorator) ret += _get_dynamic_routes(route, list_routes) else: From 72b73790ddd8b4af06868b34bf5b5e00b53854aa Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 22 Dec 2017 02:12:40 -0500 Subject: [PATCH 03/13] Add 'ViewSet.get_extra_actions()' --- rest_framework/viewsets.py | 15 +++++++++++++-- tests/test_viewsets.py | 10 ++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/rest_framework/viewsets.py b/rest_framework/viewsets.py index 4ee7cdaf86..164347f934 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. @@ -112,8 +117,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 +139,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_viewsets.py b/tests/test_viewsets.py index beff42cb89..bebe6ac9c5 100644 --- a/tests/test_viewsets.py +++ b/tests/test_viewsets.py @@ -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): From c2476fc0358f4c9978d30058a8a54cdd1f30207f Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 22 Dec 2017 02:24:39 -0500 Subject: [PATCH 04/13] Refactor dynamic route checking & collection --- rest_framework/routers.py | 47 +++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 291b4e0642..f6b5b2ad3d 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -185,25 +185,21 @@ 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)) + 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] def _get_dynamic_routes(route, dynamic_routes): ret = [] @@ -224,19 +220,16 @@ def _get_dynamic_routes(route, dynamic_routes): return ret - ret = [] + routes = [] for route in self.routes: if isinstance(route, DynamicRoute) and route.detail: - # Dynamic detail routes (@detail_route decorator) - ret += _get_dynamic_routes(route, detail_routes) + routes += _get_dynamic_routes(route, detail_actions) elif isinstance(route, DynamicRoute) and not route.detail: - # Dynamic list routes (@list_route decorator) - ret += _get_dynamic_routes(route, list_routes) + routes += _get_dynamic_routes(route, list_actions) else: - # Standard route - ret.append(route) + routes.append(route) - return ret + return routes def get_method_map(self, viewset, method_map): """ From 034f8d0283fd07ec925e58b85036b29acc12a31e Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 22 Dec 2017 03:00:08 -0500 Subject: [PATCH 05/13] Refactor dynamic route generation --- rest_framework/decorators.py | 4 ++- rest_framework/routers.py | 58 +++++++++++++----------------------- tests/test_decorators.py | 2 ++ 3 files changed, 26 insertions(+), 38 deletions(-) diff --git a/rest_framework/decorators.py b/rest_framework/decorators.py index 41a1b1c521..3020edc16b 100644 --- a/rest_framework/decorators.py +++ b/rest_framework/decorators.py @@ -130,7 +130,7 @@ def decorator(func): return decorator -def action(methods=None, detail=True, **kwargs): +def action(methods=None, detail=True, url_path=None, url_name=None, **kwargs): """ Mark a ViewSet method as a routable action. @@ -143,6 +143,8 @@ def action(methods=None, detail=True, **kwargs): 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 diff --git a/rest_framework/routers.py b/rest_framework/routers.py index f6b5b2ad3d..9b729712c9 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -66,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 @@ -131,8 +119,8 @@ class SimpleRouter(BaseRouter): # Dynamically generated list routes. Generated using # @action(detail=False) decorator on methods of the viewset. DynamicRoute( - url=r'^{prefix}/{methodname}{trailing_slash}$', - name='{basename}-{methodnamehyphen}', + url=r'^{prefix}/{url_path}{trailing_slash}$', + name='{basename}-{url_name}', detail=False, initkwargs={} ), @@ -152,8 +140,8 @@ class SimpleRouter(BaseRouter): # Dynamically generated detail routes. Generated using # @action(detail=True) decorator on methods of the viewset. DynamicRoute( - url=r'^{prefix}/{lookup}/{methodname}{trailing_slash}$', - name='{basename}-{methodnamehyphen}', + url=r'^{prefix}/{lookup}/{url_path}{trailing_slash}$', + name='{basename}-{url_name}', detail=True, initkwargs={} ), @@ -201,36 +189,32 @@ def get_routes(self, viewset): detail_actions = [action for action in extra_actions if action.detail] list_actions = [action for action in extra_actions if not action.detail] - 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), - detail=route.detail, - initkwargs=initkwargs, - )) - - return ret - routes = [] for route in self.routes: if isinstance(route, DynamicRoute) and route.detail: - routes += _get_dynamic_routes(route, detail_actions) + routes += [self._get_dynamic_route(route, action) for action in detail_actions] elif isinstance(route, DynamicRoute) and not route.detail: - routes += _get_dynamic_routes(route, list_actions) + routes += [self._get_dynamic_route(route, action) for action in list_actions] else: routes.append(route) 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): """ Given a viewset, and a mapping of http methods to actions, diff --git a/tests/test_decorators.py b/tests/test_decorators.py index a41bf0da38..a86cb5a3ea 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -179,6 +179,8 @@ def test_action(request): 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_route_deprecation(self): with pytest.warns(PendingDeprecationWarning) as record: From fed76160139ae58c006dd6948595a2f5b7db5198 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 22 Dec 2017 03:51:37 -0500 Subject: [PATCH 06/13] Add 'ViewSet.detail' initkwarg --- rest_framework/routers.py | 1 + rest_framework/viewsets.py | 3 +++ tests/test_routers.py | 6 ++++++ 3 files changed, 10 insertions(+) diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 9b729712c9..9007788f85 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -284,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 164347f934..9a85049bcc 100644 --- a/rest_framework/viewsets.py +++ b/rest_framework/viewsets.py @@ -56,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 diff --git a/tests/test_routers.py b/tests/test_routers.py index 5a1cfe8f40..bc72e55d75 100644 --- a/tests/test_routers.py +++ b/tests/test_routers.py @@ -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 From 23447b0486cc60e0ef18f86596c75d4a74bc80f5 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 22 Dec 2017 12:39:51 -0500 Subject: [PATCH 07/13] Fixup schema test --- tests/test_schemas.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_schemas.py b/tests/test_schemas.py index 34cb20798a..052a1ee950 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -949,7 +949,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 +962,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 ) } ) From a591e16727aa6b8e1906f7d721b04c4d708f325f Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 22 Dec 2017 15:34:21 -0500 Subject: [PATCH 08/13] Add release notes for dynamic action changes --- docs/topics/release-notes.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) 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 From 151c0c94fd854e36f03790daf8785b96d9bf7cde Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Wed, 3 Jan 2018 18:46:29 -0500 Subject: [PATCH 09/13] Replace list/detail route decorators in tests --- tests/test_routers.py | 34 +++++++++++++++++----------------- tests/test_schemas.py | 18 ++++++++---------- tests/test_viewsets.py | 10 +++++----- 3 files changed, 30 insertions(+), 32 deletions(-) diff --git a/tests/test_routers.py b/tests/test_routers.py index bc72e55d75..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'}) diff --git a/tests/test_schemas.py b/tests/test_schemas.py index 052a1ee950..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 {} @@ -1049,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 bebe6ac9c5..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 From b69efe054e74871a3876b37b7d9802c4808ecd95 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Thu, 4 Jan 2018 02:55:30 -0500 Subject: [PATCH 10/13] Convert tabs to spaces in router docs --- docs/api-guide/routers.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/api-guide/routers.md b/docs/api-guide/routers.md index 84ca82a3f7..0466542d84 100644 --- a/docs/api-guide/routers.md +++ b/docs/api-guide/routers.md @@ -239,22 +239,22 @@ 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={} - ) + url=r'^{prefix}/{lookup}/{methodnamehyphen}$', + name='{basename}-{methodnamehyphen}', + initkwargs={} + ) ] Let's take a look at the routes our `CustomReadOnlyRouter` would generate for a simple viewset. @@ -283,7 +283,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... From 2112a58282877c7f31015d2c307685762cd0bac4 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Thu, 4 Jan 2018 03:20:43 -0500 Subject: [PATCH 11/13] Update docs --- docs/api-guide/metadata.md | 2 +- docs/api-guide/routers.md | 78 ++++++++++--------------- docs/api-guide/viewsets.md | 46 +++++++++------ docs/tutorial/6-viewsets-and-routers.md | 10 ++-- 4 files changed, 66 insertions(+), 70 deletions(-) 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 0466542d84..0cbc0d07a2 100644 --- a/docs/api-guide/routers.md +++ b/docs/api-guide/routers.md @@ -81,62 +81,45 @@ 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: +Any method on the viewset decorated with `@action` will be included in the generated routes. For example, given a method like this 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 default URL or default name, you can instead pass 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. +* URL path: `^users/{pk}/change-password/$` +* URL name: `'user-change_password'` For more information see the viewset documentation on [marking extra actions for routing][route-decorators]. @@ -144,18 +127,18 @@ For more information see the viewset documentation on [marking extra actions for ## 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 StyleHTTP MethodActionURL Name
{prefix}/GETlist{basename}-list
POSTcreate
{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}/GETretrieve{basename}-detail
PUTupdate
PATCHpartial_update
DELETEdestroy
{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 +163,12 @@ This router is similar to `SimpleRouter` as above, but additionally includes a d [.format]GETautomatically generated root viewapi-root {prefix}/[.format]GETlist{basename}-list POSTcreate - {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]GETretrieve{basename}-detail PUTupdate PATCHpartial_update DELETEdestroy - {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 +195,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 +214,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): """ @@ -250,9 +233,10 @@ The following example will only route to the `list` and `retrieve` actions, and name='{basename}-detail', initkwargs={'suffix': 'Detail'} ), - DynamicDetailRoute( - url=r'^{prefix}/{lookup}/{methodnamehyphen}$', - name='{basename}-{methodnamehyphen}', + DynamicRoute( + url=r'^{prefix}/{lookup}/{url_path}$', + name='{basename}-{url_name}', + detail=True, initkwargs={} ) ] @@ -269,7 +253,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 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/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 From 40ab128b1aae2eea504960d6eb01d66620a9a4c5 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Mon, 8 Jan 2018 07:30:20 -0500 Subject: [PATCH 12/13] Make 'detail' a required argument of 'action' --- rest_framework/decorators.py | 6 +++++- tests/test_decorators.py | 10 +++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/rest_framework/decorators.py b/rest_framework/decorators.py index 3020edc16b..62afa05979 100644 --- a/rest_framework/decorators.py +++ b/rest_framework/decorators.py @@ -130,7 +130,7 @@ def decorator(func): return decorator -def action(methods=None, detail=True, url_path=None, url_name=None, **kwargs): +def action(methods=None, detail=None, url_path=None, url_name=None, **kwargs): """ Mark a ViewSet method as a routable action. @@ -140,6 +140,10 @@ def action(methods=None, detail=True, url_path=None, url_name=None, **kwargs): 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 diff --git a/tests/test_decorators.py b/tests/test_decorators.py index a86cb5a3ea..674990730c 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -173,7 +173,7 @@ def view(request): class ActionDecoratorTestCase(TestCase): def test_defaults(self): - @action() + @action(detail=True) def test_action(request): pass @@ -182,6 +182,14 @@ def test_action(request): 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() From 4f6190ddcd3f78faf03c10aea71ddfa1e8263f45 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Mon, 8 Jan 2018 07:47:25 -0500 Subject: [PATCH 13/13] Improve router docs --- docs/api-guide/routers.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/api-guide/routers.md b/docs/api-guide/routers.md index 0cbc0d07a2..905fb5e2da 100644 --- a/docs/api-guide/routers.md +++ b/docs/api-guide/routers.md @@ -83,7 +83,7 @@ If using namespacing with hyperlinked serializers you'll also need to ensure tha ### Routing for extra actions -Any method on the viewset decorated with `@action` will be included in the generated routes. 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 action @@ -101,7 +101,7 @@ The following route would be generated: * URL name: `'user-set-password'` 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 default URL or default name, you can instead pass the `url_path` and `url_name` arguments to the `@action` decorator. +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: @@ -121,8 +121,6 @@ The above example would now generate the following URL pattern: * URL path: `^users/{pk}/change-password/$` * URL name: `'user-change_password'` -For more information see the viewset documentation on [marking extra actions for routing][route-decorators]. - # API Guide ## SimpleRouter 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