Skip to content

Commit 3c60b4b

Browse files
author
Ryan P Kilby
committed
Add 'extra actions' to ViewSet & browsable APIs
1 parent d0af8e8 commit 3c60b4b

File tree

6 files changed

+133
-6
lines changed

6 files changed

+133
-6
lines changed

rest_framework/renderers.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,11 @@ def get_description(self, view, status_code):
612612
def get_breadcrumbs(self, request):
613613
return get_breadcrumbs(request.path, request)
614614

615+
def get_extra_actions(self, view):
616+
if hasattr(view, 'get_extra_action_url_map'):
617+
return view.get_extra_action_url_map()
618+
return None
619+
615620
def get_filter_form(self, data, view, request):
616621
if not hasattr(view, 'get_queryset') or not hasattr(view, 'filter_backends'):
617622
return
@@ -698,6 +703,8 @@ def get_context(self, data, accepted_media_type, renderer_context):
698703
'delete_form': self.get_rendered_html_form(data, view, 'DELETE', request),
699704
'options_form': self.get_rendered_html_form(data, view, 'OPTIONS', request),
700705

706+
'extra_actions': self.get_extra_actions(view),
707+
701708
'filter_form': self.get_filter_form(data, view, request),
702709

703710
'raw_data_put_form': raw_data_put_form,

rest_framework/templates/rest_framework/admin.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,20 @@
110110
</form>
111111
{% endif %}
112112

113+
{% if extra_actions %}
114+
<div class="dropdown" style="float: right; margin-right: 10px">
115+
<button class="btn btn-default" id="extra-actions-menu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
116+
{% trans "Extra Actions" %}
117+
<span class="caret"></span>
118+
</button>
119+
<ul class="dropdown-menu" aria-labelledby="extra-actions-menu">
120+
{% for action_name, url in extra_actions|items %}
121+
<li><a href="{{ url }}">{{ action_name }}</a></li>
122+
{% endfor %}
123+
</ul>
124+
</div>
125+
{% endif %}
126+
113127
{% if filter_form %}
114128
<button style="float: right; margin-right: 10px" data-toggle="modal" data-target="#filtersModal" class="btn btn-default">
115129
<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span>

rest_framework/templates/rest_framework/base.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,20 @@ <h4 class="text-center">Are you sure you want to delete this {{ name }}?</h4>
128128
</div>
129129
{% endif %}
130130

131+
{% if extra_actions %}
132+
<div class="dropdown" style="float: right; margin-right: 10px">
133+
<button class="btn btn-default" id="extra-actions-menu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
134+
{% trans "Extra Actions" %}
135+
<span class="caret"></span>
136+
</button>
137+
<ul class="dropdown-menu" aria-labelledby="extra-actions-menu">
138+
{% for action_name, url in extra_actions|items %}
139+
<li><a href="{{ url }}">{{ action_name }}</a></li>
140+
{% endfor %}
141+
</ul>
142+
</div>
143+
{% endif %}
144+
131145
{% if filter_form %}
132146
<button style="float: right; margin-right: 10px" data-toggle="modal" data-target="#filtersModal" class="btn btn-default">
133147
<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span>

rest_framework/viewsets.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@
1818
"""
1919
from __future__ import unicode_literals
2020

21+
from collections import OrderedDict
2122
from functools import update_wrapper
2223
from inspect import getmembers
2324

25+
from django.urls import NoReverseMatch
2426
from django.utils.decorators import classonlymethod
2527
from django.views.decorators.csrf import csrf_exempt
2628

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

164+
def get_extra_action_url_map(self):
165+
"""
166+
Build a map of {names: urls} for the extra actions.
167+
168+
This method will noop if `detail` was not provided as a view initkwarg.
169+
"""
170+
action_urls = OrderedDict()
171+
172+
# exit early if `detail` has not been provided
173+
if self.detail is None:
174+
return action_urls
175+
176+
# filter for the relevant extra actions
177+
actions = [
178+
action for action in self.get_extra_actions()
179+
if action.detail == self.detail
180+
]
181+
182+
for action in actions:
183+
try:
184+
url_name = '%s-%s' % (self.basename, action.url_name)
185+
url = reverse(url_name, self.args, self.kwargs, request=self.request)
186+
action_urls[action.name] = url
187+
except NoReverseMatch:
188+
pass # URL requires additional arguments, ignore
189+
190+
return action_urls
191+
162192

163193
class ViewSet(ViewSetMixin, views.APIView):
164194
"""

tests/test_renderers.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,19 @@
1616

1717
from rest_framework import permissions, serializers, status
1818
from rest_framework.compat import coreapi
19+
from rest_framework.decorators import action
1920
from rest_framework.renderers import (
2021
AdminRenderer, BaseRenderer, BrowsableAPIRenderer, DocumentationRenderer,
2122
HTMLFormRenderer, JSONRenderer, SchemaJSRenderer, StaticHTMLRenderer
2223
)
2324
from rest_framework.request import Request
2425
from rest_framework.response import Response
26+
from rest_framework.routers import SimpleRouter
2527
from rest_framework.settings import api_settings
26-
from rest_framework.test import APIRequestFactory
28+
from rest_framework.test import APIRequestFactory, URLPatternsTestCase
2729
from rest_framework.utils import json
2830
from rest_framework.views import APIView
31+
from rest_framework.viewsets import ViewSet
2932

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

624627

625-
class BrowsableAPIRendererTests(TestCase):
628+
class BrowsableAPIRendererTests(URLPatternsTestCase):
629+
class ExampleViewSet(ViewSet):
630+
def list(self, request):
631+
return Response()
632+
633+
@action(detail=False, name="Extra list action")
634+
def list_action(self, request):
635+
raise NotImplementedError
636+
637+
router = SimpleRouter()
638+
router.register('examples', ExampleViewSet, base_name='example')
639+
urlpatterns = [url(r'^api/', include(router.urls))]
626640

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

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

644664
class AdminRendererTests(TestCase):
645665

tests/test_viewsets.py

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from collections import OrderedDict
2+
13
import pytest
24
from django.conf.urls import include, url
35
from django.db import models
@@ -35,10 +37,10 @@ class ActionViewSet(GenericViewSet):
3537
queryset = Action.objects.all()
3638

3739
def list(self, request, *args, **kwargs):
38-
raise NotImplementedError
40+
return Response()
3941

4042
def retrieve(self, request, *args, **kwargs):
41-
raise NotImplementedError
43+
return Response()
4244

4345
@action(detail=False)
4446
def list_action(self, request, *args, **kwargs):
@@ -56,6 +58,10 @@ def detail_action(self, request, *args, **kwargs):
5658
def custom_detail_action(self, request, *args, **kwargs):
5759
raise NotImplementedError
5860

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

6066
router = SimpleRouter()
6167
router.register(r'actions', ActionViewSet)
@@ -121,16 +127,52 @@ def test_args_kwargs_request_action_map_on_self(self):
121127
self.assertIn(attribute, dir(view))
122128

123129

124-
class GetExtraActionTests(TestCase):
130+
class GetExtraActionsTests(TestCase):
125131

126132
def test_extra_actions(self):
127133
view = ActionViewSet()
128134
actual = [action.__name__ for action in view.get_extra_actions()]
129-
expected = ['custom_detail_action', 'custom_list_action', 'detail_action', 'list_action']
135+
expected = [
136+
'custom_detail_action',
137+
'custom_list_action',
138+
'detail_action',
139+
'list_action',
140+
'unresolvable_detail_action',
141+
]
130142

131143
self.assertEqual(actual, expected)
132144

133145

146+
@override_settings(ROOT_URLCONF='tests.test_viewsets')
147+
class GetExtraActionUrlMapTests(TestCase):
148+
149+
def test_list_view(self):
150+
response = self.client.get('/api/actions/')
151+
view = response.renderer_context['view']
152+
153+
expected = OrderedDict([
154+
('Custom list action', 'http://testserver/api/actions/custom_list_action/'),
155+
('List action', 'http://testserver/api/actions/list_action/'),
156+
])
157+
158+
self.assertEqual(view.get_extra_action_url_map(), expected)
159+
160+
def test_detail_view(self):
161+
response = self.client.get('/api/actions/1/')
162+
view = response.renderer_context['view']
163+
164+
expected = OrderedDict([
165+
('Custom detail action', 'http://testserver/api/actions/1/custom_detail_action/'),
166+
('Detail action', 'http://testserver/api/actions/1/detail_action/'),
167+
# "Unresolvable detail action" excluded, since it's not resolvable
168+
])
169+
170+
self.assertEqual(view.get_extra_action_url_map(), expected)
171+
172+
def test_uninitialized_view(self):
173+
self.assertEqual(ActionViewSet().get_extra_action_url_map(), OrderedDict())
174+
175+
134176
@override_settings(ROOT_URLCONF='tests.test_viewsets')
135177
class ReverseActionTests(TestCase):
136178
def test_default_basename(self):

0 commit comments

Comments
 (0)