Repository: encode/django-rest-framework

Issue:
use list_route add multiple decorate => 405 error

## Steps to reproduce
I use list_route, add multiple decorate
```
def audit_log():
    def _log_record(func):
        def record(self, request, *args, **kwargs):
            print("enter$$$$$$$$$")
            res = func(self, request, *args, **kwargs)
            return res
        return record
    return _log_record


class HostInfoViewSet(ModelViewSet):

    @list_route(methods=['post'], url_path='valid')
    @audit_log()
    def get_valid_snooze(self, request, *args, **kwargs):
        print('@@@@@@@TEST')
        return Response("@@@@@@@TEST")

```
## Expected behavior
old version:
django==2.0.4
djangorestframework==3.7.*

![image](https://user-images.githubusercontent.com/8147810/71225389-74d9c100-2313-11ea-8974-7f7a77cff3e7.png)
![image](https://user-images.githubusercontent.com/8147810/71225405-86bb6400-2313-11ea-99ae-84774cd35e80.png)

## Actual behavior
new version:
Django==2.2.5
djangorestframework==3.9.4

occur 405 error
![image](https://user-images.githubusercontent.com/8147810/71225511-d568fe00-2313-11ea-8dde-b1a4b6cd271c.png)




Discussion:

Resolving patch:
diff --git a/rest_framework/viewsets.py b/rest_framework/viewsets.py
--- 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, (
+        'Expected function (`{func.__name__}`) to match its attribute name '
+        '(`{name}`). If using a decorator, ensure the inner function is '
+        'decorated with `functools.wraps`, or that `{func.__name__}.__name__` '
+        'is otherwise set to `{name}`.').format(func=func, name=name)
+    return func
+
+
 class ViewSetMixin:
     """
     This is the magic.
@@ -164,7 +173,9 @@ 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)]
+        return [_check_attr_name(method, name)
+                for name, method
+                in getmembers(cls, _is_extra_action)]
 
     def get_extra_action_url_map(self):
         """


Test patch:
diff --git a/tests/test_viewsets.py b/tests/test_viewsets.py
--- 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
@@ -33,6 +34,13 @@ class Action(models.Model):
     pass
 
 
+def decorate(fn):
+    @wraps(fn)
+    def wrapper(self, request, *args, **kwargs):
+        return fn(self, request, *args, **kwargs)
+    return wrapper
+
+
 class ActionViewSet(GenericViewSet):
     queryset = Action.objects.all()
 
@@ -68,6 +76,16 @@ def custom_detail_action(self, request, *args, **kwargs):
     def unresolvable_detail_action(self, request, *args, **kwargs):
         raise NotImplementedError
 
+    @action(detail=False)
+    @decorate
+    def wrapped_list_action(self, request, *args, **kwargs):
+        raise NotImplementedError
+
+    @action(detail=True)
+    @decorate
+    def wrapped_detail_action(self, request, *args, **kwargs):
+        raise NotImplementedError
+
 
 class ActionNamesViewSet(GenericViewSet):
 
@@ -191,6 +209,8 @@ def test_extra_actions(self):
             'detail_action',
             'list_action',
             'unresolvable_detail_action',
+            'wrapped_detail_action',
+            'wrapped_list_action',
         ]
 
         self.assertEqual(actual, expected)
@@ -204,9 +224,35 @@ def test_should_only_return_decorated_methods(self):
             '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):
@@ -218,6 +264,7 @@ def test_list_view(self):
         expected = OrderedDict([
             ('Custom list action', 'http://testserver/api/actions/custom_list_action/'),
             ('List action', 'http://testserver/api/actions/list_action/'),
+            ('Wrapped list action', 'http://testserver/api/actions/wrapped_list_action/'),
         ])
 
         self.assertEqual(view.get_extra_action_url_map(), expected)
@@ -229,6 +276,7 @@ def test_detail_view(self):
         expected = OrderedDict([
             ('Custom detail action', 'http://testserver/api/actions/1/custom_detail_action/'),
             ('Detail action', 'http://testserver/api/actions/1/detail_action/'),
+            ('Wrapped detail action', 'http://testserver/api/actions/1/wrapped_detail_action/'),
             # "Unresolvable detail action" excluded, since it's not resolvable
         ])