diff --git a/README.md b/README.md index e2038ef..8548a71 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ class ArticleAdmin(DjangoObjectActions, admin.ModelAdmin): ## Usage -Defining new &_tool actions_ is just like defining regular [admin actions]. The +Defining new _tool actions_ is just like defining regular [admin actions]. The major difference is the functions for `django-object-actions` will take an object instance instead of a queryset (see _Re-using Admin Actions_ below). @@ -176,6 +176,25 @@ def get_change_actions(self, request, object_id, form_url): The same is true for changelist actions with `get_changelist_actions`. +### Using POST instead of GET for actions + +⚠️ This is a beta feature and subject to change + +Since actions usually change data, for safety and semantics, it would be +preferable that actions use a HTTP POST instead of a GET. + +You can configure an action to only use POST with: + +```python +@action(methods=("POST",), button_type="form") +``` + +One caveat is Django's styling is pinned to anchor tags[^1], so to maintain +visual consistency, we have to use anchor tags and use JavaScript to make it act +like the submit button of the form. + +[^1]: https://github.com/django/django/blob/826ef006681eae1e9b4bd0e4f18fa13713025cba/django/contrib/admin/static/admin/css/base.css#L786 + ### Alternate Installation 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. If you want an actions menu for each row of your changelist, check out [Django Admin Row Actions](https://github.com/DjangoAdminHackers/django-admin-row-actions). - diff --git a/django_object_actions/tests/test_admin.py b/django_object_actions/tests/test_admin.py index 3dffa07..70e268f 100644 --- a/django_object_actions/tests/test_admin.py +++ b/django_object_actions/tests/test_admin.py @@ -25,7 +25,7 @@ def test_action_on_a_model_with_uuid_pk_works(self): response = self.client.get(action_url) self.assertRedirects(response, comment_url) - @patch("django_object_actions.utils.ChangeActionView.get") + @patch("django_object_actions.utils.ChangeActionView.dispatch") def test_action_on_a_model_with_arbitrary_pk_works(self, mock_view): mock_view.return_value = HttpResponse() 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): self.assertTrue(mock_view.called) self.assertEqual(mock_view.call_args[1]["pk"], " i am a pk ") - @patch("django_object_actions.utils.ChangeActionView.get") + @patch("django_object_actions.utils.ChangeActionView.dispatch") def test_action_on_a_model_with_slash_in_pk_works(self, mock_view): mock_view.return_value = HttpResponse() action_url = "/admin/polls/comment/{0}/actions/hodor/".format("pk/slash") @@ -76,10 +76,16 @@ def test_changelist_template_context(self): self.assertIn("foo", response.context_data) def test_changelist_action_view(self): - url = "/admin/polls/choice/actions/delete_all/" + url = reverse("admin:polls_choice_actions", args=("delete_all",)) response = self.client.get(url) self.assertRedirects(response, "/admin/polls/choice/") + def test_changelist_action_post_only_tool_rejects_get(self): + poll = PollFactory.create() + url = reverse("admin:polls_choice_actions", args=(poll.pk, "reset_vote")) + response = self.client.get(url) + self.assertEqual(response.status_code, 405) + def test_changelist_nonexistent_action(self): url = "/admin/polls/choice/actions/xyzzy/" response = self.client.get(url) diff --git a/django_object_actions/utils.py b/django_object_actions/utils.py index 6bd3b60..2635d07 100644 --- a/django_object_actions/utils.py +++ b/django_object_actions/utils.py @@ -11,6 +11,9 @@ from django.views.generic.list import MultipleObjectMixin from django.urls import re_path, reverse +DEFAULT_METHODS_ALLOWED = ("GET", "POST") +DEFAULT_BUTTON_TYPE = "a" + class BaseDjangoObjectActions(object): """ @@ -159,7 +162,7 @@ def _get_tool_dict(self, tool_name): label=getattr(tool, "label", tool_name.replace("_", " ").capitalize()), standard_attrs=standard_attrs, custom_attrs=custom_attrs, - button_type=tool.button_type, + button_type=getattr(tool, "button_type", DEFAULT_BUTTON_TYPE), ) def _get_button_attrs(self, tool): @@ -249,8 +252,9 @@ def dispatch(self, request, tool, **kwargs): except KeyError: raise Http404("Action does not exist") - if request.method not in view.methods: - return HttpResponseNotAllowed(view.methods) + allowed_methods = getattr(view, "methods", DEFAULT_METHODS_ALLOWED) + if request.method.upper() not in allowed_methods: + return HttpResponseNotAllowed(allowed_methods) ret = view(request, *self.view_args) if isinstance(ret, HttpResponseBase): @@ -315,9 +319,14 @@ def decorated_function(self, request, queryset): def action( - function=None, *, permissions=None, description=None, label=None, attrs=None, - methods=('GET', 'POST'), - button_type='a', + function=None, + *, + permissions=None, + description=None, + label=None, + attrs=None, + methods=DEFAULT_METHODS_ALLOWED, + button_type=DEFAULT_BUTTON_TYPE, ): """ Conveniently add attributes to an action function: diff --git a/example_project/polls/admin.py b/example_project/polls/admin.py index 8d784a8..3c2ffdb 100644 --- a/example_project/polls/admin.py +++ b/example_project/polls/admin.py @@ -45,7 +45,7 @@ def decrement_vote(self, request, obj): def delete_all(self, request, queryset): self.message_user(request, "just kidding!") - @action(description="0") + @action(description="0", methods=("POST",), button_type="form") def reset_vote(self, request, obj): obj.votes = 0 obj.save()