test_actions.py 20 KB

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