Skip to content

Commit 7855d3b

Browse files
Ryan P Kilbycarltongibson
authored andcommitted
Add '.basename' and '.reverse_action()' to ViewSet (#5648)
* Router sets 'basename' on ViewSet * Add 'ViewSet.reverse_action()' method * Test router setting initkwargs
1 parent c7df69a commit 7855d3b

File tree

5 files changed

+137
-3
lines changed

5 files changed

+137
-3
lines changed

docs/api-guide/viewsets.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,21 @@ These decorators will route `GET` requests by default, but may also accept other
159159

160160
The two new actions will then be available at the urls `^users/{pk}/set_password/$` and `^users/{pk}/unset_password/$`
161161

162+
## Reversing action URLs
163+
164+
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.
165+
166+
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.
167+
168+
Using the example from the previous section:
169+
170+
```python
171+
>>> view.reverse_action('set-password', args=['1'])
172+
'http://localhost:8000/api/users/1/set_password'
173+
```
174+
175+
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.
176+
162177
---
163178

164179
# API Reference

rest_framework/routers.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,12 @@ def get_urls(self):
278278
if not prefix and regex[:2] == '^/':
279279
regex = '^' + regex[2:]
280280

281-
view = viewset.as_view(mapping, **route.initkwargs)
281+
initkwargs = route.initkwargs.copy()
282+
initkwargs.update({
283+
'basename': basename,
284+
})
285+
286+
view = viewset.as_view(mapping, **initkwargs)
282287
name = route.name.format(basename=basename)
283288
ret.append(url(regex, view, name=name))
284289

rest_framework/viewsets.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from django.views.decorators.csrf import csrf_exempt
2525

2626
from rest_framework import generics, mixins, views
27+
from rest_framework.reverse import reverse
2728

2829

2930
class ViewSetMixin(object):
@@ -46,10 +47,14 @@ def as_view(cls, actions=None, **initkwargs):
4647
instantiated view, we need to totally reimplement `.as_view`,
4748
and slightly modify the view function that is created and returned.
4849
"""
49-
# The suffix initkwarg is reserved for identifying the viewset type
50+
# The suffix initkwarg is reserved for displaying the viewset type.
5051
# eg. 'List' or 'Instance'.
5152
cls.suffix = None
5253

54+
# Setting a basename allows a view to reverse its action urls. This
55+
# value is provided by the router through the initkwargs.
56+
cls.basename = None
57+
5358
# actions must not be empty
5459
if not actions:
5560
raise TypeError("The `actions` argument must be provided when "
@@ -121,6 +126,15 @@ def initialize_request(self, request, *args, **kwargs):
121126
self.action = self.action_map.get(method)
122127
return request
123128

129+
def reverse_action(self, url_name, *args, **kwargs):
130+
"""
131+
Reverse the action for the given `url_name`.
132+
"""
133+
url_name = '%s-%s' % (self.basename, url_name)
134+
kwargs.setdefault('request', self.request)
135+
136+
return reverse(url_name, *args, **kwargs)
137+
124138

125139
class ViewSet(ViewSetMixin, views.APIView):
126140
"""

tests/test_routers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from django.core.exceptions import ImproperlyConfigured
88
from django.db import models
99
from django.test import TestCase, override_settings
10+
from django.urls import resolve
1011

1112
from rest_framework import permissions, serializers, viewsets
1213
from rest_framework.compat import get_regex_pattern
@@ -435,3 +436,18 @@ def test_regex_url_path_detail(self):
435436
response = self.client.get('/regex/{}/detail/{}/'.format(pk, kwarg))
436437
assert response.status_code == 200
437438
assert json.loads(response.content.decode('utf-8')) == {'pk': pk, 'kwarg': kwarg}
439+
440+
441+
@override_settings(ROOT_URLCONF='tests.test_routers')
442+
class TestViewInitkwargs(TestCase):
443+
def test_suffix(self):
444+
match = resolve('/example/notes/')
445+
initkwargs = match.func.initkwargs
446+
447+
assert initkwargs['suffix'] == 'List'
448+
449+
def test_basename(self):
450+
match = resolve('/example/notes/')
451+
initkwargs = match.func.initkwargs
452+
453+
assert initkwargs['basename'] == 'routertestmodel'

tests/test_viewsets.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
from django.test import TestCase
1+
from django.conf.urls import include, url
2+
from django.db import models
3+
from django.test import TestCase, override_settings
24

35
from rest_framework import status
6+
from rest_framework.decorators import detail_route, list_route
47
from rest_framework.response import Response
8+
from rest_framework.routers import SimpleRouter
59
from rest_framework.test import APIRequestFactory
610
from rest_framework.viewsets import GenericViewSet
711

@@ -22,6 +26,46 @@ def dummy(self, request, *args, **kwargs):
2226
return Response({'view': self})
2327

2428

29+
class Action(models.Model):
30+
pass
31+
32+
33+
class ActionViewSet(GenericViewSet):
34+
queryset = Action.objects.all()
35+
36+
def list(self, request, *args, **kwargs):
37+
pass
38+
39+
def retrieve(self, request, *args, **kwargs):
40+
pass
41+
42+
@list_route()
43+
def list_action(self, request, *args, **kwargs):
44+
pass
45+
46+
@list_route(url_name='list-custom')
47+
def custom_list_action(self, request, *args, **kwargs):
48+
pass
49+
50+
@detail_route()
51+
def detail_action(self, request, *args, **kwargs):
52+
pass
53+
54+
@detail_route(url_name='detail-custom')
55+
def custom_detail_action(self, request, *args, **kwargs):
56+
pass
57+
58+
59+
router = SimpleRouter()
60+
router.register(r'actions', ActionViewSet)
61+
router.register(r'actions-alt', ActionViewSet, base_name='actions-alt')
62+
63+
64+
urlpatterns = [
65+
url(r'^api/', include(router.urls)),
66+
]
67+
68+
2569
class InitializeViewSetsTestCase(TestCase):
2670
def test_initialize_view_set_with_actions(self):
2771
request = factory.get('/', '', content_type='application/json')
@@ -65,3 +109,43 @@ def test_args_kwargs_request_action_map_on_self(self):
65109
for attribute in ('args', 'kwargs', 'request', 'action_map'):
66110
self.assertNotIn(attribute, dir(bare_view))
67111
self.assertIn(attribute, dir(view))
112+
113+
114+
@override_settings(ROOT_URLCONF='tests.test_viewsets')
115+
class ReverseActionTests(TestCase):
116+
def test_default_basename(self):
117+
view = ActionViewSet()
118+
view.basename = router.get_default_base_name(ActionViewSet)
119+
view.request = None
120+
121+
assert view.reverse_action('list') == '/api/actions/'
122+
assert view.reverse_action('list-action') == '/api/actions/list_action/'
123+
assert view.reverse_action('list-custom') == '/api/actions/custom_list_action/'
124+
125+
assert view.reverse_action('detail', args=['1']) == '/api/actions/1/'
126+
assert view.reverse_action('detail-action', args=['1']) == '/api/actions/1/detail_action/'
127+
assert view.reverse_action('detail-custom', args=['1']) == '/api/actions/1/custom_detail_action/'
128+
129+
def test_custom_basename(self):
130+
view = ActionViewSet()
131+
view.basename = 'actions-alt'
132+
view.request = None
133+
134+
assert view.reverse_action('list') == '/api/actions-alt/'
135+
assert view.reverse_action('list-action') == '/api/actions-alt/list_action/'
136+
assert view.reverse_action('list-custom') == '/api/actions-alt/custom_list_action/'
137+
138+
assert view.reverse_action('detail', args=['1']) == '/api/actions-alt/1/'
139+
assert view.reverse_action('detail-action', args=['1']) == '/api/actions-alt/1/detail_action/'
140+
assert view.reverse_action('detail-custom', args=['1']) == '/api/actions-alt/1/custom_detail_action/'
141+
142+
def test_request_passing(self):
143+
view = ActionViewSet()
144+
view.basename = router.get_default_base_name(ActionViewSet)
145+
view.request = factory.get('/')
146+
147+
# Passing the view's request object should result in an absolute URL.
148+
assert view.reverse_action('list') == 'http://testserver/api/actions/'
149+
150+
# Users should be able to explicitly not pass the view's request.
151+
assert view.reverse_action('list', request=None) == '/api/actions/'

0 commit comments

Comments
 (0)