Skip to content

Commit

Permalink
Add 'extra actions' to ViewSet & browsable APIs
Browse files Browse the repository at this point in the history
  • Loading branch information
Ryan P Kilby committed Jun 25, 2018
1 parent d0af8e8 commit 3c60b4b
Show file tree
Hide file tree
Showing 6 changed files with 133 additions and 6 deletions.
7 changes: 7 additions & 0 deletions rest_framework/renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,11 @@ def get_description(self, view, status_code):
def get_breadcrumbs(self, request):
return get_breadcrumbs(request.path, request)

def get_extra_actions(self, view):
if hasattr(view, 'get_extra_action_url_map'):
return view.get_extra_action_url_map()
return None

def get_filter_form(self, data, view, request):
if not hasattr(view, 'get_queryset') or not hasattr(view, 'filter_backends'):
return
Expand Down Expand Up @@ -698,6 +703,8 @@ def get_context(self, data, accepted_media_type, renderer_context):
'delete_form': self.get_rendered_html_form(data, view, 'DELETE', request),
'options_form': self.get_rendered_html_form(data, view, 'OPTIONS', request),

'extra_actions': self.get_extra_actions(view),

'filter_form': self.get_filter_form(data, view, request),

'raw_data_put_form': raw_data_put_form,
Expand Down
14 changes: 14 additions & 0 deletions rest_framework/templates/rest_framework/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,20 @@
</form>
{% endif %}

{% if extra_actions %}
<div class="dropdown" style="float: right; margin-right: 10px">
<button class="btn btn-default" id="extra-actions-menu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
{% trans "Extra Actions" %}
<span class="caret"></span>
</button>
<ul class="dropdown-menu" aria-labelledby="extra-actions-menu">
{% for action_name, url in extra_actions|items %}
<li><a href="{{ url }}">{{ action_name }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}

{% if filter_form %}
<button style="float: right; margin-right: 10px" data-toggle="modal" data-target="#filtersModal" class="btn btn-default">
<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span>
Expand Down
14 changes: 14 additions & 0 deletions rest_framework/templates/rest_framework/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,20 @@ <h4 class="text-center">Are you sure you want to delete this {{ name }}?</h4>
</div>
{% endif %}

{% if extra_actions %}
<div class="dropdown" style="float: right; margin-right: 10px">
<button class="btn btn-default" id="extra-actions-menu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
{% trans "Extra Actions" %}
<span class="caret"></span>
</button>
<ul class="dropdown-menu" aria-labelledby="extra-actions-menu">
{% for action_name, url in extra_actions|items %}
<li><a href="{{ url }}">{{ action_name }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}

{% if filter_form %}
<button style="float: right; margin-right: 10px" data-toggle="modal" data-target="#filtersModal" class="btn btn-default">
<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span>
Expand Down
30 changes: 30 additions & 0 deletions rest_framework/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@
"""
from __future__ import unicode_literals

from collections import OrderedDict
from functools import update_wrapper
from inspect import getmembers

from django.urls import NoReverseMatch
from django.utils.decorators import classonlymethod
from django.views.decorators.csrf import csrf_exempt

Expand Down Expand Up @@ -159,6 +161,34 @@ def get_extra_actions(cls):
"""
return [method for _, method in getmembers(cls, _is_extra_action)]

def get_extra_action_url_map(self):
"""
Build a map of {names: urls} for the extra actions.
This method will noop if `detail` was not provided as a view initkwarg.
"""
action_urls = OrderedDict()

# exit early if `detail` has not been provided
if self.detail is None:
return action_urls

# filter for the relevant extra actions
actions = [
action for action in self.get_extra_actions()
if action.detail == self.detail
]

for action in actions:
try:
url_name = '%s-%s' % (self.basename, action.url_name)
url = reverse(url_name, self.args, self.kwargs, request=self.request)
action_urls[action.name] = url
except NoReverseMatch:
pass # URL requires additional arguments, ignore

return action_urls


class ViewSet(ViewSetMixin, views.APIView):
"""
Expand Down
24 changes: 22 additions & 2 deletions tests/test_renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,19 @@

from rest_framework import permissions, serializers, status
from rest_framework.compat import coreapi
from rest_framework.decorators import action
from rest_framework.renderers import (
AdminRenderer, BaseRenderer, BrowsableAPIRenderer, DocumentationRenderer,
HTMLFormRenderer, JSONRenderer, SchemaJSRenderer, StaticHTMLRenderer
)
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.routers import SimpleRouter
from rest_framework.settings import api_settings
from rest_framework.test import APIRequestFactory
from rest_framework.test import APIRequestFactory, URLPatternsTestCase
from rest_framework.utils import json
from rest_framework.views import APIView
from rest_framework.viewsets import ViewSet

DUMMYSTATUS = status.HTTP_200_OK
DUMMYCONTENT = 'dummycontent'
Expand Down Expand Up @@ -622,7 +625,18 @@ def test_static_renderer_with_exception(self):
assert result == '500 Internal Server Error'


class BrowsableAPIRendererTests(TestCase):
class BrowsableAPIRendererTests(URLPatternsTestCase):
class ExampleViewSet(ViewSet):
def list(self, request):
return Response()

@action(detail=False, name="Extra list action")
def list_action(self, request):
raise NotImplementedError

router = SimpleRouter()
router.register('examples', ExampleViewSet, base_name='example')
urlpatterns = [url(r'^api/', include(router.urls))]

def setUp(self):
self.renderer = BrowsableAPIRenderer()
Expand All @@ -640,6 +654,12 @@ class DummyView(object):
view=DummyView(), request={})
assert result is None

def test_extra_actions_dropdown(self):
resp = self.client.get('/api/examples/', HTTP_ACCEPT='text/html')
assert 'id="extra-actions-menu"' in resp.content.decode('utf-8')
assert '/api/examples/list_action/' in resp.content.decode('utf-8')
assert '>Extra list action<' in resp.content.decode('utf-8')


class AdminRendererTests(TestCase):

Expand Down
50 changes: 46 additions & 4 deletions tests/test_viewsets.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from collections import OrderedDict

import pytest
from django.conf.urls import include, url
from django.db import models
Expand Down Expand Up @@ -35,10 +37,10 @@ class ActionViewSet(GenericViewSet):
queryset = Action.objects.all()

def list(self, request, *args, **kwargs):
raise NotImplementedError
return Response()

def retrieve(self, request, *args, **kwargs):
raise NotImplementedError
return Response()

@action(detail=False)
def list_action(self, request, *args, **kwargs):
Expand All @@ -56,6 +58,10 @@ def detail_action(self, request, *args, **kwargs):
def custom_detail_action(self, request, *args, **kwargs):
raise NotImplementedError

@action(detail=True, url_path=r'unresolvable/(?P<arg>\w+)', url_name='unresolvable')
def unresolvable_detail_action(self, request, *args, **kwargs):
raise NotImplementedError


router = SimpleRouter()
router.register(r'actions', ActionViewSet)
Expand Down Expand Up @@ -121,16 +127,52 @@ def test_args_kwargs_request_action_map_on_self(self):
self.assertIn(attribute, dir(view))


class GetExtraActionTests(TestCase):
class GetExtraActionsTests(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']
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):

def test_list_view(self):
response = self.client.get('/api/actions/')
view = response.renderer_context['view']

expected = OrderedDict([
('Custom list action', 'http://testserver/api/actions/custom_list_action/'),
('List action', 'http://testserver/api/actions/list_action/'),
])

self.assertEqual(view.get_extra_action_url_map(), expected)

def test_detail_view(self):
response = self.client.get('/api/actions/1/')
view = response.renderer_context['view']

expected = OrderedDict([
('Custom detail action', 'http://testserver/api/actions/1/custom_detail_action/'),
('Detail action', 'http://testserver/api/actions/1/detail_action/'),
# "Unresolvable detail action" excluded, since it's not resolvable
])

self.assertEqual(view.get_extra_action_url_map(), expected)

def test_uninitialized_view(self):
self.assertEqual(ActionViewSet().get_extra_action_url_map(), OrderedDict())


@override_settings(ROOT_URLCONF='tests.test_viewsets')
class ReverseActionTests(TestCase):
def test_default_basename(self):
Expand Down

0 comments on commit 3c60b4b

Please sign in to comment.