mirror of
https://github.com/encode/django-rest-framework.git
synced 2025-08-02 11:30:12 +03:00
Add 'extra actions' to ViewSet & browsable APIs
This commit is contained in:
parent
d0af8e8723
commit
3c60b4b7b6
|
@ -612,6 +612,11 @@ class BrowsableAPIRenderer(BaseRenderer):
|
|||
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
|
||||
|
@ -698,6 +703,8 @@ class BrowsableAPIRenderer(BaseRenderer):
|
|||
'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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -128,6 +128,20 @@
|
|||
</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>
|
||||
|
|
|
@ -18,9 +18,11 @@ automatically.
|
|||
"""
|
||||
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
|
||||
|
||||
|
@ -159,6 +161,34 @@ class ViewSetMixin(object):
|
|||
"""
|
||||
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):
|
||||
"""
|
||||
|
|
|
@ -16,16 +16,19 @@ from django.utils.translation import ugettext_lazy as _
|
|||
|
||||
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'
|
||||
|
@ -622,7 +625,18 @@ class StaticHTMLRendererTests(TestCase):
|
|||
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()
|
||||
|
@ -640,6 +654,12 @@ class BrowsableAPIRendererTests(TestCase):
|
|||
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):
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
from collections import OrderedDict
|
||||
|
||||
import pytest
|
||||
from django.conf.urls import include, url
|
||||
from django.db import models
|
||||
|
@ -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):
|
||||
|
@ -56,6 +58,10 @@ class ActionViewSet(GenericViewSet):
|
|||
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)
|
||||
|
@ -121,16 +127,52 @@ class InitializeViewSetsTestCase(TestCase):
|
|||
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):
|
||||
|
|
Loading…
Reference in New Issue
Block a user