diff --git a/rest_framework/relations.py b/rest_framework/relations.py index c87b9299a..fc66d8704 100644 --- a/rest_framework/relations.py +++ b/rest_framework/relations.py @@ -339,7 +339,8 @@ class HyperlinkedRelatedField(RelatedField): self.view_name, request ) except AttributeError: - expected_viewname = self.view_name + # by default, expect the 'rest_framework' namespace + expected_viewname = 'rest_framework:' + self.view_name if match.view_name != expected_viewname: self.fail('incorrect_match') diff --git a/rest_framework/reverse.py b/rest_framework/reverse.py index e9cf737f1..7eba38f9a 100644 --- a/rest_framework/reverse.py +++ b/rest_framework/reverse.py @@ -34,10 +34,29 @@ def preserve_builtin_query_params(url, request=None): def reverse(viewname, args=None, kwargs=None, request=None, format=None, **extra): """ + Extends `django.urls.reverse` with behavior specific to rest framework. + + The `viewname` will be prepended with the 'rest_framework' application + namespace if no namspace is included in the `viewname` argument. The + framework fundamentally assumes that the router urls will be included with + the 'rest_framework' namespace, so ensure that your root url patterns are + configured accordingly. Assuming you use the default router, you can check + this with: + + from django.urls import reverse + + reverse('rest_framework:api-root') + If versioning is being used then we pass any `reverse` calls through to the versioning scheme instance, so that the resulting URL can be modified if needed. + + Optionally takes a `request` object (see `_reverse` for details). """ + # prepend the 'rest_framework' application namespace + if ':' not in viewname: + viewname = 'rest_framework:' + viewname + scheme = getattr(request, 'versioning_scheme', None) if scheme is not None: try: @@ -56,14 +75,33 @@ def _reverse(viewname, args=None, kwargs=None, request=None, format=None, **extr """ Same as `django.urls.reverse`, but optionally takes a request and returns a fully qualified URL, using the request to get the base URL. + + Additionally, the request is used to determine the `current_app` instance. """ if format is not None: kwargs = kwargs or {} kwargs['format'] = format + if request: + extra.setdefault('current_app', current_app(request)) url = django_reverse(viewname, args=args, kwargs=kwargs, **extra) if request: return request.build_absolute_uri(url) return url +def current_app(request): + """ + Get the current app for the request. + + This code is copied from the URL tag. + """ + try: + return request.current_app + except AttributeError: + try: + return request.resolver_match.namespace + except AttributeError: + return None + + reverse_lazy = lazy(reverse, six.text_type) diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 281bbde8a..55c9ca422 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -101,6 +101,10 @@ class BaseRouter(object): self._urls = self.get_urls() return self._urls + @property + def urlpatterns(self): + return (self.urls, 'rest_framework') + class SimpleRouter(BaseRouter): @@ -305,10 +309,7 @@ class APIRootView(views.APIView): def get(self, request, *args, **kwargs): # Return a plain {"name": "hyperlink"} response. ret = OrderedDict() - namespace = request.resolver_match.namespace for key, url_name in self.api_root_dict.items(): - if namespace: - url_name = namespace + ':' + url_name try: ret[key] = reverse( url_name, diff --git a/rest_framework/templatetags/rest_framework.py b/rest_framework/templatetags/rest_framework.py index 2a2459b37..abf7d4d74 100644 --- a/rest_framework/templatetags/rest_framework.py +++ b/rest_framework/templatetags/rest_framework.py @@ -96,7 +96,7 @@ def optional_login(request): Include a login snippet if REST framework's login view is in the URLconf. """ try: - login_url = reverse('rest_framework:login') + login_url = reverse('rest_framework_auth:login') except NoReverseMatch: return '' @@ -112,7 +112,7 @@ def optional_docs_login(request): Include a login snippet if REST framework's login view is in the URLconf. """ try: - login_url = reverse('rest_framework:login') + login_url = reverse('rest_framework_auth:login') except NoReverseMatch: return 'log in' @@ -128,7 +128,7 @@ def optional_logout(request, user): Include a logout snippet if REST framework's logout view is in the URLconf. """ try: - logout_url = reverse('rest_framework:logout') + logout_url = reverse('rest_framework_auth:logout') except NoReverseMatch: snippet = format_html('', user=escape(user)) return mark_safe(snippet) diff --git a/rest_framework/urls.py b/rest_framework/urls.py index 80fce5dc4..8d40727c4 100644 --- a/rest_framework/urls.py +++ b/rest_framework/urls.py @@ -27,7 +27,7 @@ else: logout = views.LogoutView.as_view() -app_name = 'rest_framework' +app_name = 'rest_framework_auth' urlpatterns = [ url(r'^login/$', login, login_kwargs, name='login'), url(r'^logout/$', logout, name='logout'), diff --git a/rest_framework/versioning.py b/rest_framework/versioning.py index 5c9a7ade1..384f1eb75 100644 --- a/rest_framework/versioning.py +++ b/rest_framework/versioning.py @@ -135,6 +135,13 @@ class NamespaceVersioning(BaseVersioning): ) def get_versioned_viewname(self, viewname, request): + """ + The incoming `viewname` should be prefixed with the 'rest_framework' + application namespace. We want to replace this with the version + instance namespace. + """ + if viewname.startswith('rest_framework:'): + viewname = viewname[len('rest_framework:'):] return request.version + ':' + viewname diff --git a/tests/test_lazy_hyperlinks.py b/tests/test_lazy_hyperlinks.py index cf3ee735f..a502ce946 100644 --- a/tests/test_lazy_hyperlinks.py +++ b/tests/test_lazy_hyperlinks.py @@ -1,4 +1,4 @@ -from django.conf.urls import url +from django.conf.urls import include, url from django.db import models from django.test import TestCase, override_settings @@ -28,10 +28,14 @@ def dummy_view(request): pass -urlpatterns = [ +patterns = [ url(r'^example/(?P[0-9]+)/$', dummy_view, name='example-detail'), ] +urlpatterns = [ + url(r'^', include((patterns, 'rest_framework'))) +] + @override_settings(ROOT_URLCONF='tests.test_lazy_hyperlinks') class TestLazyHyperlinkNames(TestCase): diff --git a/tests/test_relations.py b/tests/test_relations.py index fd3256e89..b686bea17 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -2,7 +2,7 @@ import uuid import pytest from _pytest.monkeypatch import MonkeyPatch -from django.conf.urls import url +from django.conf.urls import include, url from django.core.exceptions import ImproperlyConfigured from django.test import override_settings from django.utils.datastructures import MultiValueDict @@ -146,7 +146,9 @@ class TestProxiedPrimaryKeyRelatedField(APISimpleTestCase): @override_settings(ROOT_URLCONF=[ - url(r'^example/(?P.+)/$', lambda: None, name='example'), + url(r'^', include( + ([url(r'^example/(?P.+)/$', lambda: None, name='example')], 'rest_framework') + )), ]) class TestHyperlinkedRelatedField(APISimpleTestCase): def setUp(self): diff --git a/tests/test_relations_hyperlink.py b/tests/test_relations_hyperlink.py index 887a6f423..45faf90a2 100644 --- a/tests/test_relations_hyperlink.py +++ b/tests/test_relations_hyperlink.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from django.conf.urls import url +from django.conf.urls import include, url from django.test import TestCase, override_settings from rest_framework import serializers @@ -18,7 +18,7 @@ def dummy_view(request, pk): pass -urlpatterns = [ +patterns = [ url(r'^dummyurl/(?P[0-9]+)/$', dummy_view, name='dummy-url'), url(r'^manytomanysource/(?P[0-9]+)/$', dummy_view, name='manytomanysource-detail'), url(r'^manytomanytarget/(?P[0-9]+)/$', dummy_view, name='manytomanytarget-detail'), @@ -29,6 +29,10 @@ urlpatterns = [ url(r'^nullableonetoonesource/(?P[0-9]+)/$', dummy_view, name='nullableonetoonesource-detail'), ] +urlpatterns = [ + url(r'^', include((patterns, 'rest_framework'))) +] + # ManyToMany class ManyToManyTargetSerializer(serializers.HyperlinkedModelSerializer): @@ -449,3 +453,39 @@ class HyperlinkedNullableOneToOneTests(TestCase): {'url': 'http://testserver/onetoonetarget/2/', 'name': 'target-2', 'nullable_source': None}, ] assert serializer.data == expected + + +class HyperlinkedNamespaceTests(TestCase): + def setUp(self): + target = ForeignKeyTarget(name='target-1') + target.save() + new_target = ForeignKeyTarget(name='target-2') + new_target.save() + for idx in range(1, 4): + source = ForeignKeySource(name='source-%d' % idx, target=target) + source.save() + + def results(self): + queryset = ForeignKeySource.objects.all() + serializer = ForeignKeySourceSerializer(queryset, many=True, context={'request': request}) + expected = [ + {'url': 'http://testserver/foreignkeysource/1/', 'name': 'source-1', 'target': 'http://testserver/foreignkeytarget/1/'}, + {'url': 'http://testserver/foreignkeysource/2/', 'name': 'source-2', 'target': 'http://testserver/foreignkeytarget/1/'}, + {'url': 'http://testserver/foreignkeysource/3/', 'name': 'source-3', 'target': 'http://testserver/foreignkeytarget/1/'} + ] + with self.assertNumQueries(1): + assert serializer.data == expected + + def test_foreign_key_retrieve_no_namespace(self): + url_conf = [ + url(r'^', include((patterns, 'rest_framework'), namespace=None)) + ] + with override_settings(ROOT_URLCONF=url_conf): + self.results() + + def test_foreign_key_retrieve_namespace(self): + url_conf = [ + url(r'^', include((patterns, 'rest_framework'), namespace='api')) + ] + with override_settings(ROOT_URLCONF=url_conf): + self.results() diff --git a/tests/test_reverse.py b/tests/test_reverse.py index 145b1a54f..673f46002 100644 --- a/tests/test_reverse.py +++ b/tests/test_reverse.py @@ -1,21 +1,34 @@ from __future__ import unicode_literals -from django.conf.urls import url +from django.conf.urls import include, url +from django.http import HttpResponse from django.test import TestCase, override_settings from django.urls import NoReverseMatch -from rest_framework.reverse import reverse +from rest_framework.reverse import current_app, reverse from rest_framework.test import APIRequestFactory factory = APIRequestFactory() -def null_view(request): - pass +def mock_view(request): + return HttpResponse('') + + +apppatterns = ([ + url(r'^home$', mock_view, name='home'), +], 'app') + +apipatterns = ([ + url(r'^root$', mock_view, name='root'), +], 'rest_framework') urlpatterns = [ - url(r'^view$', null_view, name='view'), + url(r'^view$', mock_view, name='view'), + url(r'^app2/', include(apppatterns, namespace='app2')), + url(r'^app1/', include(apppatterns, namespace='app1')), + url(r'^api/', include(apipatterns)), ] @@ -28,7 +41,7 @@ class MockVersioningScheme(object): if self.raise_error: raise NoReverseMatch() - return 'http://scheme-reversed/view' + return 'http://scheme-reversed/api/root' @override_settings(ROOT_URLCONF='tests.test_reverse') @@ -36,21 +49,116 @@ class ReverseTests(TestCase): """ Tests for fully qualified URLs when using `reverse`. """ + + def test_reverse_non_drf_view(self): + request = factory.get('/api/root') + + # DRF reverse should not match non-DRF views + with self.assertRaises(NoReverseMatch): + reverse('view', request=request) + def test_reversed_urls_are_fully_qualified(self): - request = factory.get('/view') - url = reverse('view', request=request) - assert url == 'http://testserver/view' + request = factory.get('/api/root') + + url = reverse('root', request=request) + assert url == 'http://testserver/api/root' def test_reverse_with_versioning_scheme(self): - request = factory.get('/view') + request = factory.get('/api/root') request.versioning_scheme = MockVersioningScheme() - url = reverse('view', request=request) - assert url == 'http://scheme-reversed/view' + url = reverse('root', request=request) + assert url == 'http://scheme-reversed/api/root' def test_reverse_with_versioning_scheme_fallback_to_default_on_error(self): - request = factory.get('/view') + request = factory.get('/api/root') request.versioning_scheme = MockVersioningScheme(raise_error=True) - url = reverse('view', request=request) - assert url == 'http://testserver/view' + url = reverse('root', request=request) + assert url == 'http://testserver/api/root' + + +@override_settings(ROOT_URLCONF='tests.test_reverse') +class NamespaceTests(TestCase): + """ + Ensure reverse can handle namespaces. + + Note: It's necessary to use self.client() here, as the + RequestFactory does not setup the resolver_match. + """ + + def request(self, url): + return self.client.get(url).wsgi_request + + def test_default_namespace(self): + url = reverse('root') + assert url == '/api/root' + + def test_application_namespace(self): + url = reverse('app:home') + assert url == '/app1/home' + + # instance namespace provided by current_app + url = reverse('app:home', current_app='app2') + assert url == '/app2/home' + + def test_instance_namespace(self): + url = reverse('app1:home') + assert url == '/app1/home' + + url = reverse('app2:home') + assert url == '/app2/home' + + def test_application_namespace_with_request(self): + # request's current app affects result + request1 = self.request('/app1/home') + request2 = self.request('/app2/home') + + # sanity check + assert current_app(request1) == 'app1' + assert current_app(request2) == 'app2' + + assert reverse('app:home', request=request1) == 'http://testserver/app1/home' + assert reverse('app:home', request=request2) == 'http://testserver/app2/home' + + def test_instance_namespace_with_request(self): + # request's current app is not relevant + request1 = self.request('/app1/home') + request2 = self.request('/app2/home') + + # sanity check + assert current_app(request1) == 'app1' + assert current_app(request2) == 'app2' + + assert reverse('app1:home', request=request1) == 'http://testserver/app1/home' + assert reverse('app2:home', request=request1) == 'http://testserver/app2/home' + assert reverse('app1:home', request=request2) == 'http://testserver/app1/home' + assert reverse('app2:home', request=request2) == 'http://testserver/app2/home' + + +@override_settings(ROOT_URLCONF='tests.test_reverse') +class CurrentAppTests(TestCase): + """ + Test current_app() function. + + Note: It's necessary to use self.client() here, as the + RequestFactory does not setup the resolver_match. + """ + + def request(self, url): + return self.client.get(url).wsgi_request + + def test_no_namespace(self): + request = self.request('/view') + assert current_app(request) == '' + + def test_namespace(self): + request = self.request('/app1/home') + assert current_app(request) == 'app1' + + request = self.request('/app2/home') + assert current_app(request) == 'app2' + + def test_factory_incompatibility(self): + request = factory.get('/app1/home') + assert current_app(request) is None diff --git a/tests/test_routers.py b/tests/test_routers.py index 36255f48f..e1dafa9bd 100644 --- a/tests/test_routers.py +++ b/tests/test_routers.py @@ -145,13 +145,17 @@ class TestSimpleRouter(TestCase): class TestRootView(URLPatternsTestCase, TestCase): urlpatterns = [ - url(r'^non-namespaced/', include(namespaced_router.urls)), - url(r'^namespaced/', include((namespaced_router.urls, 'namespaced'), namespace='namespaced')), + url(r'^non-namespaced/', include(namespaced_router.urlpatterns)), + url(r'^namespaced1/', include(namespaced_router.urlpatterns, namespace='namespaced1')), + url(r'^namespaced2/', include(namespaced_router.urlpatterns, namespace='namespaced2')), ] def test_retrieve_namespaced_root(self): - response = self.client.get('/namespaced/') - assert response.data == {"example": "http://testserver/namespaced/example/"} + response = self.client.get('/namespaced1/') + assert response.data == {"example": "http://testserver/namespaced1/example/"} + + response = self.client.get('/namespaced2/') + assert response.data == {"example": "http://testserver/namespaced2/example/"} def test_retrieve_non_namespaced_root(self): response = self.client.get('/non-namespaced/') @@ -163,8 +167,8 @@ class TestCustomLookupFields(URLPatternsTestCase, TestCase): Ensure that custom lookup fields are correctly routed. """ urlpatterns = [ - url(r'^example/', include(notes_router.urls)), - url(r'^example2/', include(kwarged_notes_router.urls)), + url(r'^example/', include(notes_router.urlpatterns)), + url(r'^example2/', include(kwarged_notes_router.urlpatterns)), ] def setUp(self): @@ -221,8 +225,8 @@ class TestLookupUrlKwargs(URLPatternsTestCase, TestCase): Setup a deep lookup_field, but map it to a simple URL kwarg. """ urlpatterns = [ - url(r'^example/', include(notes_router.urls)), - url(r'^example2/', include(kwarged_notes_router.urls)), + url(r'^example/', include(notes_router.urlpatterns)), + url(r'^example2/', include(kwarged_notes_router.urlpatterns)), ] def setUp(self): @@ -410,7 +414,7 @@ class TestDynamicListAndDetailRouter(TestCase): class TestEmptyPrefix(URLPatternsTestCase, TestCase): urlpatterns = [ - url(r'^empty-prefix/', include(empty_prefix_router.urls)), + url(r'^empty-prefix/', include(empty_prefix_router.urlpatterns)), ] def test_empty_prefix_list(self): @@ -427,7 +431,7 @@ class TestEmptyPrefix(URLPatternsTestCase, TestCase): class TestRegexUrlPath(URLPatternsTestCase, TestCase): urlpatterns = [ - url(r'^regex/', include(regex_url_path_router.urls)), + url(r'^regex/', include(regex_url_path_router.urlpatterns)), ] def test_regex_url_path_list(self): diff --git a/tests/test_urlconf.py b/tests/test_urlconf.py new file mode 100644 index 000000000..6d3e02e42 --- /dev/null +++ b/tests/test_urlconf.py @@ -0,0 +1,127 @@ +""" +Test rest framework assumptions about the configuration of the root urlconf. +""" +from django.conf.urls import include, url + +from rest_framework import routers, viewsets +from rest_framework.reverse import NoReverseMatch, reverse +from rest_framework.test import APITestCase, URLPatternsTestCase + + +class MockViewSet(viewsets.ViewSet): + def list(self, request, *args, **kwargs): + pass + + +router = routers.DefaultRouter(trailing_slash=False) +router.register(r'example', MockViewSet, base_name='example') + + +class TestUrlsAppNameRequired(APITestCase, URLPatternsTestCase): + urlpatterns = [ + url(r'^api/', include(router.urls)), + ] + + def test_reverse(self): + """ + The 'rest_framework' namespace must be present. + """ + with self.assertRaises(NoReverseMatch): + reverse('example-list') + + +class TestUrlpatternsAppName(APITestCase, URLPatternsTestCase): + urlpatterns = [ + url(r'^api/', include(router.urlpatterns)), + ] + + def test_reverse(self): + self.assertEqual(reverse('example-list'), '/api/example') + + def test_app_name_reverse(self): + self.assertEqual(reverse('rest_framework:example-list'), '/api/example') + + +class TestUrlpatternsNamespace(APITestCase, URLPatternsTestCase): + urlpatterns = [ + url(r'^api/v1/', include(router.urlpatterns, namespace='v1')), + url(r'^api/v2/', include(router.urlpatterns, namespace='v2')), + ] + + def test_reverse(self): + self.assertEqual(reverse('example-list'), '/api/v2/example') + + def test_app_name_reverse(self): + self.assertEqual(reverse('rest_framework:example-list'), '/api/v2/example') + + def test_namespace_reverse(self): + self.assertEqual(reverse('v1:example-list'), '/api/v1/example') + self.assertEqual(reverse('v2:example-list'), '/api/v2/example') + + +class TestAppUrlpatternsAppName(APITestCase, URLPatternsTestCase): + apppatterns = ([ + url(r'^api/', include(router.urlpatterns)), + ], 'api') + + urlpatterns = [ + url(r'^', include(apppatterns)), + ] + + def test_reverse(self): + """ + Nesting the router.urlpatterns in an app with an app_name will + break url resolution. + """ + with self.assertRaises(NoReverseMatch): + reverse('example-list') + + +class TestAppUrlpatterns(APITestCase, URLPatternsTestCase): + apppatterns = ([ + url(r'^api/', include(router.urlpatterns)), + ], None) + + urlpatterns = [ + url(r'^', include(apppatterns)), + ] + + def test_reverse(self): + self.assertEqual(reverse('example-list'), '/api/example') + + +class TestAppUrlsAppName(APITestCase, URLPatternsTestCase): + apppatterns = ([ + url(r'^api/', include(router.urls)), + ], 'rest_framework') + + urlpatterns = [ + url(r'^', include(apppatterns)), + ] + + def test_reverse(self): + self.assertEqual(reverse('example-list'), '/api/example') + + def test_app_name_reverse(self): + self.assertEqual(reverse('rest_framework:example-list'), '/api/example') + + +class TestAppUrlsNamespace(APITestCase, URLPatternsTestCase): + apppatterns = ([ + url(r'^', include(router.urls)), + ], 'rest_framework') + + urlpatterns = [ + url(r'^api/v1/', include(apppatterns, namespace='v1')), + url(r'^api/v2/', include(apppatterns, namespace='v2')), + ] + + def test_reverse(self): + self.assertEqual(reverse('example-list'), '/api/v2/example') + + def test_app_name_reverse(self): + self.assertEqual(reverse('rest_framework:example-list'), '/api/v2/example') + + def test_namespace_reverse(self): + self.assertEqual(reverse('v1:example-list'), '/api/v1/example') + self.assertEqual(reverse('v2:example-list'), '/api/v2/example') diff --git a/tests/test_versioning.py b/tests/test_versioning.py index 7e650e275..3a19cdaac 100644 --- a/tests/test_versioning.py +++ b/tests/test_versioning.py @@ -143,17 +143,21 @@ class TestRequestVersion: class TestURLReversing(URLPatternsTestCase, APITestCase): - included = [ + versioned = [ url(r'^namespaced/$', dummy_view, name='another'), url(r'^example/(?P\d+)/$', dummy_pk_view, name='example-detail') ] - urlpatterns = [ - url(r'^v1/', include((included, 'v1'), namespace='v1')), + included = [ url(r'^another/$', dummy_view, name='another'), url(r'^(?P[v1|v2]+)/another/$', dummy_view, name='another'), ] + urlpatterns = [ + url(r'^v1/', include((versioned, 'rest_framework'), namespace='v1')), + url(r'^', include((included, 'rest_framework'))), + ] + def test_reverse_unversioned(self): view = ReverseView.as_view() @@ -314,8 +318,8 @@ class TestHyperlinkedRelatedField(URLPatternsTestCase, APITestCase): ] urlpatterns = [ - url(r'^v1/', include((included, 'v1'), namespace='v1')), - url(r'^v2/', include((included, 'v2'), namespace='v2')) + url(r'^v1/', include((included, 'rest_framework'), namespace='v1')), + url(r'^v2/', include((included, 'rest_framework'), namespace='v2')) ] def setUp(self): @@ -344,17 +348,21 @@ class TestNamespaceVersioningHyperlinkedRelatedFieldScheme(URLPatternsTestCase, nested = [ url(r'^namespaced/(?P\d+)/$', dummy_pk_view, name='nested'), ] - included = [ + versioned = [ url(r'^namespaced/(?P\d+)/$', dummy_pk_view, name='namespaced'), url(r'^nested/', include((nested, 'nested-namespace'), namespace='nested-namespace')) ] - urlpatterns = [ - url(r'^v1/', include((included, 'restframeworkv1'), namespace='v1')), - url(r'^v2/', include((included, 'restframeworkv2'), namespace='v2')), + included = [ url(r'^non-api/(?P\d+)/$', dummy_pk_view, name='non-api-view') ] + urlpatterns = [ + url(r'^v1/', include((versioned, 'rest_framework'), namespace='v1')), + url(r'^v2/', include((versioned, 'rest_framework'), namespace='v2')), + url(r'^', include((included, 'rest_framework'))), + ] + def _create_field(self, view_name, version): request = factory.get("/") request.versioning_scheme = NamespaceVersioning() @@ -381,6 +389,7 @@ class TestNamespaceVersioningHyperlinkedRelatedFieldScheme(URLPatternsTestCase, def test_non_api_url_is_properly_reversed_regardless_of_the_version(self): """ Regression test for #2711 + Note: non-api-views still need to be included in the 'rest_framework' namespace """ field = self._create_field('non-api-view', 'v1') assert field.to_representation(PKOnlyObject(10)) == 'http://testserver/non-api/10/' diff --git a/tests/test_viewsets.py b/tests/test_viewsets.py index 25feb0f37..30bb03144 100644 --- a/tests/test_viewsets.py +++ b/tests/test_viewsets.py @@ -62,7 +62,7 @@ router.register(r'actions-alt', ActionViewSet, base_name='actions-alt') urlpatterns = [ - url(r'^api/', include(router.urls)), + url(r'^api/', include(router.urlpatterns)), ]