From 7855d3bd8b607ed8aed1a2266b35e3e3ef265288 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Mon, 4 Dec 2017 05:55:49 -0500 Subject: [PATCH] Add '.basename' and '.reverse_action()' to ViewSet (#5648) * Router sets 'basename' on ViewSet * Add 'ViewSet.reverse_action()' method * Test router setting initkwargs --- docs/api-guide/viewsets.md | 15 +++++++ rest_framework/routers.py | 7 +++- rest_framework/viewsets.py | 16 ++++++- tests/test_routers.py | 16 +++++++ tests/test_viewsets.py | 86 +++++++++++++++++++++++++++++++++++++- 5 files changed, 137 insertions(+), 3 deletions(-) diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md index a8fc6afef..04ce7357d 100644 --- a/docs/api-guide/viewsets.md +++ b/docs/api-guide/viewsets.md @@ -159,6 +159,21 @@ These decorators will route `GET` requests by default, but may also accept other The two new actions will then be available at the urls `^users/{pk}/set_password/$` and `^users/{pk}/unset_password/$` +## Reversing action URLs + +If you need to get the URL of an action, use the `.reverse_action()` method. This is a convenience wrapper for `reverse()`, automatically passing the view's `request` object and prepending the `url_name` with the `.basename` attribute. + +Note that the `basename` is provided by the router during `ViewSet` registration. If you are not using a router, then you must provide the `basename` argument to the `.as_view()` method. + +Using the example from the previous section: + +```python +>>> view.reverse_action('set-password', args=['1']) +'http://localhost:8000/api/users/1/set_password' +``` + +The `url_name` argument should match the same argument to the `@list_route` and `@detail_route` decorators. Additionally, this can be used to reverse the default `list` and `detail` routes. + --- # API Reference diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 2010a1138..f4d2fab38 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -278,7 +278,12 @@ class SimpleRouter(BaseRouter): if not prefix and regex[:2] == '^/': regex = '^' + regex[2:] - view = viewset.as_view(mapping, **route.initkwargs) + initkwargs = route.initkwargs.copy() + initkwargs.update({ + 'basename': basename, + }) + + view = viewset.as_view(mapping, **initkwargs) name = route.name.format(basename=basename) ret.append(url(regex, view, name=name)) diff --git a/rest_framework/viewsets.py b/rest_framework/viewsets.py index 7f3c550a9..4ee7cdaf8 100644 --- a/rest_framework/viewsets.py +++ b/rest_framework/viewsets.py @@ -24,6 +24,7 @@ from django.utils.decorators import classonlymethod from django.views.decorators.csrf import csrf_exempt from rest_framework import generics, mixins, views +from rest_framework.reverse import reverse class ViewSetMixin(object): @@ -46,10 +47,14 @@ class ViewSetMixin(object): instantiated view, we need to totally reimplement `.as_view`, and slightly modify the view function that is created and returned. """ - # The suffix initkwarg is reserved for identifying the viewset type + # The suffix initkwarg is reserved for displaying the viewset type. # eg. 'List' or 'Instance'. cls.suffix = None + # Setting a basename allows a view to reverse its action urls. This + # value is provided by the router through the initkwargs. + cls.basename = None + # actions must not be empty if not actions: raise TypeError("The `actions` argument must be provided when " @@ -121,6 +126,15 @@ class ViewSetMixin(object): self.action = self.action_map.get(method) return request + def reverse_action(self, url_name, *args, **kwargs): + """ + Reverse the action for the given `url_name`. + """ + url_name = '%s-%s' % (self.basename, url_name) + kwargs.setdefault('request', self.request) + + return reverse(url_name, *args, **kwargs) + class ViewSet(ViewSetMixin, views.APIView): """ diff --git a/tests/test_routers.py b/tests/test_routers.py index 07a57fe55..5a1cfe8f4 100644 --- a/tests/test_routers.py +++ b/tests/test_routers.py @@ -7,6 +7,7 @@ from django.conf.urls import include, url from django.core.exceptions import ImproperlyConfigured from django.db import models from django.test import TestCase, override_settings +from django.urls import resolve from rest_framework import permissions, serializers, viewsets from rest_framework.compat import get_regex_pattern @@ -435,3 +436,18 @@ class TestRegexUrlPath(TestCase): response = self.client.get('/regex/{}/detail/{}/'.format(pk, kwarg)) assert response.status_code == 200 assert json.loads(response.content.decode('utf-8')) == {'pk': pk, 'kwarg': kwarg} + + +@override_settings(ROOT_URLCONF='tests.test_routers') +class TestViewInitkwargs(TestCase): + def test_suffix(self): + match = resolve('/example/notes/') + initkwargs = match.func.initkwargs + + assert initkwargs['suffix'] == 'List' + + def test_basename(self): + match = resolve('/example/notes/') + initkwargs = match.func.initkwargs + + assert initkwargs['basename'] == 'routertestmodel' diff --git a/tests/test_viewsets.py b/tests/test_viewsets.py index 846a36807..beff42cb8 100644 --- a/tests/test_viewsets.py +++ b/tests/test_viewsets.py @@ -1,7 +1,11 @@ -from django.test import TestCase +from django.conf.urls import include, url +from django.db import models +from django.test import TestCase, override_settings from rest_framework import status +from rest_framework.decorators import detail_route, list_route from rest_framework.response import Response +from rest_framework.routers import SimpleRouter from rest_framework.test import APIRequestFactory from rest_framework.viewsets import GenericViewSet @@ -22,6 +26,46 @@ class InstanceViewSet(GenericViewSet): return Response({'view': self}) +class Action(models.Model): + pass + + +class ActionViewSet(GenericViewSet): + queryset = Action.objects.all() + + def list(self, request, *args, **kwargs): + pass + + def retrieve(self, request, *args, **kwargs): + pass + + @list_route() + def list_action(self, request, *args, **kwargs): + pass + + @list_route(url_name='list-custom') + def custom_list_action(self, request, *args, **kwargs): + pass + + @detail_route() + def detail_action(self, request, *args, **kwargs): + pass + + @detail_route(url_name='detail-custom') + def custom_detail_action(self, request, *args, **kwargs): + pass + + +router = SimpleRouter() +router.register(r'actions', ActionViewSet) +router.register(r'actions-alt', ActionViewSet, base_name='actions-alt') + + +urlpatterns = [ + url(r'^api/', include(router.urls)), +] + + class InitializeViewSetsTestCase(TestCase): def test_initialize_view_set_with_actions(self): request = factory.get('/', '', content_type='application/json') @@ -65,3 +109,43 @@ class InitializeViewSetsTestCase(TestCase): for attribute in ('args', 'kwargs', 'request', 'action_map'): self.assertNotIn(attribute, dir(bare_view)) self.assertIn(attribute, dir(view)) + + +@override_settings(ROOT_URLCONF='tests.test_viewsets') +class ReverseActionTests(TestCase): + def test_default_basename(self): + view = ActionViewSet() + view.basename = router.get_default_base_name(ActionViewSet) + view.request = None + + assert view.reverse_action('list') == '/api/actions/' + assert view.reverse_action('list-action') == '/api/actions/list_action/' + assert view.reverse_action('list-custom') == '/api/actions/custom_list_action/' + + assert view.reverse_action('detail', args=['1']) == '/api/actions/1/' + assert view.reverse_action('detail-action', args=['1']) == '/api/actions/1/detail_action/' + assert view.reverse_action('detail-custom', args=['1']) == '/api/actions/1/custom_detail_action/' + + def test_custom_basename(self): + view = ActionViewSet() + view.basename = 'actions-alt' + view.request = None + + assert view.reverse_action('list') == '/api/actions-alt/' + assert view.reverse_action('list-action') == '/api/actions-alt/list_action/' + assert view.reverse_action('list-custom') == '/api/actions-alt/custom_list_action/' + + assert view.reverse_action('detail', args=['1']) == '/api/actions-alt/1/' + assert view.reverse_action('detail-action', args=['1']) == '/api/actions-alt/1/detail_action/' + assert view.reverse_action('detail-custom', args=['1']) == '/api/actions-alt/1/custom_detail_action/' + + def test_request_passing(self): + view = ActionViewSet() + view.basename = router.get_default_base_name(ActionViewSet) + view.request = factory.get('/') + + # Passing the view's request object should result in an absolute URL. + assert view.reverse_action('list') == 'http://testserver/api/actions/' + + # Users should be able to explicitly not pass the view's request. + assert view.reverse_action('list', request=None) == '/api/actions/'