test_actions.py 21 KB

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