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 Permission, 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 .admin import SubscriberAdmin from .forms import MediaActionForm from .models import ( Actor, Answer, Book, 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, '<h2>Summary</h2>') self.assertContains(confirmation, '<li>Subscribers: 2</li>') self.assertContains(confirmation, '<li>External subscribers: 1</li>') 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) def test_default_delete_action_nonexistent_pk(self): self.assertFalse(Subscriber.objects.filter(id=9998).exists()) action_data = { ACTION_CHECKBOX_NAME: ['9998'], 'action': 'delete_selected', 'index': 0, } response = self.client.post(reverse('admin:admin_views_subscriber_changelist'), action_data) self.assertContains(response, 'Are you sure you want to delete the selected subscribers?') self.assertContains(response, '<ul></ul>', html=True) @override_settings(USE_THOUSAND_SEPARATOR=True, USE_L10N=True, NUMBER_GROUPING=3) 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, '<li>Answer: <a href="%s">Because.</a></li>' % reverse('admin:admin_views_answer_change', args=(a1.pk,)), html=True ) self.assertContains( response, '<li>Answer: <a href="%s">Yes.</a></li>' % 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. The page doesn't display a link to # the nonexistent change page. self.assertContains(response, '<li>Unchangeable object: %s</li>' % obj, 1, html=True) def test_delete_queryset_hook(self): delete_confirmation_data = { ACTION_CHECKBOX_NAME: [self.s1.pk, self.s2.pk], 'action': 'delete_selected', 'post': 'yes', 'index': 0, } SubscriberAdmin.overridden = False self.client.post(reverse('admin:admin_views_subscriber_changelist'), delete_confirmation_data) # SubscriberAdmin.delete_queryset() sets overridden to True. self.assertIs(SubscriberAdmin.overridden, True) self.assertEqual(Subscriber.objects.all().count(), 0) def test_delete_selected_uses_get_deleted_objects(self): """The delete_selected action uses ModelAdmin.get_deleted_objects().""" book = Book.objects.create(name='Test Book') data = { ACTION_CHECKBOX_NAME: [book.pk], 'action': 'delete_selected', 'index': 0, } response = self.client.post(reverse('admin2:admin_views_book_changelist'), data) # BookAdmin.get_deleted_objects() returns custom text. self.assertContains(response, 'a deletable object') 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, '''<label>Action: <select name="action" required> <option value="" selected>---------</option> <option value="delete_selected">Delete selected external subscribers</option> <option value="redirect_to">Redirect to (Awesome action)</option> <option value="external_mail">External mail (Another awesome action)</option> <option value="download">Download subscription</option> <option value="no_perm">No permission to run</option> </select>''', html=True) def test_model_without_action(self): """A ModelAdmin might not have any actions.""" response = self.client.get(reverse('admin:admin_views_oldsubscriber_changelist')) self.assertIsNone(response.context['action_form']) self.assertNotContains( response, '<input type="checkbox" class="action-select"', msg_prefix='Found an unexpected action toggle checkboxbox in response' ) self.assertNotContains(response, '<input type="checkbox" class="action-select"') def test_model_without_action_still_has_jquery(self): """ A ModelAdmin without any actions still has jQuery included on the page. """ response = self.client.get(reverse('admin:admin_views_oldsubscriber_changelist')) self.assertIsNone(response.context['action_form']) self.assertContains( response, 'jquery.min.js', msg_prefix='jQuery missing from admin pages for model with no admin actions' ) def test_action_column_class(self): """The checkbox column class is present in the response.""" response = self.client.get(reverse('admin:admin_views_subscriber_changelist')) self.assertIsNotNone(response.context['action_form']) self.assertContains(response, 'action-checkbox-column') def test_multiple_actions_form(self): """ Actions come from the form whose submit button was pressed (#10618). """ action_data = { ACTION_CHECKBOX_NAME: [self.s1.pk], # Two different actions selected on the two forms... 'action': ['external_mail', 'delete_selected'], # ...but "go" was clicked on the top form. 'index': 0 } self.client.post(reverse('admin:admin_views_externalsubscriber_changelist'), action_data) # The action sends mail rather than deletes. self.assertEqual(len(mail.outbox), 1) self.assertEqual(mail.outbox[0].subject, 'Greetings from a function action') def test_media_from_actions_form(self): """ The action form's media is included in the changelist view's media. """ response = self.client.get(reverse('admin:admin_views_subscriber_changelist')) media_path = MediaActionForm.Media.js[0] self.assertIsInstance(response.context['action_form'], MediaActionForm) self.assertIn('media', response.context) self.assertIn(media_path, response.context['media']._js) self.assertContains(response, media_path) def test_user_message_on_none_selected(self): """ User sees a warning when 'Go' is pressed and no items are selected. """ action_data = { ACTION_CHECKBOX_NAME: [], 'action': 'delete_selected', 'index': 0, } url = reverse('admin:admin_views_subscriber_changelist') response = self.client.post(url, action_data) self.assertRedirects(response, url, fetch_redirect_response=False) response = self.client.get(response.url) msg = 'Items must be selected in order to perform actions on them. No items have been changed.' self.assertContains(response, msg) self.assertEqual(Subscriber.objects.count(), 2) def test_user_message_on_no_action(self): """ User sees a warning when 'Go' is pressed and no action is selected. """ action_data = { ACTION_CHECKBOX_NAME: [self.s1.pk, self.s2.pk], 'action': '', 'index': 0, } url = reverse('admin:admin_views_subscriber_changelist') response = self.client.post(url, action_data) self.assertRedirects(response, url, fetch_redirect_response=False) response = self.client.get(response.url) self.assertContains(response, 'No action selected.') self.assertEqual(Subscriber.objects.count(), 2) def test_selection_counter(self): """The selection counter is there.""" response = self.client.get(reverse('admin:admin_views_subscriber_changelist')) self.assertContains(response, '0 of 2 selected') def test_popup_actions(self): """ Actions aren't shown in popups.""" changelist_url = reverse('admin:admin_views_subscriber_changelist') response = self.client.get(changelist_url) self.assertIsNotNone(response.context['action_form']) response = self.client.get(changelist_url + '?%s' % IS_POPUP_VAR) self.assertIsNone(response.context['action_form']) def test_popup_template_response_on_add(self): """ Success on popups shall be rendered from template in order to allow easy customization. """ response = self.client.post( reverse('admin:admin_views_actor_add') + '?%s=1' % IS_POPUP_VAR, {'name': 'Troy McClure', 'age': '55', IS_POPUP_VAR: '1'} ) self.assertEqual(response.status_code, 200) self.assertEqual(response.template_name, [ 'admin/admin_views/actor/popup_response.html', 'admin/admin_views/popup_response.html', 'admin/popup_response.html', ]) self.assertTemplateUsed(response, 'admin/popup_response.html') def test_popup_template_response_on_change(self): instance = Actor.objects.create(name='David Tennant', age=45) response = self.client.post( reverse('admin:admin_views_actor_change', args=(instance.pk,)) + '?%s=1' % IS_POPUP_VAR, {'name': 'David Tennant', 'age': '46', IS_POPUP_VAR: '1'} ) self.assertEqual(response.status_code, 200) self.assertEqual(response.template_name, [ 'admin/admin_views/actor/popup_response.html', 'admin/admin_views/popup_response.html', 'admin/popup_response.html', ]) self.assertTemplateUsed(response, 'admin/popup_response.html') def test_popup_template_response_on_delete(self): instance = Actor.objects.create(name='David Tennant', age=45) response = self.client.post( reverse('admin:admin_views_actor_delete', args=(instance.pk,)) + '?%s=1' % IS_POPUP_VAR, {IS_POPUP_VAR: '1'} ) self.assertEqual(response.status_code, 200) self.assertEqual(response.template_name, [ 'admin/admin_views/actor/popup_response.html', 'admin/admin_views/popup_response.html', 'admin/popup_response.html', ]) self.assertTemplateUsed(response, 'admin/popup_response.html') def test_popup_template_escaping(self): popup_response_data = json.dumps({ 'new_value': 'new_value\\', 'obj': 'obj\\', 'value': 'value\\', }) context = { 'popup_response_data': popup_response_data, } output = render_to_string('admin/popup_response.html', context) self.assertIn( r'"value\\"', output ) self.assertIn( r'"new_value\\"', output ) self.assertIn( r'"obj\\"', output ) @override_settings(ROOT_URLCONF='admin_views.urls') class AdminActionsPermissionTests(TestCase): @classmethod def setUpTestData(cls): cls.s1 = ExternalSubscriber.objects.create(name='John Doe', email='john@example.org') cls.s2 = Subscriber.objects.create(name='Max Mustermann', email='max@example.org') cls.user = User.objects.create_user( username='user', password='secret', email='user@example.com', is_staff=True, ) permission = Permission.objects.get(codename='change_subscriber') cls.user.user_permissions.add(permission) def setUp(self): self.client.force_login(self.user) def test_model_admin_no_delete_permission(self): """ Permission is denied if the user doesn't have delete permission for the model (Subscriber). """ action_data = { ACTION_CHECKBOX_NAME: [self.s1.pk], 'action': 'delete_selected', } url = reverse('admin:admin_views_subscriber_changelist') response = self.client.post(url, action_data) self.assertRedirects(response, url, fetch_redirect_response=False) response = self.client.get(response.url) self.assertContains(response, 'No action selected.') def test_model_admin_no_delete_permission_externalsubscriber(self): """ Permission is denied if the user doesn't have delete permission for a related model (ExternalSubscriber). """ permission = Permission.objects.get(codename='delete_subscriber') self.user.user_permissions.add(permission) delete_confirmation_data = { ACTION_CHECKBOX_NAME: [self.s1.pk, self.s2.pk], 'action': 'delete_selected', 'post': 'yes', } response = self.client.post(reverse('admin:admin_views_subscriber_changelist'), delete_confirmation_data) self.assertEqual(response.status_code, 403)