Skip to content

Commit 494d581

Browse files
authored
feat: add a way to make a POST only action (#174)
Followup to #168 to get CI to pass again, documents how to make a POST only action, and adds some test coverage. There are still a few cleanup issues but this should get things moving on POST only actions again.
1 parent 1274ae7 commit 494d581

File tree

4 files changed

+45
-12
lines changed

4 files changed

+45
-12
lines changed

README.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ class ArticleAdmin(DjangoObjectActions, admin.ModelAdmin):
3232

3333
## Usage
3434

35-
Defining new &_tool actions_ is just like defining regular [admin actions]. The
35+
Defining new _tool actions_ is just like defining regular [admin actions]. The
3636
major difference is the functions for `django-object-actions` will take an
3737
object instance instead of a queryset (see _Re-using Admin Actions_ below).
3838

@@ -176,6 +176,25 @@ def get_change_actions(self, request, object_id, form_url):
176176

177177
The same is true for changelist actions with `get_changelist_actions`.
178178

179+
### Using POST instead of GET for actions
180+
181+
⚠️ This is a beta feature and subject to change
182+
183+
Since actions usually change data, for safety and semantics, it would be
184+
preferable that actions use a HTTP POST instead of a GET.
185+
186+
You can configure an action to only use POST with:
187+
188+
```python
189+
@action(methods=("POST",), button_type="form")
190+
```
191+
192+
One caveat is Django's styling is pinned to anchor tags[^1], so to maintain
193+
visual consistency, we have to use anchor tags and use JavaScript to make it act
194+
like the submit button of the form.
195+
196+
[^1]: https://github.com/django/django/blob/826ef006681eae1e9b4bd0e4f18fa13713025cba/django/contrib/admin/static/admin/css/base.css#L786
197+
179198
### Alternate Installation
180199

181200
You don't have to add this to `INSTALLED_APPS`, all you need to to do
@@ -249,4 +268,3 @@ open a simple form in a modal dialog.
249268

250269
If you want an actions menu for each row of your changelist, check out [Django
251270
Admin Row Actions](https://github.com/DjangoAdminHackers/django-admin-row-actions).
252-

django_object_actions/tests/test_admin.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def test_action_on_a_model_with_uuid_pk_works(self):
2525
response = self.client.get(action_url)
2626
self.assertRedirects(response, comment_url)
2727

28-
@patch("django_object_actions.utils.ChangeActionView.get")
28+
@patch("django_object_actions.utils.ChangeActionView.dispatch")
2929
def test_action_on_a_model_with_arbitrary_pk_works(self, mock_view):
3030
mock_view.return_value = HttpResponse()
3131
action_url = "/admin/polls/comment/{0}/actions/hodor/".format(" i am a pk ")
@@ -35,7 +35,7 @@ def test_action_on_a_model_with_arbitrary_pk_works(self, mock_view):
3535
self.assertTrue(mock_view.called)
3636
self.assertEqual(mock_view.call_args[1]["pk"], " i am a pk ")
3737

38-
@patch("django_object_actions.utils.ChangeActionView.get")
38+
@patch("django_object_actions.utils.ChangeActionView.dispatch")
3939
def test_action_on_a_model_with_slash_in_pk_works(self, mock_view):
4040
mock_view.return_value = HttpResponse()
4141
action_url = "/admin/polls/comment/{0}/actions/hodor/".format("pk/slash")
@@ -76,10 +76,16 @@ def test_changelist_template_context(self):
7676
self.assertIn("foo", response.context_data)
7777

7878
def test_changelist_action_view(self):
79-
url = "/admin/polls/choice/actions/delete_all/"
79+
url = reverse("admin:polls_choice_actions", args=("delete_all",))
8080
response = self.client.get(url)
8181
self.assertRedirects(response, "/admin/polls/choice/")
8282

83+
def test_changelist_action_post_only_tool_rejects_get(self):
84+
poll = PollFactory.create()
85+
url = reverse("admin:polls_choice_actions", args=(poll.pk, "reset_vote"))
86+
response = self.client.get(url)
87+
self.assertEqual(response.status_code, 405)
88+
8389
def test_changelist_nonexistent_action(self):
8490
url = "/admin/polls/choice/actions/xyzzy/"
8591
response = self.client.get(url)

django_object_actions/utils.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
from django.views.generic.list import MultipleObjectMixin
1212
from django.urls import re_path, reverse
1313

14+
DEFAULT_METHODS_ALLOWED = ("GET", "POST")
15+
DEFAULT_BUTTON_TYPE = "a"
16+
1417

1518
class BaseDjangoObjectActions(object):
1619
"""
@@ -159,7 +162,7 @@ def _get_tool_dict(self, tool_name):
159162
label=getattr(tool, "label", tool_name.replace("_", " ").capitalize()),
160163
standard_attrs=standard_attrs,
161164
custom_attrs=custom_attrs,
162-
button_type=tool.button_type,
165+
button_type=getattr(tool, "button_type", DEFAULT_BUTTON_TYPE),
163166
)
164167

165168
def _get_button_attrs(self, tool):
@@ -249,8 +252,9 @@ def dispatch(self, request, tool, **kwargs):
249252
except KeyError:
250253
raise Http404("Action does not exist")
251254

252-
if request.method not in view.methods:
253-
return HttpResponseNotAllowed(view.methods)
255+
allowed_methods = getattr(view, "methods", DEFAULT_METHODS_ALLOWED)
256+
if request.method.upper() not in allowed_methods:
257+
return HttpResponseNotAllowed(allowed_methods)
254258

255259
ret = view(request, *self.view_args)
256260
if isinstance(ret, HttpResponseBase):
@@ -315,9 +319,14 @@ def decorated_function(self, request, queryset):
315319

316320

317321
def action(
318-
function=None, *, permissions=None, description=None, label=None, attrs=None,
319-
methods=('GET', 'POST'),
320-
button_type='a',
322+
function=None,
323+
*,
324+
permissions=None,
325+
description=None,
326+
label=None,
327+
attrs=None,
328+
methods=DEFAULT_METHODS_ALLOWED,
329+
button_type=DEFAULT_BUTTON_TYPE,
321330
):
322331
"""
323332
Conveniently add attributes to an action function:

example_project/polls/admin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def decrement_vote(self, request, obj):
4545
def delete_all(self, request, queryset):
4646
self.message_user(request, "just kidding!")
4747

48-
@action(description="0")
48+
@action(description="0", methods=("POST",), button_type="form")
4949
def reset_vote(self, request, obj):
5050
obj.votes = 0
5151
obj.save()

0 commit comments

Comments
 (0)