Repository: encode/django-rest-framework

Issue:
ViewSet extra action detection is too permissive.
## Checklist

- [x] I have verified that that issue exists against the `master` branch of Django REST framework.
- [x] I have searched for similar issues in both open and closed tickets and cannot find a duplicate.
- [x] This is not a usage question. (Those should be directed to the [discussion group](https://groups.google.com/forum/#!forum/django-rest-framework) instead.)
- [x] This cannot be dealt with as a third party library. (We prefer new functionality to be [in the form of third party libraries](https://www.django-rest-framework.org/community/third-party-packages/#about-third-party-packages) where possible.)
- [x] I have reduced the issue to the simplest possible case.
- [x] I have included a failing test as a pull request. (If you are unable to do so we can still accept the issue.)

## Steps to reproduce

Add any attribute to a ViewSet to which `hasattr(attr, 'mapping')` returns True. A case I got in a work project is that, because of a certain mock, one of the attributes of a viewset became a `MagicMock` instance. `hasattr(MagicMock(), 'mapping')` returns True (actually it returns True for **any** attribute you pass as the second parameter of `hasattr`), causing the same issue.

## Expected behavior

This attribute should not be accounted as an "action"

## Actual behavior

By itself, `get_extra_actions` will return any attribute with a `mapping` attribute. 

If the viewset is used with a router, the test suite fails to initialize because SimpleRouter expect `get_extra_actions` to only return methods:

```
tests/test_viewsets.py:102: in <module>
    url(r'^api/', include(router.urls)),
rest_framework/routers.py:78: in urls
    self._urls = self.get_urls()
rest_framework/routers.py:237: in get_urls
    routes = self.get_routes(viewset)
rest_framework/routers.py:156: in get_routes
    not_allowed = [
rest_framework/routers.py:158: in <listcomp>
    if action.__name__ in known_actions
E   AttributeError: 'ThingWithMapping' object has no attribute '__name__'
```


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
@@ -25,11 +25,12 @@
 from django.views.decorators.csrf import csrf_exempt
 
 from rest_framework import generics, mixins, views
+from rest_framework.decorators import MethodMapper
 from rest_framework.reverse import reverse
 
 
 def _is_extra_action(attr):
-    return hasattr(attr, 'mapping')
+    return hasattr(attr, 'mapping') and isinstance(attr.mapping, MethodMapper)
 
 
 class ViewSetMixin:


Test patch:
diff --git a/tests/test_viewsets.py b/tests/test_viewsets.py
--- a/tests/test_viewsets.py
+++ b/tests/test_viewsets.py
@@ -81,10 +81,20 @@ def suffixed_action(self, request, *args, **kwargs):
         raise NotImplementedError
 
 
+class ThingWithMapping:
+    def __init__(self):
+        self.mapping = {}
+
+
+class ActionViewSetWithMapping(ActionViewSet):
+    mapper = ThingWithMapping()
+
+
 router = SimpleRouter()
 router.register(r'actions', ActionViewSet)
 router.register(r'actions-alt', ActionViewSet, basename='actions-alt')
 router.register(r'names', ActionNamesViewSet, basename='names')
+router.register(r'mapping', ActionViewSetWithMapping, basename='mapping')
 
 
 urlpatterns = [
@@ -161,6 +171,18 @@ def test_extra_actions(self):
 
         self.assertEqual(actual, expected)
 
+    def test_should_only_return_decorated_methods(self):
+        view = ActionViewSetWithMapping()
+        actual = [action.__name__ for action in view.get_extra_actions()]
+        expected = [
+            'custom_detail_action',
+            'custom_list_action',
+            'detail_action',
+            'list_action',
+            'unresolvable_detail_action',
+        ]
+        self.assertEqual(actual, expected)
+
 
 @override_settings(ROOT_URLCONF='tests.test_viewsets')
 class GetExtraActionUrlMapTests(TestCase):