test_actions.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. import json
  2. from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
  3. from django.contrib.admin.views.main import IS_POPUP_VAR
  4. from django.contrib.auth.models import Permission, User
  5. from django.core import mail
  6. from django.template.loader import render_to_string
  7. from django.template.response import TemplateResponse
  8. from django.test import TestCase, override_settings
  9. from django.urls import reverse
  10. from .admin import SubscriberAdmin
  11. from .forms import MediaActionForm
  12. from .models import (
  13. Actor, Answer, Book, ExternalSubscriber, Question, Subscriber,
  14. UnchangeableObject,
  15. )
  16. @override_settings(ROOT_URLCONF='admin_views.urls')
  17. class AdminActionsTest(TestCase):
  18. @classmethod
  19. def setUpTestData(cls):
  20. cls.superuser = User.objects.create_superuser(username='super', password='secret', email='super@example.com')
  21. cls.s1 = ExternalSubscriber.objects.create(name='John Doe', email='john@example.org')
  22. cls.s2 = Subscriber.objects.create(name='Max Mustermann', email='max@example.org')
  23. def setUp(self):
  24. self.client.force_login(self.superuser)
  25. def test_model_admin_custom_action(self):
  26. """A custom action defined in a ModelAdmin method."""
  27. action_data = {
  28. ACTION_CHECKBOX_NAME: [self.s1.pk],
  29. 'action': 'mail_admin',
  30. 'index': 0,
  31. }
  32. self.client.post(reverse('admin:admin_views_subscriber_changelist'), action_data)
  33. self.assertEqual(len(mail.outbox), 1)
  34. self.assertEqual(mail.outbox[0].subject, 'Greetings from a ModelAdmin action')
  35. def test_model_admin_default_delete_action(self):
  36. action_data = {
  37. ACTION_CHECKBOX_NAME: [self.s1.pk, self.s2.pk],
  38. 'action': 'delete_selected',
  39. 'index': 0,
  40. }
  41. delete_confirmation_data = {
  42. ACTION_CHECKBOX_NAME: [self.s1.pk, self.s2.pk],
  43. 'action': 'delete_selected',
  44. 'post': 'yes',
  45. }
  46. confirmation = self.client.post(reverse('admin:admin_views_subscriber_changelist'), action_data)
  47. self.assertIsInstance(confirmation, TemplateResponse)
  48. self.assertContains(confirmation, 'Are you sure you want to delete the selected subscribers?')
  49. self.assertContains(confirmation, '<h2>Summary</h2>')
  50. self.assertContains(confirmation, '<li>Subscribers: 2</li>')
  51. self.assertContains(confirmation, '<li>External subscribers: 1</li>')
  52. self.assertContains(confirmation, ACTION_CHECKBOX_NAME, count=2)
  53. self.client.post(reverse('admin:admin_views_subscriber_changelist'), delete_confirmation_data)
  54. self.assertEqual(Subscriber.objects.count(), 0)
  55. def test_default_delete_action_nonexistent_pk(self):
  56. self.assertFalse(Subscriber.objects.filter(id=9998).exists())
  57. action_data = {
  58. ACTION_CHECKBOX_NAME: ['9998'],
  59. 'action': 'delete_selected',
  60. 'index': 0,
  61. }
  62. response = self.client.post(reverse('admin:admin_views_subscriber_changelist'), action_data)
  63. self.assertContains(response, 'Are you sure you want to delete the selected subscribers?')
  64. self.assertContains(response, '<ul></ul>', html=True)
  65. @override_settings(USE_THOUSAND_SEPARATOR=True, NUMBER_GROUPING=3)
  66. def test_non_localized_pk(self):
  67. """
  68. If USE_THOUSAND_SEPARATOR is set, the ids for the objects selected for
  69. deletion are rendered without separators.
  70. """
  71. s = ExternalSubscriber.objects.create(id=9999)
  72. action_data = {
  73. ACTION_CHECKBOX_NAME: [s.pk, self.s2.pk],
  74. 'action': 'delete_selected',
  75. 'index': 0,
  76. }
  77. response = self.client.post(reverse('admin:admin_views_subscriber_changelist'), action_data)
  78. self.assertTemplateUsed(response, 'admin/delete_selected_confirmation.html')
  79. self.assertContains(response, 'value="9999"') # Instead of 9,999
  80. self.assertContains(response, 'value="%s"' % self.s2.pk)
  81. def test_model_admin_default_delete_action_protected(self):
  82. """
  83. The default delete action where some related objects are protected
  84. from deletion.
  85. """
  86. q1 = Question.objects.create(question='Why?')
  87. a1 = Answer.objects.create(question=q1, answer='Because.')
  88. a2 = Answer.objects.create(question=q1, answer='Yes.')
  89. q2 = Question.objects.create(question='Wherefore?')
  90. action_data = {
  91. ACTION_CHECKBOX_NAME: [q1.pk, q2.pk],
  92. 'action': 'delete_selected',
  93. 'index': 0,
  94. }
  95. delete_confirmation_data = action_data.copy()
  96. delete_confirmation_data['post'] = 'yes'
  97. response = self.client.post(reverse('admin:admin_views_question_changelist'), action_data)
  98. self.assertContains(response, 'would require deleting the following protected related objects')
  99. self.assertContains(
  100. response,
  101. '<li>Answer: <a href="%s">Because.</a></li>' % reverse('admin:admin_views_answer_change', args=(a1.pk,)),
  102. html=True
  103. )
  104. self.assertContains(
  105. response,
  106. '<li>Answer: <a href="%s">Yes.</a></li>' % reverse('admin:admin_views_answer_change', args=(a2.pk,)),
  107. html=True
  108. )
  109. # A POST request to delete protected objects displays the page which
  110. # says the deletion is prohibited.
  111. response = self.client.post(reverse('admin:admin_views_question_changelist'), delete_confirmation_data)
  112. self.assertContains(response, 'would require deleting the following protected related objects')
  113. self.assertEqual(Question.objects.count(), 2)
  114. def test_model_admin_default_delete_action_no_change_url(self):
  115. """
  116. The default delete action doesn't break if a ModelAdmin removes the
  117. change_view URL (#20640).
  118. """
  119. obj = UnchangeableObject.objects.create()
  120. action_data = {
  121. ACTION_CHECKBOX_NAME: obj.pk,
  122. 'action': 'delete_selected',
  123. 'index': '0',
  124. }
  125. response = self.client.post(reverse('admin:admin_views_unchangeableobject_changelist'), action_data)
  126. # No 500 caused by NoReverseMatch. The page doesn't display a link to
  127. # the nonexistent change page.
  128. self.assertContains(response, '<li>Unchangeable object: %s</li>' % obj, 1, html=True)
  129. def test_delete_queryset_hook(self):
  130. delete_confirmation_data = {
  131. ACTION_CHECKBOX_NAME: [self.s1.pk, self.s2.pk],
  132. 'action': 'delete_selected',
  133. 'post': 'yes',
  134. 'index': 0,
  135. }
  136. SubscriberAdmin.overridden = False
  137. self.client.post(reverse('admin:admin_views_subscriber_changelist'), delete_confirmation_data)
  138. # SubscriberAdmin.delete_queryset() sets overridden to True.
  139. self.assertIs(SubscriberAdmin.overridden, True)
  140. self.assertEqual(Subscriber.objects.all().count(), 0)
  141. def test_delete_selected_uses_get_deleted_objects(self):
  142. """The delete_selected action uses ModelAdmin.get_deleted_objects()."""
  143. book = Book.objects.create(name='Test Book')
  144. data = {
  145. ACTION_CHECKBOX_NAME: [book.pk],
  146. 'action': 'delete_selected',
  147. 'index': 0,
  148. }
  149. response = self.client.post(reverse('admin2:admin_views_book_changelist'), data)
  150. # BookAdmin.get_deleted_objects() returns custom text.
  151. self.assertContains(response, 'a deletable object')
  152. def test_custom_function_mail_action(self):
  153. """A custom action may be defined in a function."""
  154. action_data = {
  155. ACTION_CHECKBOX_NAME: [self.s1.pk],
  156. 'action': 'external_mail',
  157. 'index': 0,
  158. }
  159. self.client.post(reverse('admin:admin_views_externalsubscriber_changelist'), action_data)
  160. self.assertEqual(len(mail.outbox), 1)
  161. self.assertEqual(mail.outbox[0].subject, 'Greetings from a function action')
  162. def test_custom_function_action_with_redirect(self):
  163. """Another custom action defined in a function."""
  164. action_data = {
  165. ACTION_CHECKBOX_NAME: [self.s1.pk],
  166. 'action': 'redirect_to',
  167. 'index': 0,
  168. }
  169. response = self.client.post(reverse('admin:admin_views_externalsubscriber_changelist'), action_data)
  170. self.assertEqual(response.status_code, 302)
  171. def test_default_redirect(self):
  172. """
  173. Actions which don't return an HttpResponse are redirected to the same
  174. page, retaining the querystring (which may contain changelist info).
  175. """
  176. action_data = {
  177. ACTION_CHECKBOX_NAME: [self.s1.pk],
  178. 'action': 'external_mail',
  179. 'index': 0,
  180. }
  181. url = reverse('admin:admin_views_externalsubscriber_changelist') + '?o=1'
  182. response = self.client.post(url, action_data)
  183. self.assertRedirects(response, url)
  184. def test_custom_function_action_streaming_response(self):
  185. """A custom action may return a StreamingHttpResponse."""
  186. action_data = {
  187. ACTION_CHECKBOX_NAME: [self.s1.pk],
  188. 'action': 'download',
  189. 'index': 0,
  190. }
  191. response = self.client.post(reverse('admin:admin_views_externalsubscriber_changelist'), action_data)
  192. content = b''.join(response.streaming_content)
  193. self.assertEqual(content, b'This is the content of the file')
  194. self.assertEqual(response.status_code, 200)
  195. def test_custom_function_action_no_perm_response(self):
  196. """A custom action may returns an HttpResponse with a 403 code."""
  197. action_data = {
  198. ACTION_CHECKBOX_NAME: [self.s1.pk],
  199. 'action': 'no_perm',
  200. 'index': 0,
  201. }
  202. response = self.client.post(reverse('admin:admin_views_externalsubscriber_changelist'), action_data)
  203. self.assertEqual(response.status_code, 403)
  204. self.assertEqual(response.content, b'No permission to perform this action')
  205. def test_actions_ordering(self):
  206. """Actions are ordered as expected."""
  207. response = self.client.get(reverse('admin:admin_views_externalsubscriber_changelist'))
  208. self.assertContains(response, '''<label>Action: <select name="action" required>
  209. <option value="" selected>---------</option>
  210. <option value="delete_selected">Delete selected external
  211. subscribers</option>
  212. <option value="redirect_to">Redirect to (Awesome action)</option>
  213. <option value="external_mail">External mail (Another awesome
  214. action)</option>
  215. <option value="download">Download subscription</option>
  216. <option value="no_perm">No permission to run</option>
  217. </select>''', html=True)
  218. def test_model_without_action(self):
  219. """A ModelAdmin might not have any actions."""
  220. response = self.client.get(reverse('admin:admin_views_oldsubscriber_changelist'))
  221. self.assertIsNone(response.context['action_form'])
  222. self.assertNotContains(
  223. response, '<input type="checkbox" class="action-select"',
  224. msg_prefix='Found an unexpected action toggle checkboxbox in response'
  225. )
  226. self.assertNotContains(response, '<input type="checkbox" class="action-select"')
  227. def test_model_without_action_still_has_jquery(self):
  228. """
  229. A ModelAdmin without any actions still has jQuery included on the page.
  230. """
  231. response = self.client.get(reverse('admin:admin_views_oldsubscriber_changelist'))
  232. self.assertIsNone(response.context['action_form'])
  233. self.assertContains(
  234. response, 'jquery.min.js',
  235. msg_prefix='jQuery missing from admin pages for model with no admin actions'
  236. )
  237. def test_action_column_class(self):
  238. """The checkbox column class is present in the response."""
  239. response = self.client.get(reverse('admin:admin_views_subscriber_changelist'))
  240. self.assertIsNotNone(response.context['action_form'])
  241. self.assertContains(response, 'action-checkbox-column')
  242. def test_multiple_actions_form(self):
  243. """
  244. Actions come from the form whose submit button was pressed (#10618).
  245. """
  246. action_data = {
  247. ACTION_CHECKBOX_NAME: [self.s1.pk],
  248. # Two different actions selected on the two forms...
  249. 'action': ['external_mail', 'delete_selected'],
  250. # ...but "go" was clicked on the top form.
  251. 'index': 0
  252. }
  253. self.client.post(reverse('admin:admin_views_externalsubscriber_changelist'), action_data)
  254. # The action sends mail rather than deletes.
  255. self.assertEqual(len(mail.outbox), 1)
  256. self.assertEqual(mail.outbox[0].subject, 'Greetings from a function action')
  257. def test_media_from_actions_form(self):
  258. """
  259. The action form's media is included in the changelist view's media.
  260. """
  261. response = self.client.get(reverse('admin:admin_views_subscriber_changelist'))
  262. media_path = MediaActionForm.Media.js[0]
  263. self.assertIsInstance(response.context['action_form'], MediaActionForm)
  264. self.assertIn('media', response.context)
  265. self.assertIn(media_path, response.context['media']._js)
  266. self.assertContains(response, media_path)
  267. def test_user_message_on_none_selected(self):
  268. """
  269. User sees a warning when 'Go' is pressed and no items are selected.
  270. """
  271. action_data = {
  272. ACTION_CHECKBOX_NAME: [],
  273. 'action': 'delete_selected',
  274. 'index': 0,
  275. }
  276. url = reverse('admin:admin_views_subscriber_changelist')
  277. response = self.client.post(url, action_data)
  278. self.assertRedirects(response, url, fetch_redirect_response=False)
  279. response = self.client.get(response.url)
  280. msg = 'Items must be selected in order to perform actions on them. No items have been changed.'
  281. self.assertContains(response, msg)
  282. self.assertEqual(Subscriber.objects.count(), 2)
  283. def test_user_message_on_no_action(self):
  284. """
  285. User sees a warning when 'Go' is pressed and no action is selected.
  286. """
  287. action_data = {
  288. ACTION_CHECKBOX_NAME: [self.s1.pk, self.s2.pk],
  289. 'action': '',
  290. 'index': 0,
  291. }
  292. url = reverse('admin:admin_views_subscriber_changelist')
  293. response = self.client.post(url, action_data)
  294. self.assertRedirects(response, url, fetch_redirect_response=False)
  295. response = self.client.get(response.url)
  296. self.assertContains(response, 'No action selected.')
  297. self.assertEqual(Subscriber.objects.count(), 2)
  298. def test_selection_counter(self):
  299. """The selection counter is there."""
  300. response = self.client.get(reverse('admin:admin_views_subscriber_changelist'))
  301. self.assertContains(response, '0 of 2 selected')
  302. def test_popup_actions(self):
  303. """ Actions aren't shown in popups."""
  304. changelist_url = reverse('admin:admin_views_subscriber_changelist')
  305. response = self.client.get(changelist_url)
  306. self.assertIsNotNone(response.context['action_form'])
  307. response = self.client.get(changelist_url + '?%s' % IS_POPUP_VAR)
  308. self.assertIsNone(response.context['action_form'])
  309. def test_popup_template_response_on_add(self):
  310. """
  311. Success on popups shall be rendered from template in order to allow
  312. easy customization.
  313. """
  314. response = self.client.post(
  315. reverse('admin:admin_views_actor_add') + '?%s=1' % IS_POPUP_VAR,
  316. {'name': 'Troy McClure', 'age': '55', IS_POPUP_VAR: '1'}
  317. )
  318. self.assertEqual(response.status_code, 200)
  319. self.assertEqual(response.template_name, [
  320. 'admin/admin_views/actor/popup_response.html',
  321. 'admin/admin_views/popup_response.html',
  322. 'admin/popup_response.html',
  323. ])
  324. self.assertTemplateUsed(response, 'admin/popup_response.html')
  325. def test_popup_template_response_on_change(self):
  326. instance = Actor.objects.create(name='David Tennant', age=45)
  327. response = self.client.post(
  328. reverse('admin:admin_views_actor_change', args=(instance.pk,)) + '?%s=1' % IS_POPUP_VAR,
  329. {'name': 'David Tennant', 'age': '46', IS_POPUP_VAR: '1'}
  330. )
  331. self.assertEqual(response.status_code, 200)
  332. self.assertEqual(response.template_name, [
  333. 'admin/admin_views/actor/popup_response.html',
  334. 'admin/admin_views/popup_response.html',
  335. 'admin/popup_response.html',
  336. ])
  337. self.assertTemplateUsed(response, 'admin/popup_response.html')
  338. def test_popup_template_response_on_delete(self):
  339. instance = Actor.objects.create(name='David Tennant', age=45)
  340. response = self.client.post(
  341. reverse('admin:admin_views_actor_delete', args=(instance.pk,)) + '?%s=1' % IS_POPUP_VAR,
  342. {IS_POPUP_VAR: '1'}
  343. )
  344. self.assertEqual(response.status_code, 200)
  345. self.assertEqual(response.template_name, [
  346. 'admin/admin_views/actor/popup_response.html',
  347. 'admin/admin_views/popup_response.html',
  348. 'admin/popup_response.html',
  349. ])
  350. self.assertTemplateUsed(response, 'admin/popup_response.html')
  351. def test_popup_template_escaping(self):
  352. popup_response_data = json.dumps({
  353. 'new_value': 'new_value\\',
  354. 'obj': 'obj\\',
  355. 'value': 'value\\',
  356. })
  357. context = {
  358. 'popup_response_data': popup_response_data,
  359. }
  360. output = render_to_string('admin/popup_response.html', context)
  361. self.assertIn(
  362. r'&quot;value\\&quot;', output
  363. )
  364. self.assertIn(
  365. r'&quot;new_value\\&quot;', output
  366. )
  367. self.assertIn(
  368. r'&quot;obj\\&quot;', output
  369. )
  370. @override_settings(ROOT_URLCONF='admin_views.urls')
  371. class AdminActionsPermissionTests(TestCase):
  372. @classmethod
  373. def setUpTestData(cls):
  374. cls.s1 = ExternalSubscriber.objects.create(name='John Doe', email='john@example.org')
  375. cls.s2 = Subscriber.objects.create(name='Max Mustermann', email='max@example.org')
  376. cls.user = User.objects.create_user(
  377. username='user', password='secret', email='user@example.com',
  378. is_staff=True,
  379. )
  380. permission = Permission.objects.get(codename='change_subscriber')
  381. cls.user.user_permissions.add(permission)
  382. def setUp(self):
  383. self.client.force_login(self.user)
  384. def test_model_admin_no_delete_permission(self):
  385. """
  386. Permission is denied if the user doesn't have delete permission for the
  387. model (Subscriber).
  388. """
  389. action_data = {
  390. ACTION_CHECKBOX_NAME: [self.s1.pk],
  391. 'action': 'delete_selected',
  392. }
  393. url = reverse('admin:admin_views_subscriber_changelist')
  394. response = self.client.post(url, action_data)
  395. self.assertRedirects(response, url, fetch_redirect_response=False)
  396. response = self.client.get(response.url)
  397. self.assertContains(response, 'No action selected.')
  398. def test_model_admin_no_delete_permission_externalsubscriber(self):
  399. """
  400. Permission is denied if the user doesn't have delete permission for a
  401. related model (ExternalSubscriber).
  402. """
  403. permission = Permission.objects.get(codename='delete_subscriber')
  404. self.user.user_permissions.add(permission)
  405. delete_confirmation_data = {
  406. ACTION_CHECKBOX_NAME: [self.s1.pk, self.s2.pk],
  407. 'action': 'delete_selected',
  408. 'post': 'yes',
  409. }
  410. response = self.client.post(reverse('admin:admin_views_subscriber_changelist'), delete_confirmation_data)
  411. self.assertEqual(response.status_code, 403)