Skip to content

Commit 4bb5de5

Browse files
author
Ryan P Kilby
committed
Add method mapping to ViewSet actions
1 parent 460f8d4 commit 4bb5de5

File tree

6 files changed

+153
-12
lines changed

6 files changed

+153
-12
lines changed

rest_framework/decorators.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,8 @@ def action(methods=None, detail=None, name=None, url_path=None, url_name=None, *
146146
)
147147

148148
def decorator(func):
149-
func.bind_to_methods = methods
149+
func.mapping = MethodMapper(func, methods)
150+
150151
func.detail = detail
151152
func.name = name if name else pretty_name(func.__name__)
152153
func.url_path = url_path if url_path else func.__name__
@@ -156,10 +157,70 @@ def decorator(func):
156157
'name': func.name,
157158
'description': func.__doc__ or None
158159
})
160+
159161
return func
160162
return decorator
161163

162164

165+
class MethodMapper(dict):
166+
"""
167+
Enables mapping HTTP methods to different ViewSet methods for a single,
168+
logical action.
169+
170+
Example usage:
171+
172+
class MyViewSet(ViewSet):
173+
174+
@action(detail=False)
175+
def example(self, request, **kwargs):
176+
...
177+
178+
@example.mapping.post
179+
def create_example(self, request, **kwargs):
180+
...
181+
"""
182+
183+
def __init__(self, action, methods):
184+
self.action = action
185+
for method in methods:
186+
self[method] = self.action.__name__
187+
188+
def _map(self, method, func):
189+
assert method not in self, (
190+
"Method '%s' has already been mapped to '.%s'." % (method, self[method]))
191+
assert func.__name__ != self.action.__name__, (
192+
"Method mapping does not behave like the property decorator. You "
193+
"cannot use the same method name for each mapping declaration.")
194+
195+
self[method] = func.__name__
196+
197+
return func
198+
199+
def get(self, func):
200+
return self._map('get', func)
201+
202+
def post(self, func):
203+
return self._map('post', func)
204+
205+
def put(self, func):
206+
return self._map('put', func)
207+
208+
def patch(self, func):
209+
return self._map('patch', func)
210+
211+
def delete(self, func):
212+
return self._map('delete', func)
213+
214+
def head(self, func):
215+
return self._map('head', func)
216+
217+
def options(self, func):
218+
return self._map('options', func)
219+
220+
def trace(self, func):
221+
return self._map('trace', func)
222+
223+
163224
def detail_route(methods=None, **kwargs):
164225
"""
165226
Used to mark a method on a ViewSet that should be routed for detail requests.

rest_framework/routers.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,8 +208,7 @@ def _get_dynamic_route(self, route, action):
208208

209209
return Route(
210210
url=route.url.replace('{url_path}', url_path),
211-
mapping={http_method: action.__name__
212-
for http_method in action.bind_to_methods},
211+
mapping=action.mapping,
213212
name=route.name.replace('{url_name}', action.url_name),
214213
detail=route.detail,
215214
initkwargs=initkwargs,

rest_framework/viewsets.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131

3232

3333
def _is_extra_action(attr):
34-
return hasattr(attr, 'bind_to_methods')
34+
return hasattr(attr, 'mapping')
3535

3636

3737
class ViewSetMixin(object):

tests/test_decorators.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ def test_defaults(self):
177177
def test_action(request):
178178
"""Description"""
179179

180-
assert test_action.bind_to_methods == ['get']
180+
assert test_action.mapping == {'get': 'test_action'}
181181
assert test_action.detail is True
182182
assert test_action.name == 'Test action'
183183
assert test_action.url_path == 'test_action'
@@ -195,6 +195,42 @@ def test_action(request):
195195

196196
assert str(excinfo.value) == "@action() missing required argument: 'detail'"
197197

198+
def test_method_mapping(self):
199+
@action(detail=False)
200+
def test_action(request):
201+
pass
202+
203+
@test_action.mapping.post
204+
def test_action_post(request):
205+
pass
206+
207+
# The secondary handler methods should not have the action attributes
208+
for name in ['mapping', 'detail', 'name', 'url_path', 'url_name', 'kwargs']:
209+
assert hasattr(test_action, name) and not hasattr(test_action_post, name)
210+
211+
def test_method_mapping_already_mapped(self):
212+
@action(detail=True)
213+
def test_action(request):
214+
pass
215+
216+
msg = "Method 'get' has already been mapped to '.test_action'."
217+
with self.assertRaisesMessage(AssertionError, msg):
218+
@test_action.mapping.get
219+
def test_action_get(request):
220+
pass
221+
222+
def test_method_mapping_overwrite(self):
223+
@action(detail=True)
224+
def test_action():
225+
pass
226+
227+
msg = ("Method mapping does not behave like the property decorator. You "
228+
"cannot use the same method name for each mapping declaration.")
229+
with self.assertRaisesMessage(AssertionError, msg):
230+
@test_action.mapping.post
231+
def test_action():
232+
pass
233+
198234
def test_detail_route_deprecation(self):
199235
with pytest.warns(PendingDeprecationWarning) as record:
200236
@detail_route()

tests/test_routers.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +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
10+
from django.urls import resolve, reverse
1111

1212
from rest_framework import permissions, serializers, viewsets
1313
from rest_framework.compat import get_regex_pattern
@@ -107,8 +107,23 @@ def action1(self, request, *args, **kwargs):
107107
def action2(self, request, *args, **kwargs):
108108
return Response({'method': 'action2'})
109109

110+
@action(methods=['post'], detail=True)
111+
def action3(self, request, pk, *args, **kwargs):
112+
return Response({'post': pk})
113+
114+
@action3.mapping.delete
115+
def action3_delete(self, request, pk, *args, **kwargs):
116+
return Response({'delete': pk})
117+
118+
119+
class TestSimpleRouter(URLPatternsTestCase, TestCase):
120+
router = SimpleRouter()
121+
router.register('basics', BasicViewSet, base_name='basic')
122+
123+
urlpatterns = [
124+
url(r'^api/', include(router.urls)),
125+
]
110126

111-
class TestSimpleRouter(TestCase):
112127
def setUp(self):
113128
self.router = SimpleRouter()
114129

@@ -127,6 +142,21 @@ def test_action_routes(self):
127142
'delete': 'action2',
128143
}
129144

145+
assert routes[2].url == '^{prefix}/{lookup}/action3{trailing_slash}$'
146+
assert routes[2].mapping == {
147+
'post': 'action3',
148+
'delete': 'action3_delete',
149+
}
150+
151+
def test_multiple_action_handlers(self):
152+
# Standard action
153+
response = self.client.post(reverse('basic-action3', args=[1]))
154+
assert response.data == {'post': '1'}
155+
156+
# Additional handler registered with MethodMapper
157+
response = self.client.delete(reverse('basic-action3', args=[1]))
158+
assert response.data == {'delete': '1'}
159+
130160

131161
class TestRootView(URLPatternsTestCase, TestCase):
132162
urlpatterns = [

tests/test_schemas.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ def custom_list_action(self, request):
9797

9898
@action(methods=['post', 'get'], detail=False, serializer_class=EmptySerializer)
9999
def custom_list_action_multiple_methods(self, request):
100+
"""Custom description."""
101+
return super(ExampleViewSet, self).list(self, request)
102+
103+
@custom_list_action_multiple_methods.mapping.delete
104+
def custom_list_action_multiple_methods_delete(self, request):
105+
"""Deletion description."""
100106
return super(ExampleViewSet, self).list(self, request)
101107

102108
def get_serializer(self, *args, **kwargs):
@@ -147,7 +153,8 @@ def test_anonymous_request(self):
147153
'custom_list_action_multiple_methods': {
148154
'read': coreapi.Link(
149155
url='/example/custom_list_action_multiple_methods/',
150-
action='get'
156+
action='get',
157+
description='Custom description.',
151158
)
152159
},
153160
'read': coreapi.Link(
@@ -238,12 +245,19 @@ def test_authenticated_request(self):
238245
'custom_list_action_multiple_methods': {
239246
'read': coreapi.Link(
240247
url='/example/custom_list_action_multiple_methods/',
241-
action='get'
248+
action='get',
249+
description='Custom description.',
242250
),
243251
'create': coreapi.Link(
244252
url='/example/custom_list_action_multiple_methods/',
245-
action='post'
246-
)
253+
action='post',
254+
description='Custom description.',
255+
),
256+
'delete': coreapi.Link(
257+
url='/example/custom_list_action_multiple_methods/',
258+
action='delete',
259+
description='Deletion description.',
260+
),
247261
},
248262
'update': coreapi.Link(
249263
url='/example/{id}/',
@@ -526,7 +540,8 @@ def test_schema_for_regular_views(self):
526540
'custom_list_action_multiple_methods': {
527541
'read': coreapi.Link(
528542
url='/example1/custom_list_action_multiple_methods/',
529-
action='get'
543+
action='get',
544+
description='Custom description.',
530545
)
531546
},
532547
'read': coreapi.Link(

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy