diff --git a/tests/admin_views/test_actions.py b/tests/admin_views/test_actions.py
new file mode 100644
index 0000000000..f869fc2cc2
--- /dev/null
+++ b/tests/admin_views/test_actions.py
@@ -0,0 +1,366 @@
+import json
+
+from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
+from django.contrib.admin.views.main import IS_POPUP_VAR
+from django.contrib.auth.models import User
+from django.core import mail
+from django.template.loader import render_to_string
+from django.template.response import TemplateResponse
+from django.test import TestCase, override_settings
+from django.urls import reverse
+
+from .forms import MediaActionForm
+from .models import (
+ Actor, Answer, ExternalSubscriber, Question, Subscriber,
+ UnchangeableObject,
+)
+
+
+@override_settings(ROOT_URLCONF='admin_views.urls')
+class AdminActionsTest(TestCase):
+
+ @classmethod
+ def setUpTestData(cls):
+ cls.superuser = User.objects.create_superuser(username='super', password='secret', email='super@example.com')
+ cls.s1 = ExternalSubscriber.objects.create(name='John Doe', email='john@example.org')
+ cls.s2 = Subscriber.objects.create(name='Max Mustermann', email='max@example.org')
+
+ def setUp(self):
+ self.client.force_login(self.superuser)
+
+ def test_model_admin_custom_action(self):
+ """A custom action defined in a ModelAdmin method."""
+ action_data = {
+ ACTION_CHECKBOX_NAME: [self.s1.pk],
+ 'action': 'mail_admin',
+ 'index': 0,
+ }
+ self.client.post(reverse('admin:admin_views_subscriber_changelist'), action_data)
+ self.assertEqual(len(mail.outbox), 1)
+ self.assertEqual(mail.outbox[0].subject, 'Greetings from a ModelAdmin action')
+
+ def test_model_admin_default_delete_action(self):
+ action_data = {
+ ACTION_CHECKBOX_NAME: [self.s1.pk, self.s2.pk],
+ 'action': 'delete_selected',
+ 'index': 0,
+ }
+ delete_confirmation_data = {
+ ACTION_CHECKBOX_NAME: [self.s1.pk, self.s2.pk],
+ 'action': 'delete_selected',
+ 'post': 'yes',
+ }
+ confirmation = self.client.post(reverse('admin:admin_views_subscriber_changelist'), action_data)
+ self.assertIsInstance(confirmation, TemplateResponse)
+ self.assertContains(confirmation, 'Are you sure you want to delete the selected subscribers?')
+ self.assertContains(confirmation, '
Summary
')
+ self.assertContains(confirmation, 'Subscribers: 2')
+ self.assertContains(confirmation, 'External subscribers: 1')
+ self.assertContains(confirmation, ACTION_CHECKBOX_NAME, count=2)
+ self.client.post(reverse('admin:admin_views_subscriber_changelist'), delete_confirmation_data)
+ self.assertEqual(Subscriber.objects.count(), 0)
+
+ @override_settings(USE_THOUSAND_SEPARATOR=True, USE_L10N=True)
+ def test_non_localized_pk(self):
+ """
+ If USE_THOUSAND_SEPARATOR is set, the ids for the objects selected for
+ deletion are rendered without separators.
+ """
+ s = ExternalSubscriber.objects.create(id=9999)
+ action_data = {
+ ACTION_CHECKBOX_NAME: [s.pk, self.s2.pk],
+ 'action': 'delete_selected',
+ 'index': 0,
+ }
+ response = self.client.post(reverse('admin:admin_views_subscriber_changelist'), action_data)
+ self.assertTemplateUsed(response, 'admin/delete_selected_confirmation.html')
+ self.assertContains(response, 'value="9999"') # Instead of 9,999
+ self.assertContains(response, 'value="%s"' % self.s2.pk)
+
+ def test_model_admin_default_delete_action_protected(self):
+ """
+ The default delete action where some related objects are protected
+ from deletion.
+ """
+ q1 = Question.objects.create(question='Why?')
+ a1 = Answer.objects.create(question=q1, answer='Because.')
+ a2 = Answer.objects.create(question=q1, answer='Yes.')
+ q2 = Question.objects.create(question='Wherefore?')
+ action_data = {
+ ACTION_CHECKBOX_NAME: [q1.pk, q2.pk],
+ 'action': 'delete_selected',
+ 'index': 0,
+ }
+ delete_confirmation_data = action_data.copy()
+ delete_confirmation_data['post'] = 'yes'
+ response = self.client.post(reverse('admin:admin_views_question_changelist'), action_data)
+ self.assertContains(response, 'would require deleting the following protected related objects')
+ self.assertContains(
+ response,
+ 'Answer: Because.' % reverse('admin:admin_views_answer_change', args=(a1.pk,)),
+ html=True
+ )
+ self.assertContains(
+ response,
+ 'Answer: Yes.' % reverse('admin:admin_views_answer_change', args=(a2.pk,)),
+ html=True
+ )
+ # A POST request to delete protected objects displays the page which
+ # says the deletion is prohibited.
+ response = self.client.post(reverse('admin:admin_views_question_changelist'), delete_confirmation_data)
+ self.assertContains(response, 'would require deleting the following protected related objects')
+ self.assertEqual(Question.objects.count(), 2)
+
+ def test_model_admin_default_delete_action_no_change_url(self):
+ """
+ The default delete action doesn't break if a ModelAdmin removes the
+ change_view URL (#20640).
+ """
+ obj = UnchangeableObject.objects.create()
+ action_data = {
+ ACTION_CHECKBOX_NAME: obj.pk,
+ 'action': 'delete_selected',
+ 'index': '0',
+ }
+ response = self.client.post(reverse('admin:admin_views_unchangeableobject_changelist'), action_data)
+ # No 500 caused by NoReverseMatch
+ self.assertEqual(response.status_code, 200)
+ # The page doesn't display a link to the nonexistent change page.
+ self.assertContains(response, 'Unchangeable object: %s' % obj, 1, html=True)
+
+ def test_custom_function_mail_action(self):
+ """A custom action may be defined in a function."""
+ action_data = {
+ ACTION_CHECKBOX_NAME: [self.s1.pk],
+ 'action': 'external_mail',
+ 'index': 0,
+ }
+ self.client.post(reverse('admin:admin_views_externalsubscriber_changelist'), action_data)
+ self.assertEqual(len(mail.outbox), 1)
+ self.assertEqual(mail.outbox[0].subject, 'Greetings from a function action')
+
+ def test_custom_function_action_with_redirect(self):
+ """Another custom action defined in a function."""
+ action_data = {
+ ACTION_CHECKBOX_NAME: [self.s1.pk],
+ 'action': 'redirect_to',
+ 'index': 0,
+ }
+ response = self.client.post(reverse('admin:admin_views_externalsubscriber_changelist'), action_data)
+ self.assertEqual(response.status_code, 302)
+
+ def test_default_redirect(self):
+ """
+ Actions which don't return an HttpResponse are redirected to the same
+ page, retaining the querystring (which may contain changelist info).
+ """
+ action_data = {
+ ACTION_CHECKBOX_NAME: [self.s1.pk],
+ 'action': 'external_mail',
+ 'index': 0,
+ }
+ url = reverse('admin:admin_views_externalsubscriber_changelist') + '?o=1'
+ response = self.client.post(url, action_data)
+ self.assertRedirects(response, url)
+
+ def test_custom_function_action_streaming_response(self):
+ """A custom action may return a StreamingHttpResponse."""
+ action_data = {
+ ACTION_CHECKBOX_NAME: [self.s1.pk],
+ 'action': 'download',
+ 'index': 0,
+ }
+ response = self.client.post(reverse('admin:admin_views_externalsubscriber_changelist'), action_data)
+ content = b''.join(response.streaming_content)
+ self.assertEqual(content, b'This is the content of the file')
+ self.assertEqual(response.status_code, 200)
+
+ def test_custom_function_action_no_perm_response(self):
+ """A custom action may returns an HttpResponse with a 403 code."""
+ action_data = {
+ ACTION_CHECKBOX_NAME: [self.s1.pk],
+ 'action': 'no_perm',
+ 'index': 0,
+ }
+ response = self.client.post(reverse('admin:admin_views_externalsubscriber_changelist'), action_data)
+ self.assertEqual(response.status_code, 403)
+ self.assertEqual(response.content, b'No permission to perform this action')
+
+ def test_actions_ordering(self):
+ """Actions are ordered as expected."""
+ response = self.client.get(reverse('admin:admin_views_externalsubscriber_changelist'))
+ self.assertContains(response, '''