diff --git a/rest_framework/viewsets.py b/rest_framework/viewsets.py index 9cb48729e..39fb5a5ff 100644 --- a/rest_framework/viewsets.py +++ b/rest_framework/viewsets.py @@ -33,6 +33,15 @@ def _is_extra_action(attr): return hasattr(attr, 'mapping') and isinstance(attr.mapping, MethodMapper) +def _check_attr_name(func, name): + assert func.__name__ == name, ( + f'Expected function (`{func.__name__}`) to match its attribute name ' + f'(`{name}`). If using a decorator, ensure the inner function is ' + f'decorated with `functools.wraps`, or that `{func.__name__}.__name__` ' + f'is otherwise set to `{name}`.') + return func + + class ViewSetMixin: """ This is the magic. @@ -164,7 +173,9 @@ class ViewSetMixin: """ Get the methods that are marked as an extra ViewSet `@action`. """ - return [method for _, method in getmembers(cls, _is_extra_action)] + return [_check_attr_name(method, name) + for name, method + in getmembers(cls, _is_extra_action)] def get_extra_action_url_map(self): """ diff --git a/tests/test_viewsets.py b/tests/test_viewsets.py index 0488f6173..2a2997a0b 100644 --- a/tests/test_viewsets.py +++ b/tests/test_viewsets.py @@ -1,4 +1,5 @@ from collections import OrderedDict +from functools import wraps import pytest from django.conf.urls import include, url @@ -34,6 +35,7 @@ class Action(models.Model): def decorate(fn): + @wraps(fn) def wrapper(self, request, *args, **kwargs): return fn(self, request, *args, **kwargs) return wrapper @@ -222,9 +224,35 @@ class GetExtraActionsTests(TestCase): 'detail_action', 'list_action', 'unresolvable_detail_action', + 'wrapped_detail_action', + 'wrapped_list_action', ] self.assertEqual(actual, expected) + def test_attr_name_check(self): + def decorate(fn): + def wrapper(self, request, *args, **kwargs): + return fn(self, request, *args, **kwargs) + return wrapper + + class ActionViewSet(GenericViewSet): + queryset = Action.objects.all() + + @action(detail=False) + @decorate + def wrapped_list_action(self, request, *args, **kwargs): + raise NotImplementedError + + view = ActionViewSet() + with pytest.raises(AssertionError) as excinfo: + view.get_extra_actions() + + assert str(excinfo.value) == ( + 'Expected function (`wrapper`) to match its attribute name ' + '(`wrapped_list_action`). If using a decorator, ensure the inner ' + 'function is decorated with `functools.wraps`, or that ' + '`wrapper.__name__` is otherwise set to `wrapped_list_action`.') + @override_settings(ROOT_URLCONF='tests.test_viewsets') class GetExtraActionUrlMapTests(TestCase):