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/viewsets.py b/rest_framework/viewsets.py index cf5f35af6..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): @@ -125,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_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/'