diff --git a/CHANGELOG.md b/CHANGELOG.md index d8ca3aa..917ca88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog +### Feature +* Drop support for GET method. All action are now invoked with POST method. +* Add option to include inline forms with actions. + +### Breaking +* When dealing with a secondary form in action, you cannot simply check the http method to determine if the form should be rendered or processed. You need to check for specific form inputs in POST payload. ## v4.1.0 (2022-11-14) ### Feature diff --git a/README.md b/README.md index da479d0..9f826aa 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,25 @@ increment_vote.attrs = { } ``` +## Adding inline forms + +You can add parameters to the action button by adding Django [Form](https://docs.djangoproject.com/en/4.1/ref/forms/api/#django.forms.Form) object to it. Parameter values can be read form request's `POST` property. + +```python +from django import forms + +class ResetAllForm(forms.Form): + new_value = forms.IntegerField(initial=0) + +def reset_all(self, request, queryset): + new_value = int(request.POST["new_value"]) + queryset.update(value=new_value) +reset_all.form = ResetAllForm() +``` + +Each action with form assigned is rendered in it's own, separate row. + + ### Programmatically Disabling Actions You can programmatically disable registered actions by defining your own diff --git a/django_object_actions/static/django_object_actions/css/style.css b/django_object_actions/static/django_object_actions/css/style.css new file mode 100644 index 0000000..ef40852 --- /dev/null +++ b/django_object_actions/static/django_object_actions/css/style.css @@ -0,0 +1,6 @@ +ul.object-tools.django-object-actions { + margin-top: 0; + padding-top: 16px; + position: relative; + top: -24px; +} diff --git a/django_object_actions/templates/django_object_actions/change_form.html b/django_object_actions/templates/django_object_actions/change_form.html index 1b0f80d..60b1783 100644 --- a/django_object_actions/templates/django_object_actions/change_form.html +++ b/django_object_actions/templates/django_object_actions/change_form.html @@ -1,18 +1,56 @@ {% extends "admin/change_form.html" %} {% load add_preserved_filters from admin_urls %} +{% load static %} + +{% block extrastyle %} +{{ block.super }} + +{% endblock %} {% block object-tools-items %} {% for tool in objectactions %} -
  • - {% url tools_view_name pk=object_id tool=tool.name as action_url %} - - {{ tool.label|capfirst }} - -
  • + {% if not tool.has_inline_form %} +
  • + {% url tools_view_name pk=object_id tool=tool.name as action_url %} +
    + {% csrf_token %} + + {{ tool.label|capfirst }} + +
    +
  • + {% endif %} {% endfor %} {{ block.super }} {% endblock %} + +{% block object-tools %} + {{ block.super }} + {% for tool in objectactions %} + {% if tool.has_inline_form %} + {% url tools_view_name pk=object_id tool=tool.name as action_url %} +
    +
    + {% csrf_token %} + +
    +
    + {% endif %} + {% endfor %} +
    +{% endblock %} diff --git a/django_object_actions/templates/django_object_actions/change_list.html b/django_object_actions/templates/django_object_actions/change_list.html index 2fd9082..906ef0c 100644 --- a/django_object_actions/templates/django_object_actions/change_list.html +++ b/django_object_actions/templates/django_object_actions/change_list.html @@ -1,18 +1,56 @@ {% extends "admin/change_list.html" %} {% load add_preserved_filters from admin_urls %} +{% load static %} + +{% block extrastyle %} +{{ block.super }} + +{% endblock %} {% block object-tools-items %} {% for tool in objectactions %} -
  • - {% url tools_view_name tool=tool.name as action_url %} - - {{ tool.label|capfirst }} - -
  • + {% if not tool.has_inline_form %} +
  • + {% url tools_view_name tool=tool.name as action_url %} +
    + {% csrf_token %} + + {{ tool.label|capfirst }} + +
    +
  • + {% endif %} {% endfor %} {{ block.super }} {% endblock %} + +{% block object-tools %} + {{ block.super }} + {% for tool in objectactions %} + {% if tool.has_inline_form %} + {% url tools_view_name tool=tool.name as action_url %} +
    +
    + {% csrf_token %} + +
    +
    + {% endif %} + {% endfor %} +
    +{% endblock %} diff --git a/django_object_actions/templates/django_object_actions/modal_form.html b/django_object_actions/templates/django_object_actions/modal_form.html new file mode 100644 index 0000000..cbb87be --- /dev/null +++ b/django_object_actions/templates/django_object_actions/modal_form.html @@ -0,0 +1,15 @@ +{% extends "admin/base_site.html" %} +{% load add_preserved_filters from admin_urls %} + +{% block content %} +
    +
    + {% csrf_token %} + {{ form.as_table }} +
    + + +
    +
    +
    +{% endblock %} diff --git a/django_object_actions/tests/test_admin.py b/django_object_actions/tests/test_admin.py index db6e7da..ecbda4b 100644 --- a/django_object_actions/tests/test_admin.py +++ b/django_object_actions/tests/test_admin.py @@ -8,6 +8,7 @@ from .tests import LoggedInTestCase from example_project.polls.factories import ( + ChoiceFactory, CommentFactory, PollFactory, RelatedDataFactory, @@ -21,25 +22,25 @@ def test_action_on_a_model_with_uuid_pk_works(self): action_url = "/admin/polls/comment/{0}/actions/hodor/".format(comment.pk) # sanity check that url has a uuid self.assertIn("-", action_url) - response = self.client.get(action_url) + response = self.client.post(action_url) self.assertRedirects(response, comment_url) - @patch("django_object_actions.utils.ChangeActionView.get") + @patch("django_object_actions.utils.ChangeActionView.post") 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 ") - self.client.get(action_url) + self.client.post(action_url) 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.post") 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") - self.client.get(action_url) + self.client.post(action_url) self.assertTrue(mock_view.called) self.assertEqual(mock_view.call_args[1]["pk"], "pk/slash") @@ -55,7 +56,7 @@ def test_action_on_a_model_with_complex_id(self): quote(related_data.pk) ) - response = self.client.get(action_url) + response = self.client.post(action_url) self.assertNotEqual(response.status_code, 404) self.assertRedirects(response, related_data_url) @@ -76,12 +77,12 @@ def test_changelist_template_context(self): def test_changelist_action_view(self): url = "/admin/polls/choice/actions/delete_all/" - response = self.client.get(url) + response = self.client.post(url) self.assertRedirects(response, "/admin/polls/choice/") def test_changelist_nonexistent_action(self): url = "/admin/polls/choice/actions/xyzzy/" - response = self.client.get(url) + response = self.client.post(url) self.assertEqual(response.status_code, 404) def test_get_changelist_can_remove_action(self): @@ -94,13 +95,23 @@ def test_get_changelist_can_remove_action(self): response = self.client.get(admin_change_url) self.assertIn(action_url, response.rendered_content) - response = self.client.get(action_url) # Click on the button + response = self.client.post(action_url) # Click on the button self.assertRedirects(response, admin_change_url) # button is not in the admin anymore response = self.client.get(admin_change_url) self.assertNotIn(action_url, response.rendered_content) + def test_changelist_get_method_action_view(self): + url = "/admin/polls/choice/actions/delete_all/" + response = self.client.get(url) + self.assertEqual(response.status_code, 405) + + def test_changelist_get_method_nonexistent_action(self): + url = "/admin/polls/choice/actions/xyzzy/" + response = self.client.get(url) + self.assertEqual(response.status_code, 405) + class ChangeListTests(LoggedInTestCase): def test_changelist_template_context(self): @@ -122,5 +133,38 @@ def test_redirect_back_from_secondary_admin(self): action_url = "/support/polls/poll/1/actions/question_mark/" self.assertTrue(admin_change_url.startswith("/support/")) - response = self.client.get(action_url) + response = self.client.post(action_url) self.assertRedirects(response, admin_change_url) + + +class FormTests(LoggedInTestCase): + def test_form_is_rendered_in_change_view(self): + choice = ChoiceFactory() + admin_change_url = reverse("admin:polls_choice_change", args=(choice.pk,)) + + response = self.client.get(admin_change_url) + + # form is in the admin + action_url_lookup = 'action="/admin/polls/choice/1/actions/change_votes/"' + self.assertIn(action_url_lookup, response.rendered_content) + form_lookup = '
    {% csrf_token %} Delete All Choices? - +