Add 'name' and 'description' attributes to ViewSet

ViewSets may now provide their `name` and `description` attributes
directly, instead of relying on view introspection to derive them.
These attributes may also be provided with the view's initkwargs.

The ViewSet `name` and `suffix` initkwargs are mutually exclusive.

The `action` decorator now provides the `name` and `description` to
the view's initkwargs. By default, these values are derived from the
method name and its docstring. The `name` may be overridden by providing
it as an argument to the decorator.

The `get_view_name` and `get_view_description` hooks now provide the
view instance to the handler, instead of the view class. The default
implementations of these handlers now respect the `name`/`description`.
This commit is contained in:
Ryan P Kilby 2017-11-17 21:17:35 -05:00
parent 06265b6bf3
commit d0af8e8723
7 changed files with 105 additions and 8 deletions

View File

@ -11,6 +11,7 @@ from __future__ import unicode_literals
import types import types
import warnings import warnings
from django.forms.utils import pretty_name
from django.utils import six from django.utils import six
from rest_framework.views import APIView from rest_framework.views import APIView
@ -130,7 +131,7 @@ def schema(view_inspector):
return decorator return decorator
def action(methods=None, detail=None, url_path=None, url_name=None, **kwargs): def action(methods=None, detail=None, name=None, url_path=None, url_name=None, **kwargs):
""" """
Mark a ViewSet method as a routable action. Mark a ViewSet method as a routable action.
@ -147,9 +148,14 @@ def action(methods=None, detail=None, url_path=None, url_name=None, **kwargs):
def decorator(func): def decorator(func):
func.bind_to_methods = methods func.bind_to_methods = methods
func.detail = detail func.detail = detail
func.name = name if name else pretty_name(func.__name__)
func.url_path = url_path if url_path else func.__name__ func.url_path = url_path if url_path else func.__name__
func.url_name = url_name if url_name else func.__name__.replace('_', '-') func.url_name = url_name if url_name else func.__name__.replace('_', '-')
func.kwargs = kwargs func.kwargs = kwargs
func.kwargs.update({
'name': func.name,
'description': func.__doc__ or None
})
return func return func
return decorator return decorator

View File

@ -21,31 +21,43 @@ from rest_framework.settings import api_settings
from rest_framework.utils import formatting from rest_framework.utils import formatting
def get_view_name(view_cls, suffix=None): def get_view_name(view):
""" """
Given a view class, return a textual name to represent the view. Given a view class, return a textual name to represent the view.
This name is used in the browsable API, and in OPTIONS responses. This name is used in the browsable API, and in OPTIONS responses.
This function is the default for the `VIEW_NAME_FUNCTION` setting. This function is the default for the `VIEW_NAME_FUNCTION` setting.
""" """
name = view_cls.__name__ # Name may be set by some Views, such as a ViewSet.
name = getattr(view, 'name', None)
if name is not None:
return name
name = view.__class__.__name__
name = formatting.remove_trailing_string(name, 'View') name = formatting.remove_trailing_string(name, 'View')
name = formatting.remove_trailing_string(name, 'ViewSet') name = formatting.remove_trailing_string(name, 'ViewSet')
name = formatting.camelcase_to_spaces(name) name = formatting.camelcase_to_spaces(name)
# Suffix may be set by some Views, such as a ViewSet.
suffix = getattr(view, 'suffix', None)
if suffix: if suffix:
name += ' ' + suffix name += ' ' + suffix
return name return name
def get_view_description(view_cls, html=False): def get_view_description(view, html=False):
""" """
Given a view class, return a textual description to represent the view. Given a view class, return a textual description to represent the view.
This name is used in the browsable API, and in OPTIONS responses. This name is used in the browsable API, and in OPTIONS responses.
This function is the default for the `VIEW_DESCRIPTION_FUNCTION` setting. This function is the default for the `VIEW_DESCRIPTION_FUNCTION` setting.
""" """
description = view_cls.__doc__ or '' # Description may be set by some Views, such as a ViewSet.
description = getattr(view, 'description', None)
if description is None:
description = view.__class__.__doc__ or ''
description = formatting.dedent(smart_text(description)) description = formatting.dedent(smart_text(description))
if html: if html:
return formatting.markup_description(description) return formatting.markup_description(description)
@ -224,7 +236,7 @@ class APIView(View):
browsable API. browsable API.
""" """
func = self.settings.VIEW_NAME_FUNCTION func = self.settings.VIEW_NAME_FUNCTION
return func(self.__class__, getattr(self, 'suffix', None)) return func(self)
def get_view_description(self, html=False): def get_view_description(self, html=False):
""" """
@ -232,7 +244,7 @@ class APIView(View):
and in the browsable API. and in the browsable API.
""" """
func = self.settings.VIEW_DESCRIPTION_FUNCTION func = self.settings.VIEW_DESCRIPTION_FUNCTION
return func(self.__class__, html) return func(self, html)
# API policy instantiation methods # API policy instantiation methods

View File

@ -52,7 +52,13 @@ class ViewSetMixin(object):
instantiated view, we need to totally reimplement `.as_view`, instantiated view, we need to totally reimplement `.as_view`,
and slightly modify the view function that is created and returned. and slightly modify the view function that is created and returned.
""" """
# The name and description initkwargs may be explicitly overridden for
# certain route confiugurations. eg, names of extra actions.
cls.name = None
cls.description = None
# The suffix initkwarg is reserved for displaying the viewset type. # The suffix initkwarg is reserved for displaying the viewset type.
# This initkwarg should have no effect if the name is provided.
# eg. 'List' or 'Instance'. # eg. 'List' or 'Instance'.
cls.suffix = None cls.suffix = None
@ -79,6 +85,11 @@ class ViewSetMixin(object):
raise TypeError("%s() received an invalid keyword %r" % ( raise TypeError("%s() received an invalid keyword %r" % (
cls.__name__, key)) cls.__name__, key))
# name and suffix are mutually exclusive
if 'name' in initkwargs and 'suffix' in initkwargs:
raise TypeError("%s() received both `name` and `suffix`, which are "
"mutually exclusive arguments." % (cls.__name__))
def view(request, *args, **kwargs): def view(request, *args, **kwargs):
self = cls(**initkwargs) self = cls(**initkwargs)
# We also store the mapping of request methods to actions, # We also store the mapping of request methods to actions,

View File

@ -175,12 +175,17 @@ class ActionDecoratorTestCase(TestCase):
def test_defaults(self): def test_defaults(self):
@action(detail=True) @action(detail=True)
def test_action(request): def test_action(request):
pass """Description"""
assert test_action.bind_to_methods == ['get'] assert test_action.bind_to_methods == ['get']
assert test_action.detail is True assert test_action.detail is True
assert test_action.name == 'Test action'
assert test_action.url_path == 'test_action' assert test_action.url_path == 'test_action'
assert test_action.url_name == 'test-action' assert test_action.url_name == 'test-action'
assert test_action.kwargs == {
'name': 'Test action',
'description': 'Description',
}
def test_detail_required(self): def test_detail_required(self):
with pytest.raises(AssertionError) as excinfo: with pytest.raises(AssertionError) as excinfo:

View File

@ -85,6 +85,22 @@ class TestViewNamesAndDescriptions(TestCase):
pass pass
assert MockView().get_view_name() == 'Mock' assert MockView().get_view_name() == 'Mock'
def test_view_name_uses_name_attribute(self):
class MockView(APIView):
name = 'Foo'
assert MockView().get_view_name() == 'Foo'
def test_view_name_uses_suffix_attribute(self):
class MockView(APIView):
suffix = 'List'
assert MockView().get_view_name() == 'Mock List'
def test_view_name_preferences_name_over_suffix(self):
class MockView(APIView):
name = 'Foo'
suffix = 'List'
assert MockView().get_view_name() == 'Foo'
def test_view_description_uses_docstring(self): def test_view_description_uses_docstring(self):
"""Ensure view descriptions are based on the docstring.""" """Ensure view descriptions are based on the docstring."""
class MockView(APIView): class MockView(APIView):
@ -112,6 +128,17 @@ class TestViewNamesAndDescriptions(TestCase):
assert MockView().get_view_description() == DESCRIPTION assert MockView().get_view_description() == DESCRIPTION
def test_view_description_uses_description_attribute(self):
class MockView(APIView):
description = 'Foo'
assert MockView().get_view_description() == 'Foo'
def test_view_description_allows_empty_description(self):
class MockView(APIView):
"""Description."""
description = ''
assert MockView().get_view_description() == ''
def test_view_description_can_be_empty(self): def test_view_description_can_be_empty(self):
""" """
Ensure that if a view has no docstring, Ensure that if a view has no docstring,

View File

@ -4,6 +4,7 @@ from __future__ import unicode_literals
from django.conf.urls import url from django.conf.urls import url
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from rest_framework.decorators import action
from rest_framework.routers import SimpleRouter from rest_framework.routers import SimpleRouter
from rest_framework.serializers import ModelSerializer from rest_framework.serializers import ModelSerializer
from rest_framework.utils import json from rest_framework.utils import json
@ -43,6 +44,14 @@ class ResourceViewSet(ModelViewSet):
serializer_class = ModelSerializer serializer_class = ModelSerializer
queryset = BasicModel.objects.all() queryset = BasicModel.objects.all()
@action(detail=False)
def list_action(self, request, *args, **kwargs):
raise NotImplementedError
@action(detail=True)
def detail_action(self, request, *args, **kwargs):
raise NotImplementedError
router = SimpleRouter() router = SimpleRouter()
router.register(r'resources', ResourceViewSet) router.register(r'resources', ResourceViewSet)
@ -119,6 +128,23 @@ class BreadcrumbTests(TestCase):
('Resource Instance', '/resources/1/') ('Resource Instance', '/resources/1/')
] ]
def test_modelviewset_list_action_breadcrumbs(self):
url = '/resources/list_action/'
assert get_breadcrumbs(url) == [
('Root', '/'),
('Resource List', '/resources/'),
('List action', '/resources/list_action/'),
]
def test_modelviewset_detail_action_breadcrumbs(self):
url = '/resources/1/detail_action/'
assert get_breadcrumbs(url) == [
('Root', '/'),
('Resource List', '/resources/'),
('Resource Instance', '/resources/1/'),
('Detail action', '/resources/1/detail_action/'),
]
class JsonFloatTests(TestCase): class JsonFloatTests(TestCase):
""" """

View File

@ -96,6 +96,16 @@ class InitializeViewSetsTestCase(TestCase):
"when calling `.as_view()` on a ViewSet. " "when calling `.as_view()` on a ViewSet. "
"For example `.as_view({'get': 'list'})`") "For example `.as_view({'get': 'list'})`")
def test_initialize_view_set_with_both_name_and_suffix(self):
with pytest.raises(TypeError) as excinfo:
BasicViewSet.as_view(name='', suffix='', actions={
'get': 'list',
})
assert str(excinfo.value) == (
"BasicViewSet() received both `name` and `suffix`, "
"which are mutually exclusive arguments.")
def test_args_kwargs_request_action_map_on_self(self): def test_args_kwargs_request_action_map_on_self(self):
""" """
Test a view only has args, kwargs, request, action_map Test a view only has args, kwargs, request, action_map