Repository: encode/django-rest-framework

Issue:
self.action is set to None during HEAD request
## 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](http://www.django-rest-framework.org/topics/third-party-resources/#about-third-party-packages) where possible.)
- [x] I have reduced the issue to the simplest possible case.

## Steps to reproduce
Send a HEAD request to a viewset which prints `self.action` in `get_queryset`:

```
class MyViewSet(viewsets.ModelViewSet):
    def get_queryset(self):
        print self.action
```

## Expected behavior
During a `HEAD` request this should, just like during `GET` request, print `list` or `retrieve` depending on the route requested.

## Actual behavior
`None` is printed


Discussion:
This is a side effect of:
1. [default head being set to get](https://github.com/encode/django-rest-framework/blob/b090ae9d30f26117f474479b853289a46c6ca615/rest_framework/viewsets.py#L109)
2. routers not mapping `head`

The `self.action` mapping is performed [here](https://github.com/encode/django-rest-framework/blob/b090ae9d30f26117f474479b853289a46c6ca615/rest_framework/viewsets.py#L145)
One fix seems to be to just add 'head' with the same value as 'get' to the mappings in SimpleRouter: https://github.com/encode/django-rest-framework/blob/master/rest_framework/routers.py#L123.

Not sure how it should be tested and if it creates some other problems though.
indeed. I don't see any side effect and it doesn't look like the mapping is tested.
We'll need to add a line about it in the release note though.
> One fix seems to be to just add 'head' with the same value as 'get' to the mappings in SimpleRouter: https://github.com/encode/django-rest-framework/blob/master/rest_framework/routers.py#L123.

One more action is needed to fix it for dynamic (detail) routes: add `head` [here](https://github.com/encode/django-rest-framework/blob/3.9.2/rest_framework/decorators.py#L135) or [here](https://github.com/encode/django-rest-framework/blob/3.9.2/rest_framework/routers.py#L232) something like this:
```python
    if 'get' in action.mapping and 'head' not in action.mapping:
        action.mapping['head'] = action.mapping['get']
```
It doesn't seem this was implemented yet, so I made these workarounds. 

```python3
class PatchedDefaultRouter(routers.DefaultRouter):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        for route in self.routes:
            if hasattr(route, 'mapping') and 'get' in route.mapping and 'head' not in route.mapping:
                route.mapping['head'] = route.mapping['get']

    def _get_dynamic_route(self, route, action):
        result = super()._get_dynamic_route(route, action)
        if 'get' in result.mapping and 'head' not in result.mapping:
            result.mapping['head'] = result.mapping['get']
        return result


class PatchedSimpleRouter(routers.SimpleRouter):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        for route in self.routes:
            if hasattr(route, 'mapping'):
                route.mapping['head'] = 'retrieve' if route.detail else 'list'
    
    def _get_dynamic_route(self, route, action):
        result = super()._get_dynamic_route(route, action)
        if 'get' in result.mapping and 'head' not in result.mapping:
            result.mapping['head'] = result.mapping['get']
        return result
```

Resolving patch:
diff --git a/rest_framework/viewsets.py b/rest_framework/viewsets.py
--- a/rest_framework/viewsets.py
+++ b/rest_framework/viewsets.py
@@ -93,6 +93,10 @@ def as_view(cls, actions=None, **initkwargs):
 
         def view(request, *args, **kwargs):
             self = cls(**initkwargs)
+
+            if 'get' in actions and 'head' not in actions:
+                actions['head'] = actions['get']
+
             # We also store the mapping of request methods to actions,
             # so that we can later set the action attribute.
             # eg. `self.action = 'list'` on an incoming GET request.
@@ -104,9 +108,6 @@ def view(request, *args, **kwargs):
                 handler = getattr(self, action)
                 setattr(self, method, handler)
 
-            if hasattr(self, 'get') and not hasattr(self, 'head'):
-                self.head = self.get
-
             self.request = request
             self.args = args
             self.kwargs = kwargs


Test patch:
diff --git a/tests/test_viewsets.py b/tests/test_viewsets.py
--- a/tests/test_viewsets.py
+++ b/tests/test_viewsets.py
@@ -37,14 +37,18 @@ class ActionViewSet(GenericViewSet):
     queryset = Action.objects.all()
 
     def list(self, request, *args, **kwargs):
-        return Response()
+        response = Response()
+        response.view = self
+        return response
 
     def retrieve(self, request, *args, **kwargs):
         return Response()
 
     @action(detail=False)
     def list_action(self, request, *args, **kwargs):
-        raise NotImplementedError
+        response = Response()
+        response.view = self
+        return response
 
     @action(detail=False, url_name='list-custom')
     def custom_list_action(self, request, *args, **kwargs):
@@ -155,6 +159,22 @@ def test_args_kwargs_request_action_map_on_self(self):
             self.assertNotIn(attribute, dir(bare_view))
             self.assertIn(attribute, dir(view))
 
+    def test_viewset_action_attr(self):
+        view = ActionViewSet.as_view(actions={'get': 'list'})
+
+        get = view(factory.get('/'))
+        head = view(factory.head('/'))
+        assert get.view.action == 'list'
+        assert head.view.action == 'list'
+
+    def test_viewset_action_attr_for_extra_action(self):
+        view = ActionViewSet.as_view(actions=dict(ActionViewSet.list_action.mapping))
+
+        get = view(factory.get('/'))
+        head = view(factory.head('/'))
+        assert get.view.action == 'list_action'
+        assert head.view.action == 'list_action'
+
 
 class GetExtraActionsTests(TestCase):