wagtail 7.2.2__py3-none-any.whl → 7.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (1301) hide show
  1. wagtail/__init__.py +1 -1
  2. wagtail/actions/copy_for_translation.py +4 -2
  3. wagtail/admin/action_menu.py +4 -1
  4. wagtail/admin/api/actions/convert_alias.py +2 -2
  5. wagtail/admin/api/actions/copy.py +2 -2
  6. wagtail/admin/api/actions/copy_for_translation.py +2 -2
  7. wagtail/admin/api/actions/create_alias.py +2 -2
  8. wagtail/admin/api/actions/delete.py +1 -1
  9. wagtail/admin/api/actions/move.py +1 -1
  10. wagtail/admin/api/actions/publish.py +2 -2
  11. wagtail/admin/api/actions/revert_to_page_revision.py +2 -2
  12. wagtail/admin/api/actions/unpublish.py +1 -1
  13. wagtail/admin/api/filters.py +2 -2
  14. wagtail/admin/compare.py +22 -0
  15. wagtail/admin/forms/account.py +52 -1
  16. wagtail/admin/forms/comments.py +53 -0
  17. wagtail/admin/forms/models.py +36 -0
  18. wagtail/admin/forms/pages.py +7 -0
  19. wagtail/admin/forms/workflows.py +5 -2
  20. wagtail/admin/icons.py +4 -3
  21. wagtail/admin/locale/af/LC_MESSAGES/djangojs.mo +0 -0
  22. wagtail/admin/locale/af/LC_MESSAGES/djangojs.po +8 -1
  23. wagtail/admin/locale/ar/LC_MESSAGES/django.mo +0 -0
  24. wagtail/admin/locale/ar/LC_MESSAGES/django.po +42 -16
  25. wagtail/admin/locale/ar/LC_MESSAGES/djangojs.mo +0 -0
  26. wagtail/admin/locale/ar/LC_MESSAGES/djangojs.po +20 -11
  27. wagtail/admin/locale/az_AZ/LC_MESSAGES/django.po +1 -1
  28. wagtail/admin/locale/az_AZ/LC_MESSAGES/djangojs.mo +0 -0
  29. wagtail/admin/locale/az_AZ/LC_MESSAGES/djangojs.po +5 -2
  30. wagtail/admin/locale/be/LC_MESSAGES/django.po +1 -1
  31. wagtail/admin/locale/be/LC_MESSAGES/djangojs.mo +0 -0
  32. wagtail/admin/locale/be/LC_MESSAGES/djangojs.po +21 -3
  33. wagtail/admin/locale/bg/LC_MESSAGES/django.mo +0 -0
  34. wagtail/admin/locale/bg/LC_MESSAGES/django.po +299 -2
  35. wagtail/admin/locale/bg/LC_MESSAGES/djangojs.mo +0 -0
  36. wagtail/admin/locale/bg/LC_MESSAGES/djangojs.po +37 -3
  37. wagtail/admin/locale/bn/LC_MESSAGES/django.po +1 -1
  38. wagtail/admin/locale/bn/LC_MESSAGES/djangojs.mo +0 -0
  39. wagtail/admin/locale/bn/LC_MESSAGES/djangojs.po +9 -3
  40. wagtail/admin/locale/ca/LC_MESSAGES/django.mo +0 -0
  41. wagtail/admin/locale/ca/LC_MESSAGES/django.po +36 -18
  42. wagtail/admin/locale/ca/LC_MESSAGES/djangojs.mo +0 -0
  43. wagtail/admin/locale/ca/LC_MESSAGES/djangojs.po +34 -12
  44. wagtail/admin/locale/cs/LC_MESSAGES/django.mo +0 -0
  45. wagtail/admin/locale/cs/LC_MESSAGES/django.po +1 -7
  46. wagtail/admin/locale/cs/LC_MESSAGES/djangojs.mo +0 -0
  47. wagtail/admin/locale/cs/LC_MESSAGES/djangojs.po +22 -4
  48. wagtail/admin/locale/cy/LC_MESSAGES/django.po +1 -1
  49. wagtail/admin/locale/cy/LC_MESSAGES/djangojs.mo +0 -0
  50. wagtail/admin/locale/cy/LC_MESSAGES/djangojs.po +15 -3
  51. wagtail/admin/locale/da/LC_MESSAGES/django.po +1 -1
  52. wagtail/admin/locale/da/LC_MESSAGES/djangojs.mo +0 -0
  53. wagtail/admin/locale/da/LC_MESSAGES/djangojs.po +19 -4
  54. wagtail/admin/locale/de/LC_MESSAGES/django.mo +0 -0
  55. wagtail/admin/locale/de/LC_MESSAGES/django.po +201 -21
  56. wagtail/admin/locale/de/LC_MESSAGES/djangojs.mo +0 -0
  57. wagtail/admin/locale/de/LC_MESSAGES/djangojs.po +53 -14
  58. wagtail/admin/locale/dv/LC_MESSAGES/django.mo +0 -0
  59. wagtail/admin/locale/dv/LC_MESSAGES/django.po +7 -16
  60. wagtail/admin/locale/dv/LC_MESSAGES/djangojs.mo +0 -0
  61. wagtail/admin/locale/dv/LC_MESSAGES/djangojs.po +20 -2
  62. wagtail/admin/locale/el/LC_MESSAGES/django.po +1 -1
  63. wagtail/admin/locale/el/LC_MESSAGES/djangojs.mo +0 -0
  64. wagtail/admin/locale/el/LC_MESSAGES/djangojs.po +22 -13
  65. wagtail/admin/locale/en/LC_MESSAGES/django.mo +0 -0
  66. wagtail/admin/locale/en/LC_MESSAGES/django.po +262 -234
  67. wagtail/admin/locale/en/LC_MESSAGES/djangojs.mo +0 -0
  68. wagtail/admin/locale/en/LC_MESSAGES/djangojs.po +72 -43
  69. wagtail/admin/locale/en_IN/LC_MESSAGES/djangojs.mo +0 -0
  70. wagtail/admin/locale/en_IN/LC_MESSAGES/djangojs.po +5 -2
  71. wagtail/admin/locale/es/LC_MESSAGES/django.mo +0 -0
  72. wagtail/admin/locale/es/LC_MESSAGES/django.po +1 -10
  73. wagtail/admin/locale/es/LC_MESSAGES/djangojs.mo +0 -0
  74. wagtail/admin/locale/es/LC_MESSAGES/djangojs.po +22 -13
  75. wagtail/admin/locale/es_419/LC_MESSAGES/django.po +1 -1
  76. wagtail/admin/locale/es_419/LC_MESSAGES/djangojs.mo +0 -0
  77. wagtail/admin/locale/es_419/LC_MESSAGES/djangojs.po +8 -2
  78. wagtail/admin/locale/es_VE/LC_MESSAGES/django.po +1 -1
  79. wagtail/admin/locale/es_VE/LC_MESSAGES/djangojs.mo +0 -0
  80. wagtail/admin/locale/es_VE/LC_MESSAGES/djangojs.po +5 -2
  81. wagtail/admin/locale/et/LC_MESSAGES/django.po +1 -1
  82. wagtail/admin/locale/et/LC_MESSAGES/djangojs.mo +0 -0
  83. wagtail/admin/locale/et/LC_MESSAGES/djangojs.po +17 -2
  84. wagtail/admin/locale/eu/LC_MESSAGES/django.po +1 -1
  85. wagtail/admin/locale/eu/LC_MESSAGES/djangojs.mo +0 -0
  86. wagtail/admin/locale/eu/LC_MESSAGES/djangojs.po +8 -2
  87. wagtail/admin/locale/fa/LC_MESSAGES/django.mo +0 -0
  88. wagtail/admin/locale/fa/LC_MESSAGES/django.po +5 -11
  89. wagtail/admin/locale/fa/LC_MESSAGES/djangojs.mo +0 -0
  90. wagtail/admin/locale/fa/LC_MESSAGES/djangojs.po +18 -3
  91. wagtail/admin/locale/fi/LC_MESSAGES/django.mo +0 -0
  92. wagtail/admin/locale/fi/LC_MESSAGES/django.po +1 -12
  93. wagtail/admin/locale/fi/LC_MESSAGES/djangojs.mo +0 -0
  94. wagtail/admin/locale/fi/LC_MESSAGES/djangojs.po +22 -12
  95. wagtail/admin/locale/fr/LC_MESSAGES/django.mo +0 -0
  96. wagtail/admin/locale/fr/LC_MESSAGES/django.po +38 -21
  97. wagtail/admin/locale/fr/LC_MESSAGES/djangojs.mo +0 -0
  98. wagtail/admin/locale/fr/LC_MESSAGES/djangojs.po +35 -14
  99. wagtail/admin/locale/gl/LC_MESSAGES/django.mo +0 -0
  100. wagtail/admin/locale/gl/LC_MESSAGES/django.po +36 -18
  101. wagtail/admin/locale/gl/LC_MESSAGES/djangojs.mo +0 -0
  102. wagtail/admin/locale/gl/LC_MESSAGES/djangojs.po +34 -12
  103. wagtail/admin/locale/he_IL/LC_MESSAGES/django.po +1 -1
  104. wagtail/admin/locale/he_IL/LC_MESSAGES/djangojs.mo +0 -0
  105. wagtail/admin/locale/he_IL/LC_MESSAGES/djangojs.po +20 -3
  106. wagtail/admin/locale/hi/LC_MESSAGES/djangojs.mo +0 -0
  107. wagtail/admin/locale/hi/LC_MESSAGES/djangojs.po +5 -2
  108. wagtail/admin/locale/hr_HR/LC_MESSAGES/django.po +1 -1
  109. wagtail/admin/locale/hr_HR/LC_MESSAGES/djangojs.mo +0 -0
  110. wagtail/admin/locale/hr_HR/LC_MESSAGES/djangojs.po +17 -2
  111. wagtail/admin/locale/ht/LC_MESSAGES/django.po +1 -1
  112. wagtail/admin/locale/ht/LC_MESSAGES/djangojs.mo +0 -0
  113. wagtail/admin/locale/ht/LC_MESSAGES/djangojs.po +5 -2
  114. wagtail/admin/locale/hu/LC_MESSAGES/django.mo +0 -0
  115. wagtail/admin/locale/hu/LC_MESSAGES/django.po +7 -18
  116. wagtail/admin/locale/hu/LC_MESSAGES/djangojs.mo +0 -0
  117. wagtail/admin/locale/hu/LC_MESSAGES/djangojs.po +21 -11
  118. wagtail/admin/locale/id_ID/LC_MESSAGES/django.po +1 -1
  119. wagtail/admin/locale/id_ID/LC_MESSAGES/djangojs.mo +0 -0
  120. wagtail/admin/locale/id_ID/LC_MESSAGES/djangojs.po +18 -3
  121. wagtail/admin/locale/is_IS/LC_MESSAGES/django.mo +0 -0
  122. wagtail/admin/locale/is_IS/LC_MESSAGES/django.po +7 -17
  123. wagtail/admin/locale/is_IS/LC_MESSAGES/djangojs.mo +0 -0
  124. wagtail/admin/locale/is_IS/LC_MESSAGES/djangojs.po +21 -12
  125. wagtail/admin/locale/it/LC_MESSAGES/django.mo +0 -0
  126. wagtail/admin/locale/it/LC_MESSAGES/django.po +599 -19
  127. wagtail/admin/locale/it/LC_MESSAGES/djangojs.mo +0 -0
  128. wagtail/admin/locale/it/LC_MESSAGES/djangojs.po +73 -13
  129. wagtail/admin/locale/ja/LC_MESSAGES/django.po +1 -1
  130. wagtail/admin/locale/ja/LC_MESSAGES/djangojs.mo +0 -0
  131. wagtail/admin/locale/ja/LC_MESSAGES/djangojs.po +23 -3
  132. wagtail/admin/locale/ka/LC_MESSAGES/django.po +1 -1
  133. wagtail/admin/locale/ka/LC_MESSAGES/djangojs.mo +0 -0
  134. wagtail/admin/locale/ka/LC_MESSAGES/djangojs.po +5 -2
  135. wagtail/admin/locale/ko/LC_MESSAGES/django.mo +0 -0
  136. wagtail/admin/locale/ko/LC_MESSAGES/django.po +1 -11
  137. wagtail/admin/locale/ko/LC_MESSAGES/djangojs.mo +0 -0
  138. wagtail/admin/locale/ko/LC_MESSAGES/djangojs.po +23 -13
  139. wagtail/admin/locale/lt/LC_MESSAGES/django.po +1 -1
  140. wagtail/admin/locale/lt/LC_MESSAGES/djangojs.mo +0 -0
  141. wagtail/admin/locale/lt/LC_MESSAGES/djangojs.po +21 -12
  142. wagtail/admin/locale/lv/LC_MESSAGES/django.po +1 -1
  143. wagtail/admin/locale/lv/LC_MESSAGES/djangojs.mo +0 -0
  144. wagtail/admin/locale/lv/LC_MESSAGES/djangojs.po +22 -4
  145. wagtail/admin/locale/mi/LC_MESSAGES/django.po +1 -1
  146. wagtail/admin/locale/mi/LC_MESSAGES/djangojs.mo +0 -0
  147. wagtail/admin/locale/mi/LC_MESSAGES/djangojs.po +14 -2
  148. wagtail/admin/locale/mn/LC_MESSAGES/django.po +1 -1
  149. wagtail/admin/locale/mn/LC_MESSAGES/djangojs.mo +0 -0
  150. wagtail/admin/locale/mn/LC_MESSAGES/djangojs.po +17 -2
  151. wagtail/admin/locale/my/LC_MESSAGES/django.po +1 -1
  152. wagtail/admin/locale/my/LC_MESSAGES/djangojs.mo +0 -0
  153. wagtail/admin/locale/my/LC_MESSAGES/djangojs.po +8 -2
  154. wagtail/admin/locale/nb/LC_MESSAGES/django.po +1 -1
  155. wagtail/admin/locale/nb/LC_MESSAGES/djangojs.mo +0 -0
  156. wagtail/admin/locale/nb/LC_MESSAGES/djangojs.po +18 -3
  157. wagtail/admin/locale/nl/LC_MESSAGES/django.mo +0 -0
  158. wagtail/admin/locale/nl/LC_MESSAGES/django.po +38 -21
  159. wagtail/admin/locale/nl/LC_MESSAGES/djangojs.mo +0 -0
  160. wagtail/admin/locale/nl/LC_MESSAGES/djangojs.po +36 -13
  161. wagtail/admin/locale/pl/LC_MESSAGES/django.mo +0 -0
  162. wagtail/admin/locale/pl/LC_MESSAGES/django.po +1 -10
  163. wagtail/admin/locale/pl/LC_MESSAGES/djangojs.mo +0 -0
  164. wagtail/admin/locale/pl/LC_MESSAGES/djangojs.po +21 -12
  165. wagtail/admin/locale/pt_BR/LC_MESSAGES/django.mo +0 -0
  166. wagtail/admin/locale/pt_BR/LC_MESSAGES/django.po +7 -16
  167. wagtail/admin/locale/pt_BR/LC_MESSAGES/djangojs.mo +0 -0
  168. wagtail/admin/locale/pt_BR/LC_MESSAGES/djangojs.po +21 -13
  169. wagtail/admin/locale/pt_PT/LC_MESSAGES/django.mo +0 -0
  170. wagtail/admin/locale/pt_PT/LC_MESSAGES/django.po +1 -7
  171. wagtail/admin/locale/pt_PT/LC_MESSAGES/djangojs.mo +0 -0
  172. wagtail/admin/locale/pt_PT/LC_MESSAGES/djangojs.po +18 -12
  173. wagtail/admin/locale/ro/LC_MESSAGES/django.mo +0 -0
  174. wagtail/admin/locale/ro/LC_MESSAGES/django.po +7 -16
  175. wagtail/admin/locale/ro/LC_MESSAGES/djangojs.mo +0 -0
  176. wagtail/admin/locale/ro/LC_MESSAGES/djangojs.po +20 -11
  177. wagtail/admin/locale/ru/LC_MESSAGES/django.mo +0 -0
  178. wagtail/admin/locale/ru/LC_MESSAGES/django.po +186 -22
  179. wagtail/admin/locale/ru/LC_MESSAGES/djangojs.mo +0 -0
  180. wagtail/admin/locale/ru/LC_MESSAGES/djangojs.po +53 -14
  181. wagtail/admin/locale/sk_SK/LC_MESSAGES/django.po +1 -1
  182. wagtail/admin/locale/sk_SK/LC_MESSAGES/djangojs.mo +0 -0
  183. wagtail/admin/locale/sk_SK/LC_MESSAGES/djangojs.po +20 -3
  184. wagtail/admin/locale/sl/LC_MESSAGES/django.mo +0 -0
  185. wagtail/admin/locale/sl/LC_MESSAGES/django.po +7 -16
  186. wagtail/admin/locale/sl/LC_MESSAGES/djangojs.mo +0 -0
  187. wagtail/admin/locale/sl/LC_MESSAGES/djangojs.po +21 -12
  188. wagtail/admin/locale/sr/LC_MESSAGES/djangojs.mo +0 -0
  189. wagtail/admin/locale/sr/LC_MESSAGES/djangojs.po +15 -0
  190. wagtail/admin/locale/sv/LC_MESSAGES/django.mo +0 -0
  191. wagtail/admin/locale/sv/LC_MESSAGES/django.po +329 -23
  192. wagtail/admin/locale/sv/LC_MESSAGES/djangojs.mo +0 -0
  193. wagtail/admin/locale/sv/LC_MESSAGES/djangojs.po +23 -13
  194. wagtail/admin/locale/ta/LC_MESSAGES/djangojs.mo +0 -0
  195. wagtail/admin/locale/ta/LC_MESSAGES/djangojs.po +5 -2
  196. wagtail/admin/locale/tet/LC_MESSAGES/django.po +1 -1
  197. wagtail/admin/locale/tet/LC_MESSAGES/djangojs.mo +0 -0
  198. wagtail/admin/locale/tet/LC_MESSAGES/djangojs.po +19 -4
  199. wagtail/admin/locale/th/LC_MESSAGES/django.mo +0 -0
  200. wagtail/admin/locale/th/LC_MESSAGES/django.po +1 -4
  201. wagtail/admin/locale/th/LC_MESSAGES/djangojs.mo +0 -0
  202. wagtail/admin/locale/th/LC_MESSAGES/djangojs.po +22 -3
  203. wagtail/admin/locale/tr/LC_MESSAGES/django.mo +0 -0
  204. wagtail/admin/locale/tr/LC_MESSAGES/django.po +4 -10
  205. wagtail/admin/locale/tr/LC_MESSAGES/djangojs.mo +0 -0
  206. wagtail/admin/locale/tr/LC_MESSAGES/djangojs.po +23 -5
  207. wagtail/admin/locale/tr_TR/LC_MESSAGES/django.po +1 -1
  208. wagtail/admin/locale/tr_TR/LC_MESSAGES/djangojs.mo +0 -0
  209. wagtail/admin/locale/tr_TR/LC_MESSAGES/djangojs.po +19 -4
  210. wagtail/admin/locale/ug/LC_MESSAGES/django.mo +0 -0
  211. wagtail/admin/locale/ug/LC_MESSAGES/django.po +7 -16
  212. wagtail/admin/locale/ug/LC_MESSAGES/djangojs.mo +0 -0
  213. wagtail/admin/locale/ug/LC_MESSAGES/djangojs.po +20 -11
  214. wagtail/admin/locale/uk/LC_MESSAGES/django.mo +0 -0
  215. wagtail/admin/locale/uk/LC_MESSAGES/django.po +5 -11
  216. wagtail/admin/locale/uk/LC_MESSAGES/djangojs.mo +0 -0
  217. wagtail/admin/locale/uk/LC_MESSAGES/djangojs.po +22 -14
  218. wagtail/admin/locale/vi/LC_MESSAGES/django.po +1 -1
  219. wagtail/admin/locale/vi/LC_MESSAGES/djangojs.mo +0 -0
  220. wagtail/admin/locale/vi/LC_MESSAGES/djangojs.po +9 -2
  221. wagtail/admin/locale/zh/LC_MESSAGES/django.po +1 -1
  222. wagtail/admin/locale/zh/LC_MESSAGES/djangojs.mo +0 -0
  223. wagtail/admin/locale/zh/LC_MESSAGES/djangojs.po +20 -5
  224. wagtail/admin/locale/zh_Hans/LC_MESSAGES/django.mo +0 -0
  225. wagtail/admin/locale/zh_Hans/LC_MESSAGES/django.po +1 -7
  226. wagtail/admin/locale/zh_Hans/LC_MESSAGES/djangojs.mo +0 -0
  227. wagtail/admin/locale/zh_Hans/LC_MESSAGES/djangojs.po +23 -13
  228. wagtail/admin/locale/zh_Hant/LC_MESSAGES/django.mo +0 -0
  229. wagtail/admin/locale/zh_Hant/LC_MESSAGES/django.po +1 -7
  230. wagtail/admin/locale/zh_Hant/LC_MESSAGES/djangojs.mo +0 -0
  231. wagtail/admin/locale/zh_Hant/LC_MESSAGES/djangojs.po +21 -3
  232. wagtail/admin/panels/comment_panel.py +1 -51
  233. wagtail/admin/panels/title_field_panel.py +3 -1
  234. wagtail/admin/static/wagtailadmin/css/core.css +1 -1
  235. wagtail/admin/static/wagtailadmin/js/bulk-actions.js +1 -1
  236. wagtail/admin/static/wagtailadmin/js/comments.js +1 -1
  237. wagtail/admin/static/wagtailadmin/js/core.js +1 -1
  238. wagtail/admin/static/wagtailadmin/js/core.js.LICENSE.txt +1 -1
  239. wagtail/admin/static/wagtailadmin/js/draftail.js +1 -1
  240. wagtail/admin/static/wagtailadmin/js/privacy-switch.js +1 -1
  241. wagtail/admin/static/wagtailadmin/js/sidebar.js +1 -1
  242. wagtail/admin/static/wagtailadmin/js/telepath/blocks.js +1 -1
  243. wagtail/admin/static/wagtailadmin/js/userbar.js +1 -1
  244. wagtail/admin/static/wagtailadmin/js/userbar.js.LICENSE.txt +1 -1
  245. wagtail/admin/static/wagtailadmin/js/vendor.js +1 -1
  246. wagtail/admin/static/wagtailadmin/js/wagtailadmin.js +1 -1
  247. wagtail/admin/templates/wagtailadmin/base.html +1 -1
  248. wagtail/admin/templates/wagtailadmin/generic/edit_partials.html +100 -0
  249. wagtail/admin/templates/wagtailadmin/generic/form.html +26 -5
  250. wagtail/admin/templates/wagtailadmin/generic/includes/_loaded_revision_inputs.html +3 -0
  251. wagtail/admin/templates/wagtailadmin/generic/listing_results.html +1 -17
  252. wagtail/admin/templates/wagtailadmin/pages/create.html +14 -4
  253. wagtail/admin/templates/wagtailadmin/pages/edit.html +16 -3
  254. wagtail/admin/templates/wagtailadmin/pages/edit_partials.html +19 -0
  255. wagtail/admin/templates/wagtailadmin/pages/index_results.html +1 -9
  256. wagtail/admin/templates/wagtailadmin/shared/autosave/indicator.html +36 -0
  257. wagtail/admin/templates/wagtailadmin/shared/breadcrumbs.html +1 -1
  258. wagtail/admin/templates/wagtailadmin/shared/editing_sessions/list.html +2 -2
  259. wagtail/admin/templates/wagtailadmin/shared/editing_sessions/module.html +19 -3
  260. wagtail/admin/templates/wagtailadmin/shared/headers/_actions.html +5 -0
  261. wagtail/admin/templates/wagtailadmin/shared/headers/_history_icon_link.html +2 -2
  262. wagtail/admin/templates/wagtailadmin/shared/headers/slim_header.html +8 -9
  263. wagtail/admin/templates/wagtailadmin/shared/listing/filter_partials.html +19 -0
  264. wagtail/admin/templates/wagtailadmin/shared/side_panel_toggle.html +3 -3
  265. wagtail/admin/templates/wagtailadmin/shared/side_panels/includes/side_panel.html +3 -0
  266. wagtail/admin/templates/wagtailadmin/shared/side_panels/includes/status/privacy.html +18 -6
  267. wagtail/admin/templates/wagtailadmin/shared/side_panels/includes/status/usage.html +11 -4
  268. wagtail/admin/templates/wagtailadmin/shared/side_panels/includes/status/workflow.html +22 -1
  269. wagtail/admin/templates/wagtailadmin/shared/side_panels/preview.html +1 -0
  270. wagtail/admin/templates/wagtailadmin/shared/side_panels.html +1 -2
  271. wagtail/admin/templates/wagtailadmin/shared/unsaved_changes_warning.html +20 -20
  272. wagtail/admin/templates/wagtailadmin/userbar/base.html +2 -0
  273. wagtail/admin/templates/wagtailadmin/userbar/item_accessibility.html +1 -1
  274. wagtail/admin/templatetags/wagtailadmin_tags.py +6 -14
  275. wagtail/admin/tests/pages/test_create_page.py +359 -1
  276. wagtail/admin/tests/pages/test_custom_listing.py +48 -2
  277. wagtail/admin/tests/pages/test_edit_page.py +987 -10
  278. wagtail/admin/tests/pages/test_explorer_view.py +9 -5
  279. wagtail/admin/tests/pages/test_page_search.py +15 -0
  280. wagtail/admin/tests/pages/test_revisions.py +4 -0
  281. wagtail/admin/tests/test_account_management.py +88 -2
  282. wagtail/admin/tests/test_collections_views.py +15 -15
  283. wagtail/admin/tests/test_compare.py +34 -0
  284. wagtail/admin/tests/test_editing_sessions.py +230 -8
  285. wagtail/admin/tests/test_forms.py +192 -1
  286. wagtail/admin/tests/test_icon_sprite.py +22 -2
  287. wagtail/admin/tests/test_page_chooser.py +13 -13
  288. wagtail/admin/tests/test_reports_views.py +4 -1
  289. wagtail/admin/tests/test_userbar.py +69 -5
  290. wagtail/admin/tests/test_views_generic.py +5 -5
  291. wagtail/admin/tests/test_workflows.py +14 -12
  292. wagtail/admin/tests/viewsets/test_model_viewset.py +13 -0
  293. wagtail/admin/ui/autosave.py +5 -0
  294. wagtail/admin/ui/editing_sessions.py +3 -0
  295. wagtail/admin/ui/side_panels.py +19 -20
  296. wagtail/admin/userbar.py +48 -27
  297. wagtail/admin/views/bulk_action/dispatcher.py +2 -2
  298. wagtail/admin/views/chooser.py +6 -6
  299. wagtail/admin/views/editing_sessions.py +20 -7
  300. wagtail/admin/views/generic/__init__.py +1 -0
  301. wagtail/admin/views/generic/chooser.py +5 -5
  302. wagtail/admin/views/generic/mixins.py +143 -26
  303. wagtail/admin/views/generic/models.py +157 -27
  304. wagtail/admin/views/generic/ordering.py +1 -1
  305. wagtail/admin/views/generic/preview.py +4 -4
  306. wagtail/admin/views/home.py +3 -1
  307. wagtail/admin/views/pages/create.py +78 -29
  308. wagtail/admin/views/pages/edit.py +162 -34
  309. wagtail/admin/views/pages/preview.py +4 -4
  310. wagtail/admin/views/pages/revisions.py +3 -0
  311. wagtail/admin/views/pages/search.py +4 -4
  312. wagtail/admin/views/pages/usage.py +2 -2
  313. wagtail/admin/views/tags.py +2 -2
  314. wagtail/admin/views/workflows.py +73 -54
  315. wagtail/admin/viewsets/model.py +34 -8
  316. wagtail/admin/widgets/button.py +11 -4
  317. wagtail/admin/widgets/chooser.py +6 -4
  318. wagtail/admin/widgets/slug.py +1 -1
  319. wagtail/api/v2/filters.py +27 -21
  320. wagtail/api/v2/pagination.py +4 -4
  321. wagtail/api/v2/views.py +7 -7
  322. wagtail/blocks/list_block.py +0 -8
  323. wagtail/blocks/migrations/migrate_operation.py +8 -1
  324. wagtail/blocks/stream_block.py +2 -10
  325. wagtail/blocks/struct_block.py +192 -12
  326. wagtail/compat.py +2 -2
  327. wagtail/contrib/forms/locale/af/LC_MESSAGES/django.mo +0 -0
  328. wagtail/contrib/forms/locale/az_AZ/LC_MESSAGES/django.mo +0 -0
  329. wagtail/contrib/forms/locale/bg/LC_MESSAGES/django.mo +0 -0
  330. wagtail/contrib/forms/locale/bg/LC_MESSAGES/django.po +4 -1
  331. wagtail/contrib/forms/locale/en/LC_MESSAGES/django.mo +0 -0
  332. wagtail/contrib/forms/locale/en/LC_MESSAGES/django.po +1 -1
  333. wagtail/contrib/forms/locale/eu/LC_MESSAGES/django.mo +0 -0
  334. wagtail/contrib/forms/locale/ht/LC_MESSAGES/django.mo +0 -0
  335. wagtail/contrib/forms/locale/hy/LC_MESSAGES/django.mo +0 -0
  336. wagtail/contrib/forms/locale/it/LC_MESSAGES/django.mo +0 -0
  337. wagtail/contrib/forms/locale/it/LC_MESSAGES/django.po +40 -2
  338. wagtail/contrib/forms/locale/ka/LC_MESSAGES/django.mo +0 -0
  339. wagtail/contrib/frontend_cache/backends/azure.py +6 -6
  340. wagtail/contrib/frontend_cache/backends/cloudfront.py +2 -2
  341. wagtail/contrib/frontend_cache/utils.py +1 -1
  342. wagtail/contrib/redirects/forms.py +1 -1
  343. wagtail/contrib/redirects/locale/ar/LC_MESSAGES/django.po +1 -1
  344. wagtail/contrib/redirects/locale/be/LC_MESSAGES/django.po +1 -1
  345. wagtail/contrib/redirects/locale/bg/LC_MESSAGES/django.mo +0 -0
  346. wagtail/contrib/redirects/locale/bg/LC_MESSAGES/django.po +7 -1
  347. wagtail/contrib/redirects/locale/bn/LC_MESSAGES/django.mo +0 -0
  348. wagtail/contrib/redirects/locale/bn/LC_MESSAGES/django.po +1 -1
  349. wagtail/contrib/redirects/locale/ca/LC_MESSAGES/django.po +1 -1
  350. wagtail/contrib/redirects/locale/cs/LC_MESSAGES/django.po +1 -1
  351. wagtail/contrib/redirects/locale/cy/LC_MESSAGES/django.po +1 -1
  352. wagtail/contrib/redirects/locale/da/LC_MESSAGES/django.po +1 -1
  353. wagtail/contrib/redirects/locale/de/LC_MESSAGES/django.mo +0 -0
  354. wagtail/contrib/redirects/locale/de/LC_MESSAGES/django.po +15 -3
  355. wagtail/contrib/redirects/locale/dv/LC_MESSAGES/django.po +1 -1
  356. wagtail/contrib/redirects/locale/el/LC_MESSAGES/django.po +1 -1
  357. wagtail/contrib/redirects/locale/en/LC_MESSAGES/django.mo +0 -0
  358. wagtail/contrib/redirects/locale/en/LC_MESSAGES/django.po +14 -14
  359. wagtail/contrib/redirects/locale/en_IN/LC_MESSAGES/django.mo +0 -0
  360. wagtail/contrib/redirects/locale/en_IN/LC_MESSAGES/django.po +1 -1
  361. wagtail/contrib/redirects/locale/es/LC_MESSAGES/django.po +1 -1
  362. wagtail/contrib/redirects/locale/es_419/LC_MESSAGES/django.mo +0 -0
  363. wagtail/contrib/redirects/locale/es_419/LC_MESSAGES/django.po +1 -1
  364. wagtail/contrib/redirects/locale/es_VE/LC_MESSAGES/django.mo +0 -0
  365. wagtail/contrib/redirects/locale/es_VE/LC_MESSAGES/django.po +1 -1
  366. wagtail/contrib/redirects/locale/et/LC_MESSAGES/django.po +1 -1
  367. wagtail/contrib/redirects/locale/eu/LC_MESSAGES/django.mo +0 -0
  368. wagtail/contrib/redirects/locale/eu/LC_MESSAGES/django.po +1 -1
  369. wagtail/contrib/redirects/locale/fa/LC_MESSAGES/django.po +1 -1
  370. wagtail/contrib/redirects/locale/fi/LC_MESSAGES/django.po +1 -1
  371. wagtail/contrib/redirects/locale/fr/LC_MESSAGES/django.po +1 -1
  372. wagtail/contrib/redirects/locale/gl/LC_MESSAGES/django.po +1 -1
  373. wagtail/contrib/redirects/locale/he_IL/LC_MESSAGES/django.po +1 -1
  374. wagtail/contrib/redirects/locale/hi/LC_MESSAGES/django.mo +0 -0
  375. wagtail/contrib/redirects/locale/hi/LC_MESSAGES/django.po +1 -1
  376. wagtail/contrib/redirects/locale/hr_HR/LC_MESSAGES/django.po +1 -1
  377. wagtail/contrib/redirects/locale/ht/LC_MESSAGES/django.mo +0 -0
  378. wagtail/contrib/redirects/locale/ht/LC_MESSAGES/django.po +1 -1
  379. wagtail/contrib/redirects/locale/hu/LC_MESSAGES/django.po +1 -1
  380. wagtail/contrib/redirects/locale/hy/LC_MESSAGES/django.mo +0 -0
  381. wagtail/contrib/redirects/locale/hy/LC_MESSAGES/django.po +1 -1
  382. wagtail/contrib/redirects/locale/id_ID/LC_MESSAGES/django.po +1 -1
  383. wagtail/contrib/redirects/locale/is_IS/LC_MESSAGES/django.po +1 -1
  384. wagtail/contrib/redirects/locale/it/LC_MESSAGES/django.mo +0 -0
  385. wagtail/contrib/redirects/locale/it/LC_MESSAGES/django.po +17 -4
  386. wagtail/contrib/redirects/locale/ja/LC_MESSAGES/django.po +1 -1
  387. wagtail/contrib/redirects/locale/ka/LC_MESSAGES/django.mo +0 -0
  388. wagtail/contrib/redirects/locale/ka/LC_MESSAGES/django.po +1 -1
  389. wagtail/contrib/redirects/locale/ko/LC_MESSAGES/django.po +1 -1
  390. wagtail/contrib/redirects/locale/lt/LC_MESSAGES/django.po +1 -1
  391. wagtail/contrib/redirects/locale/lv/LC_MESSAGES/django.po +1 -1
  392. wagtail/contrib/redirects/locale/mi/LC_MESSAGES/django.po +1 -1
  393. wagtail/contrib/redirects/locale/mn/LC_MESSAGES/django.po +1 -1
  394. wagtail/contrib/redirects/locale/my/LC_MESSAGES/django.po +1 -1
  395. wagtail/contrib/redirects/locale/nb/LC_MESSAGES/django.po +1 -1
  396. wagtail/contrib/redirects/locale/nl/LC_MESSAGES/django.po +1 -1
  397. wagtail/contrib/redirects/locale/pl/LC_MESSAGES/django.po +1 -1
  398. wagtail/contrib/redirects/locale/pt_BR/LC_MESSAGES/django.po +1 -1
  399. wagtail/contrib/redirects/locale/pt_PT/LC_MESSAGES/django.po +1 -1
  400. wagtail/contrib/redirects/locale/ro/LC_MESSAGES/django.po +1 -1
  401. wagtail/contrib/redirects/locale/ru/LC_MESSAGES/django.mo +0 -0
  402. wagtail/contrib/redirects/locale/ru/LC_MESSAGES/django.po +15 -2
  403. wagtail/contrib/redirects/locale/sk_SK/LC_MESSAGES/django.po +1 -1
  404. wagtail/contrib/redirects/locale/sl/LC_MESSAGES/django.po +1 -1
  405. wagtail/contrib/redirects/locale/sr/LC_MESSAGES/django.mo +0 -0
  406. wagtail/contrib/redirects/locale/sr/LC_MESSAGES/django.po +1 -1
  407. wagtail/contrib/redirects/locale/sv/LC_MESSAGES/django.mo +0 -0
  408. wagtail/contrib/redirects/locale/sv/LC_MESSAGES/django.po +17 -4
  409. wagtail/contrib/redirects/locale/ta/LC_MESSAGES/django.mo +0 -0
  410. wagtail/contrib/redirects/locale/ta/LC_MESSAGES/django.po +1 -1
  411. wagtail/contrib/redirects/locale/tet/LC_MESSAGES/django.po +1 -1
  412. wagtail/contrib/redirects/locale/th/LC_MESSAGES/django.po +1 -1
  413. wagtail/contrib/redirects/locale/tr/LC_MESSAGES/django.po +1 -1
  414. wagtail/contrib/redirects/locale/tr_TR/LC_MESSAGES/django.po +1 -1
  415. wagtail/contrib/redirects/locale/ug/LC_MESSAGES/django.po +1 -1
  416. wagtail/contrib/redirects/locale/uk/LC_MESSAGES/django.po +1 -1
  417. wagtail/contrib/redirects/locale/vi/LC_MESSAGES/django.po +1 -1
  418. wagtail/contrib/redirects/locale/zh/LC_MESSAGES/django.po +1 -1
  419. wagtail/contrib/redirects/locale/zh_Hans/LC_MESSAGES/django.po +1 -1
  420. wagtail/contrib/redirects/locale/zh_Hant/LC_MESSAGES/django.po +1 -1
  421. wagtail/contrib/redirects/middleware.py +11 -7
  422. wagtail/contrib/redirects/models.py +17 -1
  423. wagtail/contrib/redirects/tests/test_import_admin_views.py +3 -3
  424. wagtail/contrib/redirects/tests/test_redirects.py +56 -8
  425. wagtail/contrib/search_promotions/locale/af/LC_MESSAGES/django.mo +0 -0
  426. wagtail/contrib/search_promotions/locale/af/LC_MESSAGES/django.po +1 -1
  427. wagtail/contrib/search_promotions/locale/ar/LC_MESSAGES/django.po +1 -1
  428. wagtail/contrib/search_promotions/locale/az_AZ/LC_MESSAGES/django.mo +0 -0
  429. wagtail/contrib/search_promotions/locale/az_AZ/LC_MESSAGES/django.po +1 -1
  430. wagtail/contrib/search_promotions/locale/be/LC_MESSAGES/django.po +1 -1
  431. wagtail/contrib/search_promotions/locale/bg/LC_MESSAGES/django.po +1 -1
  432. wagtail/contrib/search_promotions/locale/bn/LC_MESSAGES/django.mo +0 -0
  433. wagtail/contrib/search_promotions/locale/bn/LC_MESSAGES/django.po +1 -1
  434. wagtail/contrib/search_promotions/locale/ca/LC_MESSAGES/django.po +1 -1
  435. wagtail/contrib/search_promotions/locale/cs/LC_MESSAGES/django.po +1 -1
  436. wagtail/contrib/search_promotions/locale/cy/LC_MESSAGES/django.po +1 -1
  437. wagtail/contrib/search_promotions/locale/da/LC_MESSAGES/django.po +1 -1
  438. wagtail/contrib/search_promotions/locale/de/LC_MESSAGES/django.mo +0 -0
  439. wagtail/contrib/search_promotions/locale/de/LC_MESSAGES/django.po +9 -3
  440. wagtail/contrib/search_promotions/locale/dv/LC_MESSAGES/django.po +1 -1
  441. wagtail/contrib/search_promotions/locale/el/LC_MESSAGES/django.po +1 -1
  442. wagtail/contrib/search_promotions/locale/en/LC_MESSAGES/django.mo +0 -0
  443. wagtail/contrib/search_promotions/locale/en/LC_MESSAGES/django.po +6 -6
  444. wagtail/contrib/search_promotions/locale/es/LC_MESSAGES/django.po +1 -1
  445. wagtail/contrib/search_promotions/locale/es_419/LC_MESSAGES/django.mo +0 -0
  446. wagtail/contrib/search_promotions/locale/es_419/LC_MESSAGES/django.po +1 -1
  447. wagtail/contrib/search_promotions/locale/et/LC_MESSAGES/django.po +1 -1
  448. wagtail/contrib/search_promotions/locale/eu/LC_MESSAGES/django.po +1 -1
  449. wagtail/contrib/search_promotions/locale/fa/LC_MESSAGES/django.po +1 -1
  450. wagtail/contrib/search_promotions/locale/fi/LC_MESSAGES/django.po +1 -1
  451. wagtail/contrib/search_promotions/locale/fr/LC_MESSAGES/django.po +1 -1
  452. wagtail/contrib/search_promotions/locale/gl/LC_MESSAGES/django.po +1 -1
  453. wagtail/contrib/search_promotions/locale/he_IL/LC_MESSAGES/django.po +1 -1
  454. wagtail/contrib/search_promotions/locale/hr_HR/LC_MESSAGES/django.po +1 -1
  455. wagtail/contrib/search_promotions/locale/ht/LC_MESSAGES/django.po +1 -1
  456. wagtail/contrib/search_promotions/locale/hu/LC_MESSAGES/django.po +1 -1
  457. wagtail/contrib/search_promotions/locale/id_ID/LC_MESSAGES/django.po +1 -1
  458. wagtail/contrib/search_promotions/locale/is_IS/LC_MESSAGES/django.po +1 -1
  459. wagtail/contrib/search_promotions/locale/it/LC_MESSAGES/django.mo +0 -0
  460. wagtail/contrib/search_promotions/locale/it/LC_MESSAGES/django.po +44 -3
  461. wagtail/contrib/search_promotions/locale/ja/LC_MESSAGES/django.po +1 -1
  462. wagtail/contrib/search_promotions/locale/ka/LC_MESSAGES/django.po +1 -1
  463. wagtail/contrib/search_promotions/locale/ko/LC_MESSAGES/django.po +1 -1
  464. wagtail/contrib/search_promotions/locale/lt/LC_MESSAGES/django.po +1 -1
  465. wagtail/contrib/search_promotions/locale/lv/LC_MESSAGES/django.po +1 -1
  466. wagtail/contrib/search_promotions/locale/mi/LC_MESSAGES/django.po +1 -1
  467. wagtail/contrib/search_promotions/locale/mn/LC_MESSAGES/django.po +1 -1
  468. wagtail/contrib/search_promotions/locale/my/LC_MESSAGES/django.po +1 -1
  469. wagtail/contrib/search_promotions/locale/nb/LC_MESSAGES/django.po +1 -1
  470. wagtail/contrib/search_promotions/locale/nl/LC_MESSAGES/django.po +1 -1
  471. wagtail/contrib/search_promotions/locale/pl/LC_MESSAGES/django.po +1 -1
  472. wagtail/contrib/search_promotions/locale/pt_BR/LC_MESSAGES/django.po +1 -1
  473. wagtail/contrib/search_promotions/locale/pt_PT/LC_MESSAGES/django.po +1 -1
  474. wagtail/contrib/search_promotions/locale/ro/LC_MESSAGES/django.po +1 -1
  475. wagtail/contrib/search_promotions/locale/ru/LC_MESSAGES/django.mo +0 -0
  476. wagtail/contrib/search_promotions/locale/ru/LC_MESSAGES/django.po +9 -3
  477. wagtail/contrib/search_promotions/locale/sk_SK/LC_MESSAGES/django.po +1 -1
  478. wagtail/contrib/search_promotions/locale/sl/LC_MESSAGES/django.po +1 -1
  479. wagtail/contrib/search_promotions/locale/sv/LC_MESSAGES/django.po +1 -1
  480. wagtail/contrib/search_promotions/locale/tet/LC_MESSAGES/django.po +1 -1
  481. wagtail/contrib/search_promotions/locale/th/LC_MESSAGES/django.po +1 -1
  482. wagtail/contrib/search_promotions/locale/tr/LC_MESSAGES/django.po +1 -1
  483. wagtail/contrib/search_promotions/locale/tr_TR/LC_MESSAGES/django.po +1 -1
  484. wagtail/contrib/search_promotions/locale/ug/LC_MESSAGES/django.po +1 -1
  485. wagtail/contrib/search_promotions/locale/uk/LC_MESSAGES/django.po +1 -1
  486. wagtail/contrib/search_promotions/locale/vi/LC_MESSAGES/django.po +1 -1
  487. wagtail/contrib/search_promotions/locale/zh/LC_MESSAGES/django.po +1 -1
  488. wagtail/contrib/search_promotions/locale/zh_Hans/LC_MESSAGES/django.po +1 -1
  489. wagtail/contrib/search_promotions/locale/zh_Hant/LC_MESSAGES/django.po +1 -1
  490. wagtail/contrib/search_promotions/tests.py +1 -1
  491. wagtail/contrib/search_promotions/views/settings.py +24 -25
  492. wagtail/contrib/settings/jinja2tags.py +2 -2
  493. wagtail/contrib/settings/locale/ar/LC_MESSAGES/django.po +1 -1
  494. wagtail/contrib/settings/locale/az_AZ/LC_MESSAGES/django.mo +0 -0
  495. wagtail/contrib/settings/locale/az_AZ/LC_MESSAGES/django.po +1 -1
  496. wagtail/contrib/settings/locale/be/LC_MESSAGES/django.po +1 -1
  497. wagtail/contrib/settings/locale/bg/LC_MESSAGES/django.mo +0 -0
  498. wagtail/{locale/af → contrib/settings/locale/bg}/LC_MESSAGES/django.po +7 -16
  499. wagtail/contrib/settings/locale/bn/LC_MESSAGES/django.po +1 -1
  500. wagtail/contrib/settings/locale/ca/LC_MESSAGES/django.po +1 -1
  501. wagtail/contrib/settings/locale/cs/LC_MESSAGES/django.po +1 -1
  502. wagtail/contrib/settings/locale/cy/LC_MESSAGES/django.po +1 -1
  503. wagtail/contrib/settings/locale/da/LC_MESSAGES/django.po +1 -1
  504. wagtail/contrib/settings/locale/de/LC_MESSAGES/django.mo +0 -0
  505. wagtail/contrib/settings/locale/de/LC_MESSAGES/django.po +7 -3
  506. wagtail/contrib/settings/locale/dv/LC_MESSAGES/django.po +1 -1
  507. wagtail/contrib/settings/locale/el/LC_MESSAGES/django.po +1 -1
  508. wagtail/contrib/settings/locale/en/LC_MESSAGES/django.mo +0 -0
  509. wagtail/contrib/settings/locale/en/LC_MESSAGES/django.po +2 -2
  510. wagtail/contrib/settings/locale/en_IN/LC_MESSAGES/django.mo +0 -0
  511. wagtail/contrib/settings/locale/en_IN/LC_MESSAGES/django.po +1 -1
  512. wagtail/contrib/settings/locale/es/LC_MESSAGES/django.po +1 -1
  513. wagtail/contrib/settings/locale/es_419/LC_MESSAGES/django.po +1 -1
  514. wagtail/contrib/settings/locale/es_VE/LC_MESSAGES/django.po +1 -1
  515. wagtail/contrib/settings/locale/et/LC_MESSAGES/django.po +1 -1
  516. wagtail/contrib/settings/locale/fa/LC_MESSAGES/django.po +1 -1
  517. wagtail/contrib/settings/locale/fi/LC_MESSAGES/django.po +1 -1
  518. wagtail/contrib/settings/locale/fr/LC_MESSAGES/django.po +1 -1
  519. wagtail/contrib/settings/locale/gl/LC_MESSAGES/django.po +1 -1
  520. wagtail/contrib/settings/locale/he_IL/LC_MESSAGES/django.po +1 -1
  521. wagtail/contrib/settings/locale/hi/LC_MESSAGES/django.po +1 -1
  522. wagtail/contrib/settings/locale/hr_HR/LC_MESSAGES/django.po +1 -1
  523. wagtail/contrib/settings/locale/ht/LC_MESSAGES/django.po +1 -1
  524. wagtail/contrib/settings/locale/hu/LC_MESSAGES/django.po +1 -1
  525. wagtail/contrib/settings/locale/id_ID/LC_MESSAGES/django.po +1 -1
  526. wagtail/contrib/settings/locale/is_IS/LC_MESSAGES/django.po +1 -1
  527. wagtail/contrib/settings/locale/it/LC_MESSAGES/django.mo +0 -0
  528. wagtail/contrib/settings/locale/it/LC_MESSAGES/django.po +7 -3
  529. wagtail/contrib/settings/locale/ja/LC_MESSAGES/django.po +1 -1
  530. wagtail/contrib/settings/locale/ka/LC_MESSAGES/django.mo +0 -0
  531. wagtail/contrib/settings/locale/ka/LC_MESSAGES/django.po +1 -1
  532. wagtail/contrib/settings/locale/ko/LC_MESSAGES/django.po +1 -1
  533. wagtail/contrib/settings/locale/lt/LC_MESSAGES/django.po +1 -1
  534. wagtail/contrib/settings/locale/lv/LC_MESSAGES/django.po +1 -1
  535. wagtail/contrib/settings/locale/mi/LC_MESSAGES/django.po +1 -1
  536. wagtail/contrib/settings/locale/mn/LC_MESSAGES/django.po +1 -1
  537. wagtail/contrib/settings/locale/my/LC_MESSAGES/django.po +1 -1
  538. wagtail/contrib/settings/locale/nb/LC_MESSAGES/django.po +1 -1
  539. wagtail/contrib/settings/locale/nl/LC_MESSAGES/django.po +1 -1
  540. wagtail/contrib/settings/locale/pl/LC_MESSAGES/django.po +1 -1
  541. wagtail/contrib/settings/locale/pt_BR/LC_MESSAGES/django.po +1 -1
  542. wagtail/contrib/settings/locale/pt_PT/LC_MESSAGES/django.po +1 -1
  543. wagtail/contrib/settings/locale/ro/LC_MESSAGES/django.po +1 -1
  544. wagtail/contrib/settings/locale/ru/LC_MESSAGES/django.po +1 -1
  545. wagtail/contrib/settings/locale/sk_SK/LC_MESSAGES/django.po +1 -1
  546. wagtail/contrib/settings/locale/sl/LC_MESSAGES/django.po +1 -1
  547. wagtail/contrib/settings/locale/sr/LC_MESSAGES/django.mo +0 -0
  548. wagtail/contrib/settings/locale/sr/LC_MESSAGES/django.po +1 -1
  549. wagtail/contrib/settings/locale/sv/LC_MESSAGES/django.mo +0 -0
  550. wagtail/contrib/settings/locale/sv/LC_MESSAGES/django.po +4 -3
  551. wagtail/contrib/settings/locale/ta/LC_MESSAGES/django.po +1 -1
  552. wagtail/contrib/settings/locale/tet/LC_MESSAGES/django.po +1 -1
  553. wagtail/contrib/settings/locale/th/LC_MESSAGES/django.po +1 -1
  554. wagtail/contrib/settings/locale/tr/LC_MESSAGES/django.po +1 -1
  555. wagtail/contrib/settings/locale/tr_TR/LC_MESSAGES/django.po +1 -1
  556. wagtail/contrib/settings/locale/ug/LC_MESSAGES/django.po +1 -1
  557. wagtail/contrib/settings/locale/uk/LC_MESSAGES/django.po +1 -1
  558. wagtail/contrib/settings/locale/vi/LC_MESSAGES/django.po +1 -1
  559. wagtail/contrib/settings/locale/zh/LC_MESSAGES/django.mo +0 -0
  560. wagtail/contrib/settings/locale/zh/LC_MESSAGES/django.po +1 -1
  561. wagtail/contrib/settings/locale/zh_Hans/LC_MESSAGES/django.po +1 -1
  562. wagtail/contrib/settings/locale/zh_Hant/LC_MESSAGES/django.po +1 -1
  563. wagtail/contrib/settings/tests/generic/test_admin.py +118 -2
  564. wagtail/contrib/settings/tests/site_specific/test_admin.py +78 -15
  565. wagtail/contrib/settings/tests/site_specific/test_model.py +2 -2
  566. wagtail/contrib/settings/views.py +7 -0
  567. wagtail/contrib/simple_translation/locale/bg/LC_MESSAGES/django.mo +0 -0
  568. wagtail/contrib/simple_translation/locale/bg/LC_MESSAGES/django.po +26 -0
  569. wagtail/contrib/simple_translation/locale/el/LC_MESSAGES/django.mo +0 -0
  570. wagtail/contrib/simple_translation/locale/en/LC_MESSAGES/django.mo +0 -0
  571. wagtail/contrib/simple_translation/locale/en/LC_MESSAGES/django.po +1 -1
  572. wagtail/contrib/simple_translation/locale/es_419/LC_MESSAGES/django.mo +0 -0
  573. wagtail/contrib/simple_translation/locale/fa/LC_MESSAGES/django.mo +0 -0
  574. wagtail/contrib/simple_translation/locale/it/LC_MESSAGES/django.mo +0 -0
  575. wagtail/contrib/simple_translation/locale/it/LC_MESSAGES/django.po +5 -2
  576. wagtail/contrib/simple_translation/locale/mi/LC_MESSAGES/django.mo +0 -0
  577. wagtail/contrib/simple_translation/locale/mn/LC_MESSAGES/django.mo +0 -0
  578. wagtail/contrib/simple_translation/locale/nb/LC_MESSAGES/django.mo +0 -0
  579. wagtail/contrib/simple_translation/locale/vi/LC_MESSAGES/django.mo +0 -0
  580. wagtail/contrib/simple_translation/locale/zh_Hant/LC_MESSAGES/django.mo +0 -0
  581. wagtail/contrib/styleguide/locale/af/LC_MESSAGES/django.mo +0 -0
  582. wagtail/contrib/styleguide/locale/af/LC_MESSAGES/django.po +1 -1
  583. wagtail/contrib/styleguide/locale/ar/LC_MESSAGES/django.po +1 -1
  584. wagtail/contrib/styleguide/locale/az_AZ/LC_MESSAGES/django.mo +0 -0
  585. wagtail/contrib/styleguide/locale/az_AZ/LC_MESSAGES/django.po +1 -1
  586. wagtail/contrib/styleguide/locale/be/LC_MESSAGES/django.po +1 -1
  587. wagtail/contrib/styleguide/locale/bg/LC_MESSAGES/django.mo +0 -0
  588. wagtail/contrib/styleguide/locale/bg/LC_MESSAGES/django.po +1 -1
  589. wagtail/contrib/styleguide/locale/bn/LC_MESSAGES/django.mo +0 -0
  590. wagtail/contrib/styleguide/locale/bn/LC_MESSAGES/django.po +1 -1
  591. wagtail/contrib/styleguide/locale/ca/LC_MESSAGES/django.po +1 -1
  592. wagtail/contrib/styleguide/locale/cs/LC_MESSAGES/django.po +1 -1
  593. wagtail/contrib/styleguide/locale/cy/LC_MESSAGES/django.po +1 -1
  594. wagtail/contrib/styleguide/locale/da/LC_MESSAGES/django.mo +0 -0
  595. wagtail/contrib/styleguide/locale/da/LC_MESSAGES/django.po +1 -1
  596. wagtail/contrib/styleguide/locale/de/LC_MESSAGES/django.po +1 -1
  597. wagtail/contrib/styleguide/locale/dv/LC_MESSAGES/django.po +1 -1
  598. wagtail/contrib/styleguide/locale/el/LC_MESSAGES/django.po +1 -1
  599. wagtail/contrib/styleguide/locale/en/LC_MESSAGES/django.mo +0 -0
  600. wagtail/contrib/styleguide/locale/en/LC_MESSAGES/django.po +7 -7
  601. wagtail/contrib/styleguide/locale/en_IN/LC_MESSAGES/django.mo +0 -0
  602. wagtail/contrib/styleguide/locale/en_IN/LC_MESSAGES/django.po +1 -1
  603. wagtail/contrib/styleguide/locale/es/LC_MESSAGES/django.po +1 -1
  604. wagtail/contrib/styleguide/locale/es_419/LC_MESSAGES/django.mo +0 -0
  605. wagtail/contrib/styleguide/locale/es_419/LC_MESSAGES/django.po +1 -1
  606. wagtail/contrib/styleguide/locale/es_VE/LC_MESSAGES/django.mo +0 -0
  607. wagtail/contrib/styleguide/locale/es_VE/LC_MESSAGES/django.po +1 -1
  608. wagtail/contrib/styleguide/locale/et/LC_MESSAGES/django.po +1 -1
  609. wagtail/contrib/styleguide/locale/fa/LC_MESSAGES/django.po +1 -1
  610. wagtail/contrib/styleguide/locale/fi/LC_MESSAGES/django.po +1 -1
  611. wagtail/contrib/styleguide/locale/fr/LC_MESSAGES/django.po +1 -1
  612. wagtail/contrib/styleguide/locale/gl/LC_MESSAGES/django.po +1 -1
  613. wagtail/contrib/styleguide/locale/he_IL/LC_MESSAGES/django.mo +0 -0
  614. wagtail/contrib/styleguide/locale/he_IL/LC_MESSAGES/django.po +1 -1
  615. wagtail/contrib/styleguide/locale/hi/LC_MESSAGES/django.mo +0 -0
  616. wagtail/contrib/styleguide/locale/hi/LC_MESSAGES/django.po +1 -1
  617. wagtail/contrib/styleguide/locale/hr_HR/LC_MESSAGES/django.po +1 -1
  618. wagtail/contrib/styleguide/locale/ht/LC_MESSAGES/django.po +1 -1
  619. wagtail/contrib/styleguide/locale/hu/LC_MESSAGES/django.po +1 -1
  620. wagtail/contrib/styleguide/locale/id_ID/LC_MESSAGES/django.po +1 -1
  621. wagtail/contrib/styleguide/locale/is_IS/LC_MESSAGES/django.po +1 -1
  622. wagtail/contrib/styleguide/locale/it/LC_MESSAGES/django.po +1 -1
  623. wagtail/contrib/styleguide/locale/ja/LC_MESSAGES/django.po +1 -1
  624. wagtail/contrib/styleguide/locale/ka/LC_MESSAGES/django.mo +0 -0
  625. wagtail/contrib/styleguide/locale/ka/LC_MESSAGES/django.po +1 -1
  626. wagtail/contrib/styleguide/locale/ko/LC_MESSAGES/django.po +1 -1
  627. wagtail/contrib/styleguide/locale/lt/LC_MESSAGES/django.po +1 -1
  628. wagtail/contrib/styleguide/locale/lv/LC_MESSAGES/django.po +1 -1
  629. wagtail/contrib/styleguide/locale/mi/LC_MESSAGES/django.po +1 -1
  630. wagtail/contrib/styleguide/locale/mn/LC_MESSAGES/django.po +1 -1
  631. wagtail/contrib/styleguide/locale/my/LC_MESSAGES/django.po +1 -1
  632. wagtail/contrib/styleguide/locale/nb/LC_MESSAGES/django.po +1 -1
  633. wagtail/contrib/styleguide/locale/nl/LC_MESSAGES/django.po +1 -1
  634. wagtail/contrib/styleguide/locale/pl/LC_MESSAGES/django.po +1 -1
  635. wagtail/contrib/styleguide/locale/pt_BR/LC_MESSAGES/django.po +1 -1
  636. wagtail/contrib/styleguide/locale/pt_PT/LC_MESSAGES/django.po +1 -1
  637. wagtail/contrib/styleguide/locale/ro/LC_MESSAGES/django.po +1 -1
  638. wagtail/contrib/styleguide/locale/ru/LC_MESSAGES/django.po +1 -1
  639. wagtail/contrib/styleguide/locale/sk_SK/LC_MESSAGES/django.po +1 -1
  640. wagtail/contrib/styleguide/locale/sl/LC_MESSAGES/django.po +1 -1
  641. wagtail/contrib/styleguide/locale/sv/LC_MESSAGES/django.po +1 -1
  642. wagtail/contrib/styleguide/locale/ta/LC_MESSAGES/django.mo +0 -0
  643. wagtail/contrib/styleguide/locale/ta/LC_MESSAGES/django.po +1 -1
  644. wagtail/contrib/styleguide/locale/tet/LC_MESSAGES/django.po +1 -1
  645. wagtail/contrib/styleguide/locale/th/LC_MESSAGES/django.po +1 -1
  646. wagtail/contrib/styleguide/locale/tr/LC_MESSAGES/django.po +1 -1
  647. wagtail/contrib/styleguide/locale/tr_TR/LC_MESSAGES/django.po +1 -1
  648. wagtail/contrib/styleguide/locale/ug/LC_MESSAGES/django.po +1 -1
  649. wagtail/contrib/styleguide/locale/uk/LC_MESSAGES/django.po +1 -1
  650. wagtail/contrib/styleguide/locale/vi/LC_MESSAGES/django.po +1 -1
  651. wagtail/contrib/styleguide/locale/zh/LC_MESSAGES/django.mo +0 -0
  652. wagtail/contrib/styleguide/locale/zh/LC_MESSAGES/django.po +1 -1
  653. wagtail/contrib/styleguide/locale/zh_Hans/LC_MESSAGES/django.po +1 -1
  654. wagtail/contrib/styleguide/locale/zh_Hant/LC_MESSAGES/django.po +1 -1
  655. wagtail/contrib/styleguide/tests.py +2 -0
  656. wagtail/contrib/styleguide/views.py +4 -5
  657. wagtail/contrib/table_block/locale/ar/LC_MESSAGES/django.mo +0 -0
  658. wagtail/contrib/table_block/locale/ar/LC_MESSAGES/django.po +5 -1
  659. wagtail/contrib/table_block/locale/de/LC_MESSAGES/django.mo +0 -0
  660. wagtail/contrib/table_block/locale/de/LC_MESSAGES/django.po +6 -3
  661. wagtail/contrib/table_block/locale/en/LC_MESSAGES/django.mo +0 -0
  662. wagtail/contrib/table_block/locale/en/LC_MESSAGES/django.po +1 -1
  663. wagtail/contrib/table_block/locale/en_IN/LC_MESSAGES/django.mo +0 -0
  664. wagtail/contrib/table_block/locale/et/LC_MESSAGES/django.mo +0 -0
  665. wagtail/contrib/table_block/locale/fa/LC_MESSAGES/django.mo +0 -0
  666. wagtail/contrib/table_block/locale/ht/LC_MESSAGES/django.mo +0 -0
  667. wagtail/contrib/table_block/locale/id_ID/LC_MESSAGES/django.mo +0 -0
  668. wagtail/contrib/table_block/locale/it/LC_MESSAGES/django.mo +0 -0
  669. wagtail/contrib/table_block/locale/it/LC_MESSAGES/django.po +5 -2
  670. wagtail/contrib/table_block/locale/ja/LC_MESSAGES/django.mo +0 -0
  671. wagtail/contrib/table_block/locale/lv/LC_MESSAGES/django.mo +0 -0
  672. wagtail/contrib/table_block/locale/mi/LC_MESSAGES/django.mo +0 -0
  673. wagtail/contrib/table_block/locale/mn/LC_MESSAGES/django.mo +0 -0
  674. wagtail/contrib/table_block/locale/my/LC_MESSAGES/django.mo +0 -0
  675. wagtail/contrib/table_block/locale/nb/LC_MESSAGES/django.mo +0 -0
  676. wagtail/contrib/table_block/locale/ru/LC_MESSAGES/django.mo +0 -0
  677. wagtail/contrib/table_block/locale/ru/LC_MESSAGES/django.po +6 -3
  678. wagtail/contrib/table_block/locale/sk_SK/LC_MESSAGES/django.mo +0 -0
  679. wagtail/contrib/table_block/locale/tet/LC_MESSAGES/django.mo +0 -0
  680. wagtail/contrib/table_block/locale/tr_TR/LC_MESSAGES/django.mo +0 -0
  681. wagtail/contrib/table_block/locale/zh_Hant/LC_MESSAGES/django.mo +0 -0
  682. wagtail/contrib/typed_table_block/blocks.py +37 -0
  683. wagtail/contrib/typed_table_block/locale/ar/LC_MESSAGES/django.po +1 -1
  684. wagtail/contrib/typed_table_block/locale/be/LC_MESSAGES/django.po +1 -1
  685. wagtail/contrib/typed_table_block/locale/ca/LC_MESSAGES/django.po +1 -1
  686. wagtail/contrib/typed_table_block/locale/cy/LC_MESSAGES/django.po +1 -1
  687. wagtail/contrib/typed_table_block/locale/de/LC_MESSAGES/django.po +1 -1
  688. wagtail/contrib/typed_table_block/locale/dv/LC_MESSAGES/django.mo +0 -0
  689. wagtail/contrib/typed_table_block/locale/dv/LC_MESSAGES/django.po +1 -1
  690. wagtail/contrib/typed_table_block/locale/el/LC_MESSAGES/django.po +1 -1
  691. wagtail/contrib/typed_table_block/locale/en/LC_MESSAGES/django.mo +0 -0
  692. wagtail/contrib/typed_table_block/locale/en/LC_MESSAGES/django.po +10 -10
  693. wagtail/contrib/typed_table_block/locale/es/LC_MESSAGES/django.po +1 -1
  694. wagtail/contrib/typed_table_block/locale/fa/LC_MESSAGES/django.po +1 -1
  695. wagtail/contrib/typed_table_block/locale/fi/LC_MESSAGES/django.po +1 -1
  696. wagtail/contrib/typed_table_block/locale/fr/LC_MESSAGES/django.po +1 -1
  697. wagtail/contrib/typed_table_block/locale/gl/LC_MESSAGES/django.po +1 -1
  698. wagtail/contrib/typed_table_block/locale/hu/LC_MESSAGES/django.po +1 -1
  699. wagtail/contrib/typed_table_block/locale/is_IS/LC_MESSAGES/django.po +1 -1
  700. wagtail/contrib/typed_table_block/locale/it/LC_MESSAGES/django.po +1 -1
  701. wagtail/contrib/typed_table_block/locale/ko/LC_MESSAGES/django.po +1 -1
  702. wagtail/contrib/typed_table_block/locale/lt/LC_MESSAGES/django.po +1 -1
  703. wagtail/contrib/typed_table_block/locale/nl/LC_MESSAGES/django.po +1 -1
  704. wagtail/contrib/typed_table_block/locale/pl/LC_MESSAGES/django.po +1 -1
  705. wagtail/contrib/typed_table_block/locale/pt_BR/LC_MESSAGES/django.po +1 -1
  706. wagtail/contrib/typed_table_block/locale/pt_PT/LC_MESSAGES/django.po +1 -1
  707. wagtail/contrib/typed_table_block/locale/ro/LC_MESSAGES/django.po +1 -1
  708. wagtail/contrib/typed_table_block/locale/ru/LC_MESSAGES/django.po +1 -1
  709. wagtail/contrib/typed_table_block/locale/sl/LC_MESSAGES/django.po +1 -1
  710. wagtail/contrib/typed_table_block/locale/sv/LC_MESSAGES/django.mo +0 -0
  711. wagtail/contrib/typed_table_block/locale/sv/LC_MESSAGES/django.po +5 -4
  712. wagtail/contrib/typed_table_block/locale/th/LC_MESSAGES/django.mo +0 -0
  713. wagtail/contrib/typed_table_block/locale/th/LC_MESSAGES/django.po +1 -1
  714. wagtail/contrib/typed_table_block/locale/tr/LC_MESSAGES/django.po +1 -1
  715. wagtail/contrib/typed_table_block/locale/ug/LC_MESSAGES/django.po +1 -1
  716. wagtail/contrib/typed_table_block/locale/uk/LC_MESSAGES/django.po +1 -1
  717. wagtail/contrib/typed_table_block/locale/zh/LC_MESSAGES/django.po +1 -1
  718. wagtail/contrib/typed_table_block/locale/zh_Hans/LC_MESSAGES/django.po +1 -1
  719. wagtail/contrib/typed_table_block/locale/zh_Hant/LC_MESSAGES/django.po +1 -1
  720. wagtail/contrib/typed_table_block/tests.py +108 -0
  721. wagtail/coreutils.py +4 -4
  722. wagtail/documents/__init__.py +4 -4
  723. wagtail/documents/locale/af/LC_MESSAGES/django.mo +0 -0
  724. wagtail/documents/locale/af/LC_MESSAGES/django.po +1 -1
  725. wagtail/documents/locale/ar/LC_MESSAGES/django.po +1 -1
  726. wagtail/documents/locale/az_AZ/LC_MESSAGES/django.mo +0 -0
  727. wagtail/documents/locale/az_AZ/LC_MESSAGES/django.po +1 -1
  728. wagtail/documents/locale/be/LC_MESSAGES/django.po +1 -1
  729. wagtail/documents/locale/bg/LC_MESSAGES/django.mo +0 -0
  730. wagtail/documents/locale/bg/LC_MESSAGES/django.po +10 -1
  731. wagtail/documents/locale/bn/LC_MESSAGES/django.mo +0 -0
  732. wagtail/documents/locale/bn/LC_MESSAGES/django.po +1 -1
  733. wagtail/documents/locale/ca/LC_MESSAGES/django.po +1 -1
  734. wagtail/documents/locale/cs/LC_MESSAGES/django.po +1 -1
  735. wagtail/documents/locale/cy/LC_MESSAGES/django.po +1 -1
  736. wagtail/documents/locale/da/LC_MESSAGES/django.po +1 -1
  737. wagtail/documents/locale/de/LC_MESSAGES/django.mo +0 -0
  738. wagtail/documents/locale/de/LC_MESSAGES/django.po +18 -3
  739. wagtail/documents/locale/dv/LC_MESSAGES/django.po +1 -1
  740. wagtail/documents/locale/el/LC_MESSAGES/django.po +1 -1
  741. wagtail/documents/locale/en/LC_MESSAGES/django.mo +0 -0
  742. wagtail/documents/locale/en/LC_MESSAGES/django.po +15 -15
  743. wagtail/documents/locale/es/LC_MESSAGES/django.po +1 -1
  744. wagtail/documents/locale/es_419/LC_MESSAGES/django.po +1 -1
  745. wagtail/documents/locale/et/LC_MESSAGES/django.po +1 -1
  746. wagtail/documents/locale/eu/LC_MESSAGES/django.mo +0 -0
  747. wagtail/documents/locale/eu/LC_MESSAGES/django.po +1 -1
  748. wagtail/documents/locale/fa/LC_MESSAGES/django.po +1 -1
  749. wagtail/documents/locale/fi/LC_MESSAGES/django.po +1 -1
  750. wagtail/documents/locale/fr/LC_MESSAGES/django.po +1 -1
  751. wagtail/documents/locale/gl/LC_MESSAGES/django.po +1 -1
  752. wagtail/documents/locale/he_IL/LC_MESSAGES/django.po +1 -1
  753. wagtail/documents/locale/hr_HR/LC_MESSAGES/django.po +1 -1
  754. wagtail/documents/locale/ht/LC_MESSAGES/django.mo +0 -0
  755. wagtail/documents/locale/ht/LC_MESSAGES/django.po +1 -1
  756. wagtail/documents/locale/hu/LC_MESSAGES/django.po +1 -1
  757. wagtail/documents/locale/id_ID/LC_MESSAGES/django.po +1 -1
  758. wagtail/documents/locale/is_IS/LC_MESSAGES/django.po +1 -1
  759. wagtail/documents/locale/it/LC_MESSAGES/django.mo +0 -0
  760. wagtail/documents/locale/it/LC_MESSAGES/django.po +33 -3
  761. wagtail/documents/locale/ja/LC_MESSAGES/django.po +1 -1
  762. wagtail/documents/locale/ka/LC_MESSAGES/django.po +1 -1
  763. wagtail/documents/locale/ko/LC_MESSAGES/django.po +1 -1
  764. wagtail/documents/locale/lt/LC_MESSAGES/django.po +1 -1
  765. wagtail/documents/locale/lv/LC_MESSAGES/django.po +1 -1
  766. wagtail/documents/locale/mi/LC_MESSAGES/django.po +1 -1
  767. wagtail/documents/locale/mn/LC_MESSAGES/django.po +1 -1
  768. wagtail/documents/locale/my/LC_MESSAGES/django.po +1 -1
  769. wagtail/documents/locale/nb/LC_MESSAGES/django.po +1 -1
  770. wagtail/documents/locale/nl/LC_MESSAGES/django.po +1 -1
  771. wagtail/documents/locale/pl/LC_MESSAGES/django.po +1 -1
  772. wagtail/documents/locale/pt_BR/LC_MESSAGES/django.po +1 -1
  773. wagtail/documents/locale/pt_PT/LC_MESSAGES/django.po +1 -1
  774. wagtail/documents/locale/ro/LC_MESSAGES/django.po +1 -1
  775. wagtail/documents/locale/ru/LC_MESSAGES/django.mo +0 -0
  776. wagtail/documents/locale/ru/LC_MESSAGES/django.po +7 -3
  777. wagtail/documents/locale/sk_SK/LC_MESSAGES/django.po +1 -1
  778. wagtail/documents/locale/sl/LC_MESSAGES/django.po +1 -1
  779. wagtail/documents/locale/sv/LC_MESSAGES/django.mo +0 -0
  780. wagtail/documents/locale/sv/LC_MESSAGES/django.po +33 -5
  781. wagtail/documents/locale/tet/LC_MESSAGES/django.po +1 -1
  782. wagtail/documents/locale/th/LC_MESSAGES/django.po +1 -1
  783. wagtail/documents/locale/tr/LC_MESSAGES/django.po +1 -1
  784. wagtail/documents/locale/tr_TR/LC_MESSAGES/django.po +1 -1
  785. wagtail/documents/locale/ug/LC_MESSAGES/django.po +1 -1
  786. wagtail/documents/locale/uk/LC_MESSAGES/django.po +1 -1
  787. wagtail/documents/locale/vi/LC_MESSAGES/django.po +1 -1
  788. wagtail/documents/locale/zh/LC_MESSAGES/django.po +1 -1
  789. wagtail/documents/locale/zh_Hans/LC_MESSAGES/django.po +1 -1
  790. wagtail/documents/locale/zh_Hant/LC_MESSAGES/django.po +1 -1
  791. wagtail/documents/models.py +1 -1
  792. wagtail/documents/tests/test_admin_views.py +19 -4
  793. wagtail/documents/tests/test_search.py +0 -2
  794. wagtail/documents/tests/test_views.py +6 -4
  795. wagtail/documents/views/bulk_actions/add_tags.py +1 -1
  796. wagtail/embeds/finders/facebook.py +3 -3
  797. wagtail/embeds/finders/instagram.py +3 -3
  798. wagtail/embeds/finders/oembed.py +7 -2
  799. wagtail/embeds/locale/az_AZ/LC_MESSAGES/django.mo +0 -0
  800. wagtail/embeds/locale/bn/LC_MESSAGES/django.mo +0 -0
  801. wagtail/embeds/locale/da/LC_MESSAGES/django.mo +0 -0
  802. wagtail/embeds/locale/dv/LC_MESSAGES/django.mo +0 -0
  803. wagtail/embeds/locale/en/LC_MESSAGES/django.mo +0 -0
  804. wagtail/embeds/locale/en/LC_MESSAGES/django.po +1 -1
  805. wagtail/embeds/locale/es_419/LC_MESSAGES/django.mo +0 -0
  806. wagtail/embeds/locale/lv/LC_MESSAGES/django.mo +0 -0
  807. wagtail/embeds/locale/my/LC_MESSAGES/django.mo +0 -0
  808. wagtail/embeds/oembed_providers.py +8 -0
  809. wagtail/embeds/tests/test_embeds.py +35 -0
  810. wagtail/images/__init__.py +4 -4
  811. wagtail/images/admin_urls.py +3 -3
  812. wagtail/images/blocks.py +1 -1
  813. wagtail/images/formats.py +2 -2
  814. wagtail/images/image_operations.py +2 -2
  815. wagtail/images/locale/ar/LC_MESSAGES/django.po +1 -1
  816. wagtail/images/locale/az_AZ/LC_MESSAGES/django.mo +0 -0
  817. wagtail/images/locale/az_AZ/LC_MESSAGES/django.po +1 -1
  818. wagtail/images/locale/be/LC_MESSAGES/django.po +1 -1
  819. wagtail/images/locale/bg/LC_MESSAGES/django.mo +0 -0
  820. wagtail/images/locale/bg/LC_MESSAGES/django.po +13 -1
  821. wagtail/images/locale/bn/LC_MESSAGES/django.po +1 -1
  822. wagtail/images/locale/ca/LC_MESSAGES/django.mo +0 -0
  823. wagtail/images/locale/ca/LC_MESSAGES/django.po +6 -3
  824. wagtail/images/locale/cs/LC_MESSAGES/django.po +1 -1
  825. wagtail/images/locale/cy/LC_MESSAGES/django.po +1 -1
  826. wagtail/images/locale/da/LC_MESSAGES/django.po +1 -1
  827. wagtail/images/locale/de/LC_MESSAGES/django.mo +0 -0
  828. wagtail/images/locale/de/LC_MESSAGES/django.po +30 -3
  829. wagtail/images/locale/dv/LC_MESSAGES/django.po +1 -1
  830. wagtail/images/locale/el/LC_MESSAGES/django.po +1 -1
  831. wagtail/images/locale/en/LC_MESSAGES/django.mo +0 -0
  832. wagtail/images/locale/en/LC_MESSAGES/django.po +44 -40
  833. wagtail/images/locale/es/LC_MESSAGES/django.po +1 -1
  834. wagtail/images/locale/es_419/LC_MESSAGES/django.po +1 -1
  835. wagtail/images/locale/et/LC_MESSAGES/django.po +1 -1
  836. wagtail/images/locale/eu/LC_MESSAGES/django.mo +0 -0
  837. wagtail/images/locale/eu/LC_MESSAGES/django.po +1 -1
  838. wagtail/images/locale/fa/LC_MESSAGES/django.po +1 -1
  839. wagtail/images/locale/fi/LC_MESSAGES/django.po +1 -1
  840. wagtail/images/locale/fr/LC_MESSAGES/django.mo +0 -0
  841. wagtail/images/locale/fr/LC_MESSAGES/django.po +6 -3
  842. wagtail/images/locale/gl/LC_MESSAGES/django.mo +0 -0
  843. wagtail/images/locale/gl/LC_MESSAGES/django.po +6 -3
  844. wagtail/images/locale/he_IL/LC_MESSAGES/django.po +1 -1
  845. wagtail/images/locale/hr_HR/LC_MESSAGES/django.po +1 -1
  846. wagtail/images/locale/ht/LC_MESSAGES/django.mo +0 -0
  847. wagtail/images/locale/ht/LC_MESSAGES/django.po +1 -1
  848. wagtail/images/locale/hu/LC_MESSAGES/django.po +1 -1
  849. wagtail/images/locale/id_ID/LC_MESSAGES/django.po +1 -1
  850. wagtail/images/locale/is_IS/LC_MESSAGES/django.po +1 -1
  851. wagtail/images/locale/it/LC_MESSAGES/django.mo +0 -0
  852. wagtail/images/locale/it/LC_MESSAGES/django.po +54 -5
  853. wagtail/images/locale/ja/LC_MESSAGES/django.po +1 -1
  854. wagtail/images/locale/ka/LC_MESSAGES/django.po +1 -1
  855. wagtail/images/locale/ko/LC_MESSAGES/django.po +1 -1
  856. wagtail/images/locale/lt/LC_MESSAGES/django.po +1 -1
  857. wagtail/images/locale/lv/LC_MESSAGES/django.po +1 -1
  858. wagtail/images/locale/mi/LC_MESSAGES/django.po +1 -1
  859. wagtail/images/locale/mn/LC_MESSAGES/django.po +1 -1
  860. wagtail/images/locale/my/LC_MESSAGES/django.po +1 -1
  861. wagtail/images/locale/nb/LC_MESSAGES/django.po +1 -1
  862. wagtail/images/locale/nl/LC_MESSAGES/django.mo +0 -0
  863. wagtail/images/locale/nl/LC_MESSAGES/django.po +9 -6
  864. wagtail/images/locale/pl/LC_MESSAGES/django.po +1 -1
  865. wagtail/images/locale/pt_BR/LC_MESSAGES/django.po +1 -1
  866. wagtail/images/locale/pt_PT/LC_MESSAGES/django.po +1 -1
  867. wagtail/images/locale/ro/LC_MESSAGES/django.po +1 -1
  868. wagtail/images/locale/ru/LC_MESSAGES/django.mo +0 -0
  869. wagtail/images/locale/ru/LC_MESSAGES/django.po +15 -3
  870. wagtail/images/locale/sk_SK/LC_MESSAGES/django.po +1 -1
  871. wagtail/images/locale/sl/LC_MESSAGES/django.po +1 -1
  872. wagtail/images/locale/sv/LC_MESSAGES/django.mo +0 -0
  873. wagtail/images/locale/sv/LC_MESSAGES/django.po +50 -7
  874. wagtail/images/locale/tet/LC_MESSAGES/django.po +1 -1
  875. wagtail/images/locale/th/LC_MESSAGES/django.po +1 -1
  876. wagtail/images/locale/tr/LC_MESSAGES/django.po +1 -1
  877. wagtail/images/locale/tr_TR/LC_MESSAGES/django.po +1 -1
  878. wagtail/images/locale/ug/LC_MESSAGES/django.po +1 -1
  879. wagtail/images/locale/uk/LC_MESSAGES/django.po +1 -1
  880. wagtail/images/locale/vi/LC_MESSAGES/django.po +1 -1
  881. wagtail/images/locale/zh/LC_MESSAGES/django.po +1 -1
  882. wagtail/images/locale/zh_Hans/LC_MESSAGES/django.po +1 -1
  883. wagtail/images/locale/zh_Hant/LC_MESSAGES/django.po +1 -1
  884. wagtail/images/models.py +11 -10
  885. wagtail/images/templates/wagtailimages/images/index_results.html +1 -1
  886. wagtail/images/templates/wagtailimages/images/url_generator.html +17 -38
  887. wagtail/images/templates/wagtailimages/images/url_generator_output.html +28 -0
  888. wagtail/images/templatetags/wagtailimages_tags.py +4 -4
  889. wagtail/images/tests/test_admin_views.py +432 -59
  890. wagtail/images/tests/test_image_operations.py +2 -2
  891. wagtail/images/tests/test_models.py +0 -2
  892. wagtail/images/views/bulk_actions/add_tags.py +1 -1
  893. wagtail/images/views/images.py +72 -39
  894. wagtail/locale/ar/LC_MESSAGES/django.mo +0 -0
  895. wagtail/locale/ar/LC_MESSAGES/django.po +10 -19
  896. wagtail/locale/az_AZ/LC_MESSAGES/django.mo +0 -0
  897. wagtail/locale/az_AZ/LC_MESSAGES/django.po +4 -4
  898. wagtail/locale/be/LC_MESSAGES/django.mo +0 -0
  899. wagtail/locale/be/LC_MESSAGES/django.po +7 -19
  900. wagtail/locale/bg/LC_MESSAGES/django.mo +0 -0
  901. wagtail/locale/bg/LC_MESSAGES/django.po +35 -11
  902. wagtail/locale/bn/LC_MESSAGES/django.po +4 -4
  903. wagtail/locale/ca/LC_MESSAGES/django.mo +0 -0
  904. wagtail/locale/ca/LC_MESSAGES/django.po +24 -21
  905. wagtail/locale/cs/LC_MESSAGES/django.mo +0 -0
  906. wagtail/locale/cs/LC_MESSAGES/django.po +7 -19
  907. wagtail/locale/cy/LC_MESSAGES/django.mo +0 -0
  908. wagtail/locale/cy/LC_MESSAGES/django.po +10 -13
  909. wagtail/locale/da/LC_MESSAGES/django.mo +0 -0
  910. wagtail/locale/da/LC_MESSAGES/django.po +7 -16
  911. wagtail/locale/de/LC_MESSAGES/django.mo +0 -0
  912. wagtail/locale/de/LC_MESSAGES/django.po +41 -21
  913. wagtail/locale/dv/LC_MESSAGES/django.mo +0 -0
  914. wagtail/locale/dv/LC_MESSAGES/django.po +7 -19
  915. wagtail/locale/el/LC_MESSAGES/django.mo +0 -0
  916. wagtail/locale/el/LC_MESSAGES/django.po +10 -19
  917. wagtail/locale/en/LC_MESSAGES/django.mo +0 -0
  918. wagtail/locale/en/LC_MESSAGES/django.po +103 -105
  919. wagtail/locale/es/LC_MESSAGES/django.mo +0 -0
  920. wagtail/locale/es/LC_MESSAGES/django.po +10 -19
  921. wagtail/locale/es_419/LC_MESSAGES/django.po +6 -6
  922. wagtail/locale/et/LC_MESSAGES/django.mo +0 -0
  923. wagtail/locale/et/LC_MESSAGES/django.po +7 -16
  924. wagtail/locale/eu/LC_MESSAGES/django.mo +0 -0
  925. wagtail/locale/eu/LC_MESSAGES/django.po +4 -10
  926. wagtail/locale/fa/LC_MESSAGES/django.mo +0 -0
  927. wagtail/locale/fa/LC_MESSAGES/django.po +10 -16
  928. wagtail/locale/fi/LC_MESSAGES/django.mo +0 -0
  929. wagtail/locale/fi/LC_MESSAGES/django.po +10 -19
  930. wagtail/locale/fr/LC_MESSAGES/django.mo +0 -0
  931. wagtail/locale/fr/LC_MESSAGES/django.po +24 -21
  932. wagtail/locale/gl/LC_MESSAGES/django.mo +0 -0
  933. wagtail/locale/gl/LC_MESSAGES/django.po +24 -21
  934. wagtail/locale/he_IL/LC_MESSAGES/django.mo +0 -0
  935. wagtail/locale/he_IL/LC_MESSAGES/django.po +10 -16
  936. wagtail/locale/hr_HR/LC_MESSAGES/django.mo +0 -0
  937. wagtail/locale/hr_HR/LC_MESSAGES/django.po +7 -16
  938. wagtail/locale/hu/LC_MESSAGES/django.mo +0 -0
  939. wagtail/locale/hu/LC_MESSAGES/django.po +10 -19
  940. wagtail/locale/id_ID/LC_MESSAGES/django.mo +0 -0
  941. wagtail/locale/id_ID/LC_MESSAGES/django.po +7 -16
  942. wagtail/locale/is_IS/LC_MESSAGES/django.mo +0 -0
  943. wagtail/locale/is_IS/LC_MESSAGES/django.po +10 -19
  944. wagtail/locale/it/LC_MESSAGES/django.mo +0 -0
  945. wagtail/locale/it/LC_MESSAGES/django.po +126 -21
  946. wagtail/locale/ja/LC_MESSAGES/django.mo +0 -0
  947. wagtail/locale/ja/LC_MESSAGES/django.po +10 -19
  948. wagtail/locale/ka/LC_MESSAGES/django.mo +0 -0
  949. wagtail/locale/ka/LC_MESSAGES/django.po +9 -6
  950. wagtail/locale/ko/LC_MESSAGES/django.mo +0 -0
  951. wagtail/locale/ko/LC_MESSAGES/django.po +10 -19
  952. wagtail/locale/lt/LC_MESSAGES/django.mo +0 -0
  953. wagtail/locale/lt/LC_MESSAGES/django.po +7 -19
  954. wagtail/locale/lv/LC_MESSAGES/django.mo +0 -0
  955. wagtail/locale/lv/LC_MESSAGES/django.po +10 -19
  956. wagtail/locale/mi/LC_MESSAGES/django.mo +0 -0
  957. wagtail/locale/mi/LC_MESSAGES/django.po +7 -13
  958. wagtail/locale/mn/LC_MESSAGES/django.mo +0 -0
  959. wagtail/locale/mn/LC_MESSAGES/django.po +7 -16
  960. wagtail/locale/my/LC_MESSAGES/django.po +6 -6
  961. wagtail/locale/nb/LC_MESSAGES/django.mo +0 -0
  962. wagtail/locale/nb/LC_MESSAGES/django.po +10 -16
  963. wagtail/locale/nl/LC_MESSAGES/django.mo +0 -0
  964. wagtail/locale/nl/LC_MESSAGES/django.po +22 -21
  965. wagtail/locale/pl/LC_MESSAGES/django.mo +0 -0
  966. wagtail/locale/pl/LC_MESSAGES/django.po +10 -19
  967. wagtail/locale/pt_BR/LC_MESSAGES/django.mo +0 -0
  968. wagtail/locale/pt_BR/LC_MESSAGES/django.po +10 -19
  969. wagtail/locale/pt_PT/LC_MESSAGES/django.mo +0 -0
  970. wagtail/locale/pt_PT/LC_MESSAGES/django.po +10 -19
  971. wagtail/locale/ro/LC_MESSAGES/django.mo +0 -0
  972. wagtail/locale/ro/LC_MESSAGES/django.po +10 -19
  973. wagtail/locale/ru/LC_MESSAGES/django.mo +0 -0
  974. wagtail/locale/ru/LC_MESSAGES/django.po +32 -21
  975. wagtail/locale/sk_SK/LC_MESSAGES/django.mo +0 -0
  976. wagtail/locale/sk_SK/LC_MESSAGES/django.po +10 -16
  977. wagtail/locale/sl/LC_MESSAGES/django.mo +0 -0
  978. wagtail/locale/sl/LC_MESSAGES/django.po +10 -19
  979. wagtail/locale/sv/LC_MESSAGES/django.mo +0 -0
  980. wagtail/locale/sv/LC_MESSAGES/django.po +105 -31
  981. wagtail/locale/tet/LC_MESSAGES/django.mo +0 -0
  982. wagtail/locale/tet/LC_MESSAGES/django.po +7 -16
  983. wagtail/locale/th/LC_MESSAGES/django.mo +0 -0
  984. wagtail/locale/th/LC_MESSAGES/django.po +7 -19
  985. wagtail/locale/tr/LC_MESSAGES/django.mo +0 -0
  986. wagtail/locale/tr/LC_MESSAGES/django.po +10 -19
  987. wagtail/locale/tr_TR/LC_MESSAGES/django.mo +0 -0
  988. wagtail/locale/tr_TR/LC_MESSAGES/django.po +10 -16
  989. wagtail/locale/ug/LC_MESSAGES/django.mo +0 -0
  990. wagtail/locale/ug/LC_MESSAGES/django.po +10 -19
  991. wagtail/locale/uk/LC_MESSAGES/django.mo +0 -0
  992. wagtail/locale/uk/LC_MESSAGES/django.po +7 -19
  993. wagtail/locale/vi/LC_MESSAGES/django.po +7 -7
  994. wagtail/locale/zh/LC_MESSAGES/django.mo +0 -0
  995. wagtail/locale/zh/LC_MESSAGES/django.po +6 -15
  996. wagtail/locale/zh_Hans/LC_MESSAGES/django.mo +0 -0
  997. wagtail/locale/zh_Hans/LC_MESSAGES/django.po +10 -19
  998. wagtail/locale/zh_Hant/LC_MESSAGES/django.mo +0 -0
  999. wagtail/locale/zh_Hant/LC_MESSAGES/django.po +7 -19
  1000. wagtail/locales/locale/bg/LC_MESSAGES/django.mo +0 -0
  1001. wagtail/locales/locale/bg/LC_MESSAGES/django.po +9 -2
  1002. wagtail/locales/locale/da/LC_MESSAGES/django.mo +0 -0
  1003. wagtail/locales/locale/el/LC_MESSAGES/django.mo +0 -0
  1004. wagtail/locales/locale/en/LC_MESSAGES/django.mo +0 -0
  1005. wagtail/locales/locale/en/LC_MESSAGES/django.po +1 -1
  1006. wagtail/locales/locale/et/LC_MESSAGES/django.mo +0 -0
  1007. wagtail/locales/locale/fa/LC_MESSAGES/django.mo +0 -0
  1008. wagtail/locales/locale/he_IL/LC_MESSAGES/django.mo +0 -0
  1009. wagtail/locales/locale/id_ID/LC_MESSAGES/django.mo +0 -0
  1010. wagtail/locales/locale/it/LC_MESSAGES/django.mo +0 -0
  1011. wagtail/locales/locale/it/LC_MESSAGES/django.po +13 -2
  1012. wagtail/locales/locale/ja/LC_MESSAGES/django.mo +0 -0
  1013. wagtail/locales/locale/lv/LC_MESSAGES/django.mo +0 -0
  1014. wagtail/locales/locale/mn/LC_MESSAGES/django.mo +0 -0
  1015. wagtail/locales/locale/my/LC_MESSAGES/django.mo +0 -0
  1016. wagtail/locales/locale/nb/LC_MESSAGES/django.mo +0 -0
  1017. wagtail/locales/locale/ru/LC_MESSAGES/django.mo +0 -0
  1018. wagtail/locales/locale/ru/LC_MESSAGES/django.po +7 -2
  1019. wagtail/locales/locale/sk_SK/LC_MESSAGES/django.mo +0 -0
  1020. wagtail/locales/locale/sv/LC_MESSAGES/django.mo +0 -0
  1021. wagtail/locales/locale/sv/LC_MESSAGES/django.po +4 -3
  1022. wagtail/locales/locale/tet/LC_MESSAGES/django.mo +0 -0
  1023. wagtail/locales/locale/th/LC_MESSAGES/django.mo +0 -0
  1024. wagtail/locales/locale/tr_TR/LC_MESSAGES/django.mo +0 -0
  1025. wagtail/locales/locale/vi/LC_MESSAGES/django.mo +0 -0
  1026. wagtail/locales/locale/zh/LC_MESSAGES/django.mo +0 -0
  1027. wagtail/locales/locale/zh_Hant/LC_MESSAGES/django.mo +0 -0
  1028. wagtail/locales/tests.py +5 -5
  1029. wagtail/models/i18n.py +4 -6
  1030. wagtail/models/media.py +18 -0
  1031. wagtail/models/pages.py +65 -11
  1032. wagtail/models/reference_index.py +15 -0
  1033. wagtail/models/revisions.py +40 -12
  1034. wagtail/models/workflows.py +0 -3
  1035. wagtail/permission_policies/base.py +2 -2
  1036. wagtail/permission_policies/collections.py +2 -2
  1037. wagtail/project_template/requirements.txt +2 -2
  1038. wagtail/search/locale/ar/LC_MESSAGES/django.mo +0 -0
  1039. wagtail/search/locale/be/LC_MESSAGES/django.mo +0 -0
  1040. wagtail/search/locale/ca/LC_MESSAGES/django.mo +0 -0
  1041. wagtail/search/locale/cs/LC_MESSAGES/django.mo +0 -0
  1042. wagtail/search/locale/cy/LC_MESSAGES/django.mo +0 -0
  1043. wagtail/search/locale/de/LC_MESSAGES/django.mo +0 -0
  1044. wagtail/search/locale/dv/LC_MESSAGES/django.mo +0 -0
  1045. wagtail/search/locale/en/LC_MESSAGES/django.mo +0 -0
  1046. wagtail/search/locale/en/LC_MESSAGES/django.po +1 -1
  1047. wagtail/search/locale/es/LC_MESSAGES/django.mo +0 -0
  1048. wagtail/search/locale/et/LC_MESSAGES/django.mo +0 -0
  1049. wagtail/search/locale/fa/LC_MESSAGES/django.mo +0 -0
  1050. wagtail/search/locale/fi/LC_MESSAGES/django.mo +0 -0
  1051. wagtail/search/locale/fr/LC_MESSAGES/django.mo +0 -0
  1052. wagtail/search/locale/gl/LC_MESSAGES/django.mo +0 -0
  1053. wagtail/search/locale/hr_HR/LC_MESSAGES/django.mo +0 -0
  1054. wagtail/search/locale/ht/LC_MESSAGES/django.mo +0 -0
  1055. wagtail/search/locale/hu/LC_MESSAGES/django.mo +0 -0
  1056. wagtail/search/locale/id_ID/LC_MESSAGES/django.mo +0 -0
  1057. wagtail/search/locale/is_IS/LC_MESSAGES/django.mo +0 -0
  1058. wagtail/search/locale/it/LC_MESSAGES/django.mo +0 -0
  1059. wagtail/search/locale/ja/LC_MESSAGES/django.mo +0 -0
  1060. wagtail/search/locale/ko/LC_MESSAGES/django.mo +0 -0
  1061. wagtail/search/locale/lt/LC_MESSAGES/django.mo +0 -0
  1062. wagtail/search/locale/mi/LC_MESSAGES/django.mo +0 -0
  1063. wagtail/search/locale/mn/LC_MESSAGES/django.mo +0 -0
  1064. wagtail/search/locale/my/LC_MESSAGES/django.mo +0 -0
  1065. wagtail/search/locale/nb/LC_MESSAGES/django.mo +0 -0
  1066. wagtail/search/locale/nl/LC_MESSAGES/django.mo +0 -0
  1067. wagtail/search/locale/pl/LC_MESSAGES/django.mo +0 -0
  1068. wagtail/search/locale/pt_BR/LC_MESSAGES/django.mo +0 -0
  1069. wagtail/search/locale/pt_PT/LC_MESSAGES/django.mo +0 -0
  1070. wagtail/search/locale/ro/LC_MESSAGES/django.mo +0 -0
  1071. wagtail/search/locale/ru/LC_MESSAGES/django.mo +0 -0
  1072. wagtail/search/locale/sl/LC_MESSAGES/django.mo +0 -0
  1073. wagtail/search/locale/sv/LC_MESSAGES/django.mo +0 -0
  1074. wagtail/search/locale/tet/LC_MESSAGES/django.mo +0 -0
  1075. wagtail/search/locale/th/LC_MESSAGES/django.mo +0 -0
  1076. wagtail/search/locale/tr/LC_MESSAGES/django.mo +0 -0
  1077. wagtail/search/locale/tr_TR/LC_MESSAGES/django.mo +0 -0
  1078. wagtail/search/locale/ug/LC_MESSAGES/django.mo +0 -0
  1079. wagtail/search/locale/uk/LC_MESSAGES/django.mo +0 -0
  1080. wagtail/search/locale/vi/LC_MESSAGES/django.mo +0 -0
  1081. wagtail/search/locale/zh_Hans/LC_MESSAGES/django.mo +0 -0
  1082. wagtail/search/locale/zh_Hant/LC_MESSAGES/django.mo +0 -0
  1083. wagtail/signal_handlers.py +1 -0
  1084. wagtail/sites/locale/bn/LC_MESSAGES/django.mo +0 -0
  1085. wagtail/sites/locale/da/LC_MESSAGES/django.mo +0 -0
  1086. wagtail/sites/locale/dv/LC_MESSAGES/django.mo +0 -0
  1087. wagtail/sites/locale/en/LC_MESSAGES/django.mo +0 -0
  1088. wagtail/sites/locale/en/LC_MESSAGES/django.po +1 -1
  1089. wagtail/sites/locale/en_IN/LC_MESSAGES/django.mo +0 -0
  1090. wagtail/sites/locale/es_419/LC_MESSAGES/django.mo +0 -0
  1091. wagtail/sites/locale/es_VE/LC_MESSAGES/django.mo +0 -0
  1092. wagtail/sites/locale/hi/LC_MESSAGES/django.mo +0 -0
  1093. wagtail/sites/locale/ka/LC_MESSAGES/django.mo +0 -0
  1094. wagtail/sites/locale/sr/LC_MESSAGES/django.mo +0 -0
  1095. wagtail/sites/locale/sv/LC_MESSAGES/django.mo +0 -0
  1096. wagtail/sites/locale/sv/LC_MESSAGES/django.po +3 -2
  1097. wagtail/sites/locale/ta/LC_MESSAGES/django.mo +0 -0
  1098. wagtail/sites/tests.py +7 -7
  1099. wagtail/snippets/action_menu.py +2 -1
  1100. wagtail/snippets/locale/af/LC_MESSAGES/django.mo +0 -0
  1101. wagtail/snippets/locale/af/LC_MESSAGES/django.po +1 -1
  1102. wagtail/snippets/locale/ar/LC_MESSAGES/django.po +1 -1
  1103. wagtail/snippets/locale/az_AZ/LC_MESSAGES/django.po +1 -1
  1104. wagtail/snippets/locale/be/LC_MESSAGES/django.po +1 -1
  1105. wagtail/snippets/locale/bg/LC_MESSAGES/django.mo +0 -0
  1106. wagtail/snippets/locale/bg/LC_MESSAGES/django.po +10 -1
  1107. wagtail/snippets/locale/bn/LC_MESSAGES/django.po +1 -1
  1108. wagtail/snippets/locale/ca/LC_MESSAGES/django.mo +0 -0
  1109. wagtail/snippets/locale/ca/LC_MESSAGES/django.po +11 -3
  1110. wagtail/snippets/locale/cs/LC_MESSAGES/django.po +1 -1
  1111. wagtail/snippets/locale/cy/LC_MESSAGES/django.po +1 -1
  1112. wagtail/snippets/locale/da/LC_MESSAGES/django.po +1 -1
  1113. wagtail/snippets/locale/de/LC_MESSAGES/django.mo +0 -0
  1114. wagtail/snippets/locale/de/LC_MESSAGES/django.po +15 -3
  1115. wagtail/snippets/locale/dv/LC_MESSAGES/django.po +1 -1
  1116. wagtail/snippets/locale/el/LC_MESSAGES/django.po +1 -1
  1117. wagtail/snippets/locale/en/LC_MESSAGES/django.mo +0 -0
  1118. wagtail/snippets/locale/en/LC_MESSAGES/django.po +23 -13
  1119. wagtail/snippets/locale/en_IN/LC_MESSAGES/django.mo +0 -0
  1120. wagtail/snippets/locale/en_IN/LC_MESSAGES/django.po +1 -1
  1121. wagtail/snippets/locale/es/LC_MESSAGES/django.po +1 -1
  1122. wagtail/snippets/locale/es_419/LC_MESSAGES/django.po +1 -1
  1123. wagtail/snippets/locale/es_VE/LC_MESSAGES/django.po +1 -1
  1124. wagtail/snippets/locale/et/LC_MESSAGES/django.po +1 -1
  1125. wagtail/snippets/locale/eu/LC_MESSAGES/django.mo +0 -0
  1126. wagtail/snippets/locale/eu/LC_MESSAGES/django.po +1 -1
  1127. wagtail/snippets/locale/fa/LC_MESSAGES/django.po +1 -1
  1128. wagtail/snippets/locale/fi/LC_MESSAGES/django.po +1 -1
  1129. wagtail/snippets/locale/fr/LC_MESSAGES/django.mo +0 -0
  1130. wagtail/snippets/locale/fr/LC_MESSAGES/django.po +11 -3
  1131. wagtail/snippets/locale/gl/LC_MESSAGES/django.mo +0 -0
  1132. wagtail/snippets/locale/gl/LC_MESSAGES/django.po +9 -1
  1133. wagtail/snippets/locale/he_IL/LC_MESSAGES/django.po +1 -1
  1134. wagtail/snippets/locale/hi/LC_MESSAGES/django.mo +0 -0
  1135. wagtail/snippets/locale/hi/LC_MESSAGES/django.po +1 -1
  1136. wagtail/snippets/locale/hr_HR/LC_MESSAGES/django.po +1 -1
  1137. wagtail/snippets/locale/ht/LC_MESSAGES/django.mo +0 -0
  1138. wagtail/snippets/locale/ht/LC_MESSAGES/django.po +1 -1
  1139. wagtail/snippets/locale/hu/LC_MESSAGES/django.po +1 -1
  1140. wagtail/snippets/locale/id_ID/LC_MESSAGES/django.po +1 -1
  1141. wagtail/snippets/locale/is_IS/LC_MESSAGES/django.po +1 -1
  1142. wagtail/snippets/locale/it/LC_MESSAGES/django.mo +0 -0
  1143. wagtail/snippets/locale/it/LC_MESSAGES/django.po +11 -1
  1144. wagtail/snippets/locale/ja/LC_MESSAGES/django.po +1 -1
  1145. wagtail/snippets/locale/ka/LC_MESSAGES/django.mo +0 -0
  1146. wagtail/snippets/locale/ka/LC_MESSAGES/django.po +1 -1
  1147. wagtail/snippets/locale/ko/LC_MESSAGES/django.po +1 -1
  1148. wagtail/snippets/locale/lt/LC_MESSAGES/django.po +1 -1
  1149. wagtail/snippets/locale/lv/LC_MESSAGES/django.po +1 -1
  1150. wagtail/snippets/locale/mi/LC_MESSAGES/django.po +1 -1
  1151. wagtail/snippets/locale/mn/LC_MESSAGES/django.po +1 -1
  1152. wagtail/snippets/locale/my/LC_MESSAGES/django.po +1 -1
  1153. wagtail/snippets/locale/nb/LC_MESSAGES/django.po +1 -1
  1154. wagtail/snippets/locale/nl/LC_MESSAGES/django.mo +0 -0
  1155. wagtail/snippets/locale/nl/LC_MESSAGES/django.po +11 -3
  1156. wagtail/snippets/locale/pl/LC_MESSAGES/django.po +1 -1
  1157. wagtail/snippets/locale/pt_BR/LC_MESSAGES/django.po +1 -1
  1158. wagtail/snippets/locale/pt_PT/LC_MESSAGES/django.po +1 -1
  1159. wagtail/snippets/locale/ro/LC_MESSAGES/django.po +1 -1
  1160. wagtail/snippets/locale/ru/LC_MESSAGES/django.mo +0 -0
  1161. wagtail/snippets/locale/ru/LC_MESSAGES/django.po +11 -3
  1162. wagtail/snippets/locale/sk_SK/LC_MESSAGES/django.po +1 -1
  1163. wagtail/snippets/locale/sl/LC_MESSAGES/django.po +1 -1
  1164. wagtail/snippets/locale/sr/LC_MESSAGES/django.mo +0 -0
  1165. wagtail/snippets/locale/sr/LC_MESSAGES/django.po +1 -1
  1166. wagtail/snippets/locale/sv/LC_MESSAGES/django.mo +0 -0
  1167. wagtail/snippets/locale/sv/LC_MESSAGES/django.po +13 -2
  1168. wagtail/snippets/locale/ta/LC_MESSAGES/django.mo +0 -0
  1169. wagtail/snippets/locale/ta/LC_MESSAGES/django.po +1 -1
  1170. wagtail/snippets/locale/tet/LC_MESSAGES/django.po +1 -1
  1171. wagtail/snippets/locale/th/LC_MESSAGES/django.po +1 -1
  1172. wagtail/snippets/locale/tr/LC_MESSAGES/django.po +1 -1
  1173. wagtail/snippets/locale/tr_TR/LC_MESSAGES/django.po +1 -1
  1174. wagtail/snippets/locale/ug/LC_MESSAGES/django.po +1 -1
  1175. wagtail/snippets/locale/uk/LC_MESSAGES/django.po +1 -1
  1176. wagtail/snippets/locale/vi/LC_MESSAGES/django.po +1 -1
  1177. wagtail/snippets/locale/zh/LC_MESSAGES/django.po +1 -1
  1178. wagtail/snippets/locale/zh_Hans/LC_MESSAGES/django.po +1 -1
  1179. wagtail/snippets/locale/zh_Hant/LC_MESSAGES/django.po +1 -1
  1180. wagtail/snippets/tests/test_chooser_block.py +197 -0
  1181. wagtail/snippets/tests/test_chooser_panel.py +149 -0
  1182. wagtail/snippets/tests/test_chooser_views.py +375 -0
  1183. wagtail/snippets/tests/test_chooser_widget.py +22 -0
  1184. wagtail/snippets/tests/test_compare_revisions_view.py +167 -0
  1185. wagtail/snippets/tests/test_copy_view.py +38 -0
  1186. wagtail/snippets/tests/test_create_view.py +1163 -0
  1187. wagtail/snippets/tests/test_delete_view.py +225 -0
  1188. wagtail/snippets/tests/test_edit_view.py +3243 -0
  1189. wagtail/snippets/tests/test_history_view.py +182 -0
  1190. wagtail/snippets/tests/test_index_view.py +105 -0
  1191. wagtail/snippets/tests/test_list_view.py +786 -0
  1192. wagtail/snippets/tests/test_locking.py +28 -0
  1193. wagtail/snippets/tests/test_permissions.py +167 -0
  1194. wagtail/snippets/tests/test_preview.py +3 -3
  1195. wagtail/snippets/tests/test_revert_view.py +343 -0
  1196. wagtail/snippets/tests/test_snippet_models.py +112 -0
  1197. wagtail/snippets/tests/test_unpublish_view.py +228 -0
  1198. wagtail/snippets/tests/test_unschedule_view.py +151 -0
  1199. wagtail/snippets/tests/test_viewset.py +38 -5
  1200. wagtail/snippets/views/snippets.py +76 -30
  1201. wagtail/snippets/widgets.py +2 -2
  1202. wagtail/templatetags/wagtailcore_tags.py +2 -2
  1203. wagtail/test/dummy_external_storage.py +1 -1
  1204. wagtail/test/testapp/fixtures/test.json +22 -0
  1205. wagtail/test/testapp/fixtures/test_empty.json +2 -0
  1206. wagtail/test/testapp/fixtures/test_explorable_pages.json +10 -0
  1207. wagtail/test/testapp/fixtures/test_specific.json +9 -0
  1208. wagtail/test/testapp/migrations/0059_nopromotepage.py +25 -0
  1209. wagtail/test/testapp/models.py +7 -0
  1210. wagtail/test/testapp/views.py +5 -0
  1211. wagtail/test/utils/page_tests.py +7 -7
  1212. wagtail/test/utils/wagtail_factories/builder.py +2 -2
  1213. wagtail/tests/streamfield_migrations/test_migrations.py +35 -0
  1214. wagtail/tests/test_blocks.py +321 -61
  1215. wagtail/tests/test_collection_model.py +12 -0
  1216. wagtail/tests/test_page_model.py +190 -1
  1217. wagtail/tests/test_page_privacy.py +5 -0
  1218. wagtail/tests/test_reference_index.py +42 -0
  1219. wagtail/tests/test_revision_model.py +118 -1
  1220. wagtail/tests/test_utils.py +2 -2
  1221. wagtail/users/locale/ar/LC_MESSAGES/django.po +1 -1
  1222. wagtail/users/locale/be/LC_MESSAGES/django.po +1 -1
  1223. wagtail/users/locale/bg/LC_MESSAGES/django.mo +0 -0
  1224. wagtail/users/locale/bg/LC_MESSAGES/django.po +10 -1
  1225. wagtail/users/locale/bn/LC_MESSAGES/django.po +1 -1
  1226. wagtail/users/locale/ca/LC_MESSAGES/django.po +1 -1
  1227. wagtail/users/locale/cs/LC_MESSAGES/django.po +1 -1
  1228. wagtail/users/locale/cy/LC_MESSAGES/django.po +1 -1
  1229. wagtail/users/locale/da/LC_MESSAGES/django.po +1 -1
  1230. wagtail/users/locale/de/LC_MESSAGES/django.mo +0 -0
  1231. wagtail/users/locale/de/LC_MESSAGES/django.po +6 -3
  1232. wagtail/users/locale/dv/LC_MESSAGES/django.po +1 -1
  1233. wagtail/users/locale/el/LC_MESSAGES/django.po +1 -1
  1234. wagtail/users/locale/en/LC_MESSAGES/django.mo +0 -0
  1235. wagtail/users/locale/en/LC_MESSAGES/django.po +14 -14
  1236. wagtail/users/locale/es/LC_MESSAGES/django.po +1 -1
  1237. wagtail/users/locale/es_419/LC_MESSAGES/django.po +1 -1
  1238. wagtail/users/locale/et/LC_MESSAGES/django.po +1 -1
  1239. wagtail/users/locale/eu/LC_MESSAGES/django.mo +0 -0
  1240. wagtail/users/locale/eu/LC_MESSAGES/django.po +1 -1
  1241. wagtail/users/locale/fa/LC_MESSAGES/django.po +1 -1
  1242. wagtail/users/locale/fi/LC_MESSAGES/django.po +1 -1
  1243. wagtail/users/locale/fr/LC_MESSAGES/django.po +1 -1
  1244. wagtail/users/locale/gl/LC_MESSAGES/django.po +1 -1
  1245. wagtail/users/locale/he_IL/LC_MESSAGES/django.po +1 -1
  1246. wagtail/users/locale/hr_HR/LC_MESSAGES/django.po +1 -1
  1247. wagtail/users/locale/hu/LC_MESSAGES/django.po +1 -1
  1248. wagtail/users/locale/hy/LC_MESSAGES/django.po +1 -1
  1249. wagtail/users/locale/id_ID/LC_MESSAGES/django.mo +0 -0
  1250. wagtail/users/locale/id_ID/LC_MESSAGES/django.po +7 -3
  1251. wagtail/users/locale/is_IS/LC_MESSAGES/django.po +1 -1
  1252. wagtail/users/locale/it/LC_MESSAGES/django.mo +0 -0
  1253. wagtail/users/locale/it/LC_MESSAGES/django.po +15 -3
  1254. wagtail/users/locale/ja/LC_MESSAGES/django.po +1 -1
  1255. wagtail/users/locale/ka/LC_MESSAGES/django.po +1 -1
  1256. wagtail/users/locale/ko/LC_MESSAGES/django.po +1 -1
  1257. wagtail/users/locale/lt/LC_MESSAGES/django.po +1 -1
  1258. wagtail/users/locale/lv/LC_MESSAGES/django.po +1 -1
  1259. wagtail/users/locale/mi/LC_MESSAGES/django.po +1 -1
  1260. wagtail/users/locale/mn/LC_MESSAGES/django.po +1 -1
  1261. wagtail/users/locale/my/LC_MESSAGES/django.po +1 -1
  1262. wagtail/users/locale/nb/LC_MESSAGES/django.po +1 -1
  1263. wagtail/users/locale/nl/LC_MESSAGES/django.po +1 -1
  1264. wagtail/users/locale/pl/LC_MESSAGES/django.po +1 -1
  1265. wagtail/users/locale/pt_BR/LC_MESSAGES/django.po +1 -1
  1266. wagtail/users/locale/pt_PT/LC_MESSAGES/django.po +1 -1
  1267. wagtail/users/locale/ro/LC_MESSAGES/django.po +1 -1
  1268. wagtail/users/locale/ru/LC_MESSAGES/django.mo +0 -0
  1269. wagtail/users/locale/ru/LC_MESSAGES/django.po +4 -4
  1270. wagtail/users/locale/sk_SK/LC_MESSAGES/django.po +1 -1
  1271. wagtail/users/locale/sl/LC_MESSAGES/django.po +1 -1
  1272. wagtail/users/locale/sv/LC_MESSAGES/django.mo +0 -0
  1273. wagtail/users/locale/sv/LC_MESSAGES/django.po +49 -18
  1274. wagtail/users/locale/tet/LC_MESSAGES/django.po +1 -1
  1275. wagtail/users/locale/th/LC_MESSAGES/django.po +1 -1
  1276. wagtail/users/locale/tr/LC_MESSAGES/django.po +1 -1
  1277. wagtail/users/locale/tr_TR/LC_MESSAGES/django.po +1 -1
  1278. wagtail/users/locale/ug/LC_MESSAGES/django.po +1 -1
  1279. wagtail/users/locale/uk/LC_MESSAGES/django.po +1 -1
  1280. wagtail/users/locale/vi/LC_MESSAGES/django.po +1 -1
  1281. wagtail/users/locale/zh/LC_MESSAGES/django.po +1 -1
  1282. wagtail/users/locale/zh_Hans/LC_MESSAGES/django.po +1 -1
  1283. wagtail/users/locale/zh_Hant/LC_MESSAGES/django.po +1 -1
  1284. wagtail/users/tests/test_admin_views.py +39 -25
  1285. wagtail/users/utils.py +4 -1
  1286. wagtail/users/views/groups.py +19 -5
  1287. wagtail/users/wagtail_hooks.py +1 -1
  1288. wagtail/utils/loading.py +2 -2
  1289. wagtail-7.3.data/data/AGENTS.md +21 -0
  1290. wagtail-7.3.data/data/CHANGELOG.txt +4957 -0
  1291. wagtail-7.3.data/data/CODE_OF_CONDUCT.md +71 -0
  1292. wagtail-7.3.data/data/CONTRIBUTORS.md +1001 -0
  1293. wagtail-7.3.data/data/SPONSORS.md +55 -0
  1294. {wagtail-7.2.2.dist-info → wagtail-7.3.dist-info}/METADATA +6 -6
  1295. {wagtail-7.2.2.dist-info → wagtail-7.3.dist-info}/RECORD +1299 -1265
  1296. wagtail/images/static/wagtailimages/js/image-url-generator.js +0 -1
  1297. wagtail/snippets/tests/test_snippets.py +0 -6377
  1298. {wagtail-7.2.2.dist-info → wagtail-7.3.dist-info}/WHEEL +0 -0
  1299. {wagtail-7.2.2.dist-info → wagtail-7.3.dist-info}/entry_points.txt +0 -0
  1300. {wagtail-7.2.2.dist-info → wagtail-7.3.dist-info}/licenses/LICENSE +0 -0
  1301. {wagtail-7.2.2.dist-info → wagtail-7.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,3243 @@
1
+ import datetime
2
+ from unittest import mock
3
+
4
+ from django.contrib.admin.utils import quote
5
+ from django.contrib.auth.models import Permission
6
+ from django.contrib.contenttypes.models import ContentType
7
+ from django.core.files.base import ContentFile
8
+ from django.core.files.uploadedfile import SimpleUploadedFile
9
+ from django.core.handlers.wsgi import WSGIRequest
10
+ from django.http import HttpRequest, HttpResponse, JsonResponse
11
+ from django.test import TestCase
12
+ from django.test.utils import override_settings
13
+ from django.urls import reverse
14
+ from django.utils.timezone import now
15
+ from freezegun import freeze_time
16
+ from taggit.models import Tag
17
+
18
+ from wagtail.admin.admin_url_finder import AdminURLFinder
19
+ from wagtail.models import Locale, ModelLogEntry, Revision
20
+ from wagtail.signals import published
21
+ from wagtail.snippets.action_menu import (
22
+ ActionMenuItem,
23
+ get_base_snippet_action_menu_items,
24
+ )
25
+ from wagtail.test.snippets.models import (
26
+ FileUploadSnippet,
27
+ StandardSnippetWithCustomPrimaryKey,
28
+ TranslatableSnippet,
29
+ )
30
+ from wagtail.test.testapp.models import (
31
+ Advert,
32
+ AdvertWithTabbedInterface,
33
+ CustomPreviewSizesModel,
34
+ DraftStateCustomPrimaryKeyModel,
35
+ DraftStateModel,
36
+ FullFeaturedSnippet,
37
+ PreviewableModel,
38
+ RevisableModel,
39
+ )
40
+ from wagtail.test.utils import WagtailTestUtils
41
+ from wagtail.test.utils.timestamps import submittable_timestamp
42
+ from wagtail.utils.timestamps import render_timestamp
43
+
44
+
45
+ class BaseTestSnippetEditView(WagtailTestUtils, TestCase):
46
+ def get_edit_url(self):
47
+ snippet = self.test_snippet
48
+ args = [quote(snippet.pk)]
49
+ return reverse(snippet.snippet_viewset.get_url_name("edit"), args=args)
50
+
51
+ def get(self, params=None, headers=None):
52
+ return self.client.get(self.get_edit_url(), params, headers=headers)
53
+
54
+ def post(self, post_data=None, headers=None):
55
+ return self.client.post(self.get_edit_url(), post_data, headers=headers)
56
+
57
+ def setUp(self):
58
+ self.user = self.login()
59
+
60
+ def assertSchedulingDialogRendered(self, response, label="Edit schedule"):
61
+ # Should show the "Edit schedule" button
62
+ html = response.content.decode()
63
+ self.assertTagInHTML(
64
+ f'<button type="button" data-a11y-dialog-show="schedule-publishing-dialog">{label}</button>',
65
+ html,
66
+ count=1,
67
+ allow_extra_attrs=True,
68
+ )
69
+ # Should show the dialog template pointing to the [data-edit-form] selector as the root
70
+ soup = self.get_soup(html)
71
+ dialog = soup.select_one(
72
+ """
73
+ template[data-controller="w-teleport"][data-w-teleport-target-value="[data-edit-form]"]
74
+ #schedule-publishing-dialog
75
+ """
76
+ )
77
+ self.assertIsNotNone(dialog)
78
+ # Should render the main form with data-edit-form attribute
79
+ self.assertTagInHTML(
80
+ f'<form action="{self.get_edit_url()}" method="POST" data-edit-form>',
81
+ html,
82
+ count=1,
83
+ allow_extra_attrs=True,
84
+ )
85
+ self.assertTagInHTML(
86
+ '<div id="schedule-publishing-dialog" class="w-dialog publishing" data-controller="w-dialog">',
87
+ html,
88
+ count=1,
89
+ allow_extra_attrs=True,
90
+ )
91
+
92
+
93
+ class TestSnippetEditView(BaseTestSnippetEditView):
94
+ fixtures = ["test.json"]
95
+
96
+ def setUp(self):
97
+ super().setUp()
98
+ self.test_snippet = Advert.objects.get(pk=1)
99
+ ModelLogEntry.objects.create(
100
+ content_type=ContentType.objects.get_for_model(Advert),
101
+ label="Test Advert",
102
+ action="wagtail.create",
103
+ timestamp=now() - datetime.timedelta(weeks=3),
104
+ user=self.user,
105
+ object_id="1",
106
+ )
107
+
108
+ def test_get_with_limited_permissions(self):
109
+ self.user.is_superuser = False
110
+ self.user.user_permissions.add(
111
+ Permission.objects.get(
112
+ content_type__app_label="wagtailadmin", codename="access_admin"
113
+ )
114
+ )
115
+ self.user.save()
116
+
117
+ response = self.get()
118
+ self.assertEqual(response.status_code, 302)
119
+
120
+ def test_simple(self):
121
+ response = self.get()
122
+ html = response.content.decode()
123
+ self.assertEqual(response.status_code, 200)
124
+ self.assertTemplateUsed(response, "wagtailsnippets/snippets/edit.html")
125
+ self.assertNotContains(response, 'role="tablist"')
126
+
127
+ # Without DraftStateMixin, there should be no "No publishing schedule set" info
128
+ self.assertNotContains(response, "No publishing schedule set")
129
+
130
+ history_url = reverse(
131
+ "wagtailsnippets_tests_advert:history", args=[quote(self.test_snippet.pk)]
132
+ )
133
+ # History link should be present, one in the header and one in the status side panel
134
+ self.assertContains(response, history_url, count=2)
135
+
136
+ usage_url = reverse(
137
+ "wagtailsnippets_tests_advert:usage", args=[quote(self.test_snippet.pk)]
138
+ )
139
+ # Usage link should be present in the status side panel
140
+ self.assertContains(response, usage_url)
141
+
142
+ # Live status and last updated info should be shown, with a link to the history page
143
+ self.assertContains(response, "3\xa0weeks ago")
144
+ self.assertTagInHTML(
145
+ f'<a href="{history_url}" aria-describedby="status-sidebar-live">View history</a>',
146
+ html,
147
+ allow_extra_attrs=True,
148
+ )
149
+
150
+ soup = self.get_soup(response.content)
151
+
152
+ # Should have the unsaved controller set up
153
+ editor_form = soup.select_one("#w-editor-form")
154
+ self.assertIsNotNone(editor_form)
155
+ self.assertIn("w-unsaved", editor_form.attrs.get("data-controller").split())
156
+ self.assertTrue(
157
+ {
158
+ "w-unsaved#submit",
159
+ "beforeunload@window->w-unsaved#confirm",
160
+ }.issubset(editor_form.attrs.get("data-action").split())
161
+ )
162
+ self.assertEqual(
163
+ editor_form.attrs.get("data-w-unsaved-confirmation-value"),
164
+ "true",
165
+ )
166
+ self.assertEqual(
167
+ editor_form.attrs.get("data-w-unsaved-force-value"),
168
+ "false",
169
+ )
170
+
171
+ self.assertIsNone(editor_form.select_one("input[name='loaded_revision_id']"))
172
+ self.assertIsNone(
173
+ editor_form.select_one("input[name='loaded_revision_created_at']")
174
+ )
175
+
176
+ self.assertIsNotNone(editor_form)
177
+ self.assertNotIn("w-autosave", editor_form["data-controller"].split())
178
+ self.assertNotIn("w-autosave", editor_form["data-action"])
179
+ self.assertIsNone(editor_form.attrs.get("data-w-autosave-interval-value"))
180
+
181
+ url_finder = AdminURLFinder(self.user)
182
+ expected_url = "/admin/snippets/tests/advert/edit/%d/" % self.test_snippet.pk
183
+ self.assertEqual(url_finder.get_edit_url(self.test_snippet), expected_url)
184
+
185
+ def test_get_hydrate_create_view(self):
186
+ response = self.get(params={"_w_hydrate_create_view": "1"})
187
+ self.assertEqual(response.status_code, 200)
188
+ self.assertTemplateUsed(response, "wagtailadmin/generic/edit_partials.html")
189
+ soup = self.get_soup(response.content)
190
+
191
+ # Should reload only the status side panel
192
+ side_panels = soup.select(
193
+ "template[data-controller='w-teleport']"
194
+ "[data-w-teleport-target-value^='[data-side-panel=']"
195
+ "[data-w-teleport-mode-value='innerHTML']"
196
+ )
197
+ self.assertEqual(len(side_panels), 1)
198
+ status_side_panel = side_panels[0]
199
+ self.assertEqual(
200
+ status_side_panel["data-w-teleport-target-value"],
201
+ "[data-side-panel='status']",
202
+ )
203
+
204
+ # Workflow and privacy features are not available
205
+ workflow_status_dialog = soup.find("div", id="workflow-status-dialog")
206
+ self.assertIsNone(workflow_status_dialog)
207
+ set_privacy_dialog = soup.find("div", id="set-privacy")
208
+ self.assertIsNone(set_privacy_dialog)
209
+
210
+ breadcrumbs = soup.find(
211
+ "template",
212
+ {
213
+ "data-controller": "w-teleport",
214
+ "data-w-teleport-target-value": "header [data-w-breadcrumbs]",
215
+ "data-w-teleport-mode-value": "outerHTML",
216
+ },
217
+ )
218
+ self.assertIsNotNone(breadcrumbs)
219
+ # Should include header buttons as they were not rendered in the create view
220
+ self.assertIsNotNone(breadcrumbs.select_one("#w-slim-header-buttons"))
221
+
222
+ # Should render the history link button as it wasn't rendered in the create view
223
+ history_link = soup.find(
224
+ "template",
225
+ {
226
+ "data-controller": "w-teleport",
227
+ "data-w-teleport-target-value": "[data-side-panel-toggle]:last-of-type",
228
+ "data-w-teleport-mode-value": "afterend",
229
+ },
230
+ )
231
+ history_url = reverse(
232
+ self.test_snippet.snippet_viewset.get_url_name("history"),
233
+ args=(quote(self.test_snippet.pk),),
234
+ )
235
+ self.assertIsNotNone(history_link)
236
+ self.assertIsNotNone(history_link.select_one(f"a[href='{history_url}']"))
237
+
238
+ form_title_heading = soup.find(
239
+ "template",
240
+ {
241
+ "data-controller": "w-teleport",
242
+ "data-w-teleport-target-value": "#header-title span",
243
+ "data-w-teleport-mode-value": "textContent",
244
+ },
245
+ )
246
+ self.assertIsNotNone(form_title_heading)
247
+ self.assertEqual(form_title_heading.text.strip(), str(self.test_snippet))
248
+ header_title = soup.find(
249
+ "template",
250
+ {
251
+ "data-controller": "w-teleport",
252
+ "data-w-teleport-target-value": "head title",
253
+ "data-w-teleport-mode-value": "textContent",
254
+ },
255
+ )
256
+ self.assertIsNotNone(header_title)
257
+ self.assertEqual(header_title.text.strip(), f"Editing: {self.test_snippet}")
258
+
259
+ # Should not include any updates to the form as we don't have revisions
260
+ # enabled and thus don't need to add loaded revision info
261
+ form_adds = soup.find(
262
+ "template",
263
+ {
264
+ "data-controller": "w-teleport",
265
+ "data-w-teleport-target-value": "form[data-edit-form]",
266
+ "data-w-teleport-mode-value": "afterbegin",
267
+ },
268
+ )
269
+ self.assertIsNone(form_adds)
270
+
271
+ # Should load the editing sessions module as it was not in the create view
272
+ editing_sessions = soup.find(
273
+ "template",
274
+ {
275
+ "data-controller": "w-teleport",
276
+ "data-w-teleport-target-value": "#w-autosave-indicator",
277
+ "data-w-teleport-mode-value": "afterend",
278
+ },
279
+ )
280
+ self.assertIsNotNone(editing_sessions)
281
+ # without the revision info
282
+ self.assertIsNone(editing_sessions.select_one("input[name='revision_id']"))
283
+ self.assertIsNone(
284
+ editing_sessions.select_one("input[name='revision_created_at']")
285
+ )
286
+
287
+ def test_non_existent_model(self):
288
+ response = self.client.get(
289
+ f"/admin/snippets/tests/foo/edit/{quote(self.test_snippet.pk)}/"
290
+ )
291
+ self.assertEqual(response.status_code, 404)
292
+
293
+ def test_nonexistent_id(self):
294
+ response = self.client.get(
295
+ reverse("wagtailsnippets_tests_advert:edit", args=[999999])
296
+ )
297
+ self.assertEqual(response.status_code, 404)
298
+
299
+ def test_edit_with_limited_permissions(self):
300
+ self.user.is_superuser = False
301
+ self.user.user_permissions.add(
302
+ Permission.objects.get(
303
+ content_type__app_label="wagtailadmin", codename="access_admin"
304
+ )
305
+ )
306
+ self.user.save()
307
+
308
+ response = self.post(
309
+ post_data={"text": "test text", "url": "http://www.example.com/"}
310
+ )
311
+ self.assertEqual(response.status_code, 302)
312
+
313
+ url_finder = AdminURLFinder(self.user)
314
+ self.assertIsNone(url_finder.get_edit_url(self.test_snippet))
315
+
316
+ def test_edit_invalid(self):
317
+ response = self.post(post_data={"foo": "bar"})
318
+ soup = self.get_soup(response.content)
319
+
320
+ header_messages = soup.css.select(".messages[role='status'] ul > li")
321
+
322
+ # the top level message should indicate that the page could not be saved
323
+ self.assertEqual(len(header_messages), 1)
324
+ message = header_messages[0]
325
+ self.assertIn(
326
+ "The advert could not be saved due to errors.", message.get_text()
327
+ )
328
+
329
+ # the top level message should provide a go to error button
330
+ buttons = message.find_all("button")
331
+ self.assertEqual(len(buttons), 1)
332
+ self.assertEqual(buttons[0].attrs["data-controller"], "w-count w-focus")
333
+ self.assertEqual(
334
+ set(buttons[0].attrs["data-action"].split()),
335
+ {"click->w-focus#focus", "wagtail:panel-init@document->w-count#count"},
336
+ )
337
+ self.assertIn("Go to the first error", buttons[0].get_text())
338
+
339
+ # the error should only appear once: against the field, not in the header message
340
+ error_messages = soup.css.select(".error-message")
341
+ self.assertEqual(len(error_messages), 1)
342
+ error_message = error_messages[0]
343
+ self.assertEqual(error_message.parent["id"], "panel-child-text-errors")
344
+ self.assertIn("This field is required", error_message.get_text())
345
+
346
+ # Should have the unsaved controller set up
347
+ editor_form = soup.select_one("#w-editor-form")
348
+ self.assertIsNotNone(editor_form)
349
+ self.assertIn("w-unsaved", editor_form.attrs.get("data-controller").split())
350
+ self.assertTrue(
351
+ {
352
+ "w-unsaved#submit",
353
+ "beforeunload@window->w-unsaved#confirm",
354
+ }.issubset(editor_form.attrs.get("data-action").split())
355
+ )
356
+ self.assertEqual(
357
+ editor_form.attrs.get("data-w-unsaved-confirmation-value"),
358
+ "true",
359
+ )
360
+ self.assertEqual(
361
+ editor_form.attrs.get("data-w-unsaved-force-value"),
362
+ # The form is invalid, we want to force it to be "dirty" on initial load
363
+ "true",
364
+ )
365
+
366
+ def test_edit_invalid_with_json_response(self):
367
+ response = self.post(
368
+ post_data={"foo": "bar"},
369
+ headers={"Accept": "application/json"},
370
+ )
371
+ self.assertEqual(response.status_code, 400)
372
+ self.assertEqual(response["Content-Type"], "application/json")
373
+ self.assertEqual(
374
+ response.json(),
375
+ {
376
+ "success": False,
377
+ "error_code": "validation_error",
378
+ "error_message": "There are validation errors, click save to highlight them.",
379
+ },
380
+ )
381
+
382
+ def test_edit(self):
383
+ response = self.post(
384
+ post_data={
385
+ "text": "edited_test_advert",
386
+ "url": "http://www.example.com/edited",
387
+ }
388
+ )
389
+ self.assertRedirects(response, reverse("wagtailsnippets_tests_advert:list"))
390
+
391
+ snippets = Advert.objects.filter(text="edited_test_advert")
392
+ self.assertEqual(snippets.count(), 1)
393
+ self.assertEqual(snippets.first().url, "http://www.example.com/edited")
394
+
395
+ def test_edit_with_json_response(self):
396
+ response = self.post(
397
+ post_data={
398
+ "text": "edited_test_advert",
399
+ "url": "http://www.example.com/edited",
400
+ },
401
+ headers={"Accept": "application/json"},
402
+ )
403
+ self.assertEqual(response.status_code, 200)
404
+ self.assertEqual(response["Content-Type"], "application/json")
405
+
406
+ snippets = Advert.objects.filter(text="edited_test_advert")
407
+ self.assertEqual(snippets.count(), 1)
408
+ snippet = snippets.first()
409
+ self.assertEqual(snippet.url, "http://www.example.com/edited")
410
+
411
+ response_json = response.json()
412
+ self.assertEqual(response_json["success"], True)
413
+ self.assertEqual(response_json["pk"], snippet.pk)
414
+ self.assertEqual(response_json["field_updates"], {})
415
+
416
+ def test_edit_with_tags(self):
417
+ tags = ["hello", "world"]
418
+ response = self.post(
419
+ post_data={
420
+ "text": "edited_test_advert",
421
+ "url": "http://www.example.com/edited",
422
+ "tags": ", ".join(tags),
423
+ }
424
+ )
425
+
426
+ self.assertRedirects(response, reverse("wagtailsnippets_tests_advert:list"))
427
+
428
+ snippet = Advert.objects.get(text="edited_test_advert")
429
+
430
+ expected_tags = list(Tag.objects.order_by("name").filter(name__in=tags))
431
+ self.assertEqual(len(expected_tags), 2)
432
+ self.assertEqual(list(snippet.tags.order_by("name")), expected_tags)
433
+
434
+ def test_before_edit_snippet_hook_get(self):
435
+ def hook_func(request, instance):
436
+ self.assertIsInstance(request, HttpRequest)
437
+ self.assertEqual(instance.text, "test_advert")
438
+ self.assertEqual(instance.url, "http://www.example.com")
439
+ return HttpResponse("Overridden!")
440
+
441
+ with self.register_hook("before_edit_snippet", hook_func):
442
+ response = self.get()
443
+
444
+ self.assertEqual(response.status_code, 200)
445
+ self.assertEqual(response.content, b"Overridden!")
446
+
447
+ def test_before_edit_snippet_hook_get_with_json_response(self):
448
+ def non_json_hook_func(request, instance):
449
+ self.assertIsInstance(request, HttpRequest)
450
+ self.assertEqual(instance.text, "test_advert")
451
+ self.assertEqual(instance.url, "http://www.example.com")
452
+
453
+ return HttpResponse("Overridden!")
454
+
455
+ def json_hook_func(request, instance):
456
+ self.assertIsInstance(request, HttpRequest)
457
+ self.assertEqual(instance.text, "test_advert")
458
+ self.assertEqual(instance.url, "http://www.example.com")
459
+
460
+ return JsonResponse({"status": "purple"})
461
+
462
+ with self.register_hook("before_edit_snippet", non_json_hook_func):
463
+ response = self.get(
464
+ headers={"Accept": "application/json"},
465
+ )
466
+
467
+ self.assertEqual(response.status_code, 400)
468
+ self.assertEqual(
469
+ response.json(),
470
+ {
471
+ "success": False,
472
+ "error_code": "blocked_by_hook",
473
+ "error_message": "Request to edit advert was blocked by hook.",
474
+ },
475
+ )
476
+
477
+ with self.register_hook("before_edit_snippet", json_hook_func):
478
+ response = self.get(
479
+ headers={"Accept": "application/json"},
480
+ )
481
+
482
+ self.assertEqual(response.status_code, 200)
483
+ self.assertEqual(response.json(), {"status": "purple"})
484
+
485
+ def test_before_edit_snippet_hook_post(self):
486
+ def hook_func(request, instance):
487
+ self.assertIsInstance(request, HttpRequest)
488
+ self.assertEqual(instance.text, "test_advert")
489
+ self.assertEqual(instance.url, "http://www.example.com")
490
+ return HttpResponse("Overridden!")
491
+
492
+ with self.register_hook("before_edit_snippet", hook_func):
493
+ response = self.post(
494
+ post_data={
495
+ "text": "Edited and runs hook",
496
+ "url": "http://www.example.com/hook-enabled-edited",
497
+ }
498
+ )
499
+
500
+ self.assertEqual(response.status_code, 200)
501
+ self.assertEqual(response.content, b"Overridden!")
502
+
503
+ # Request intercepted before advert was updated
504
+ self.assertEqual(Advert.objects.get().text, "test_advert")
505
+
506
+ def test_before_edit_snippet_hook_post_with_json_response(self):
507
+ def non_json_hook_func(request, instance):
508
+ self.assertIsInstance(request, HttpRequest)
509
+ self.assertEqual(instance.text, "test_advert")
510
+ self.assertEqual(instance.url, "http://www.example.com")
511
+
512
+ return HttpResponse("Overridden!")
513
+
514
+ def json_hook_func(request, instance):
515
+ self.assertIsInstance(request, HttpRequest)
516
+ self.assertEqual(instance.text, "test_advert")
517
+ self.assertEqual(instance.url, "http://www.example.com")
518
+
519
+ return JsonResponse({"status": "purple"})
520
+
521
+ with self.register_hook("before_edit_snippet", non_json_hook_func):
522
+ post_data = {
523
+ "text": "Edited and runs hook",
524
+ "url": "http://www.example.com/hook-enabled-edited",
525
+ }
526
+ response = self.post(
527
+ post_data,
528
+ headers={"Accept": "application/json"},
529
+ )
530
+
531
+ self.assertEqual(response.status_code, 400)
532
+ self.assertEqual(
533
+ response.json(),
534
+ {
535
+ "success": False,
536
+ "error_code": "blocked_by_hook",
537
+ "error_message": "Request to edit advert was blocked by hook.",
538
+ },
539
+ )
540
+
541
+ # Request intercepted before advert was updated
542
+ self.assertEqual(Advert.objects.get().text, "test_advert")
543
+
544
+ with self.register_hook("before_edit_snippet", json_hook_func):
545
+ post_data = {
546
+ "text": "Edited and runs hook",
547
+ "url": "http://www.example.com/hook-enabled-edited",
548
+ }
549
+ response = self.post(
550
+ post_data,
551
+ headers={"Accept": "application/json"},
552
+ )
553
+
554
+ self.assertEqual(response.status_code, 200)
555
+ self.assertEqual(response.json(), {"status": "purple"})
556
+
557
+ # Request intercepted before advert was updated
558
+ self.assertEqual(Advert.objects.get().text, "test_advert")
559
+
560
+ def test_after_edit_snippet_hook(self):
561
+ def hook_func(request, instance):
562
+ self.assertIsInstance(request, HttpRequest)
563
+ self.assertEqual(instance.text, "Edited and runs hook")
564
+ self.assertEqual(instance.url, "http://www.example.com/hook-enabled-edited")
565
+ return HttpResponse("Overridden!")
566
+
567
+ with self.register_hook("after_edit_snippet", hook_func):
568
+ response = self.post(
569
+ post_data={
570
+ "text": "Edited and runs hook",
571
+ "url": "http://www.example.com/hook-enabled-edited",
572
+ }
573
+ )
574
+
575
+ self.assertEqual(response.status_code, 200)
576
+ self.assertEqual(response.content, b"Overridden!")
577
+
578
+ # Request intercepted after advert was updated
579
+ self.assertEqual(Advert.objects.get().text, "Edited and runs hook")
580
+
581
+ def test_after_edit_snippet_hook_with_json_response(self):
582
+ def non_json_hook_func(request, instance):
583
+ self.assertIsInstance(request, HttpRequest)
584
+ self.assertEqual(instance.text, "Edited and runs hook")
585
+ self.assertEqual(instance.url, "http://www.example.com/hook-enabled-edited")
586
+
587
+ return HttpResponse("Overridden!")
588
+
589
+ def json_hook_func(request, instance):
590
+ self.assertIsInstance(request, HttpRequest)
591
+ self.assertEqual(instance.text, "Edited and runs hook x2")
592
+ self.assertEqual(instance.url, "http://www.example.com/hook-enabled-edited")
593
+
594
+ return JsonResponse({"status": "purple"})
595
+
596
+ with self.register_hook("after_edit_snippet", non_json_hook_func):
597
+ post_data = {
598
+ "text": "Edited and runs hook",
599
+ "url": "http://www.example.com/hook-enabled-edited",
600
+ }
601
+ response = self.post(
602
+ post_data,
603
+ headers={"Accept": "application/json"},
604
+ )
605
+
606
+ self.assertEqual(response.status_code, 200)
607
+ # hook response is ignored, since it's not a JSON response
608
+ self.assertEqual(response.json()["success"], True)
609
+
610
+ # Request intercepted after advert was updated
611
+ self.assertEqual(Advert.objects.get().text, "Edited and runs hook")
612
+
613
+ with self.register_hook("after_edit_snippet", json_hook_func):
614
+ post_data = {
615
+ "text": "Edited and runs hook x2",
616
+ "url": "http://www.example.com/hook-enabled-edited",
617
+ }
618
+ response = self.post(
619
+ post_data,
620
+ headers={"Accept": "application/json"},
621
+ )
622
+
623
+ self.assertEqual(response.status_code, 200)
624
+ self.assertEqual(response.json(), {"status": "purple"})
625
+
626
+ # Request intercepted after advert was updated
627
+ self.assertEqual(Advert.objects.get().text, "Edited and runs hook x2")
628
+
629
+ def test_register_snippet_action_menu_item(self):
630
+ class TestSnippetActionMenuItem(ActionMenuItem):
631
+ label = "Test"
632
+ name = "test"
633
+ icon_name = "check"
634
+ classname = "custom-class"
635
+
636
+ def is_shown(self, context):
637
+ return True
638
+
639
+ def hook_func(model):
640
+ return TestSnippetActionMenuItem(order=0)
641
+
642
+ with self.register_hook("register_snippet_action_menu_item", hook_func):
643
+ get_base_snippet_action_menu_items.cache_clear()
644
+
645
+ response = self.get()
646
+
647
+ get_base_snippet_action_menu_items.cache_clear()
648
+
649
+ self.assertContains(
650
+ response,
651
+ '<button type="submit" name="test" value="Test" class="button custom-class"><svg class="icon icon-check icon" aria-hidden="true"><use href="#icon-check"></use></svg>Test</button>',
652
+ html=True,
653
+ )
654
+
655
+ def test_construct_snippet_action_menu(self):
656
+ def hook_func(menu_items, request, context):
657
+ self.assertIsInstance(menu_items, list)
658
+ self.assertIsInstance(request, WSGIRequest)
659
+ self.assertEqual(context["view"], "edit")
660
+ self.assertEqual(context["instance"], self.test_snippet)
661
+ self.assertEqual(context["model"], Advert)
662
+
663
+ # Remove the save item
664
+ del menu_items[0]
665
+
666
+ with self.register_hook("construct_snippet_action_menu", hook_func):
667
+ response = self.get()
668
+
669
+ self.assertNotContains(response, "<em>Save</em>")
670
+
671
+ def test_previewable_snippet(self):
672
+ self.test_snippet = PreviewableModel.objects.create(
673
+ text="Preview-enabled snippet"
674
+ )
675
+ response = self.get()
676
+ self.assertEqual(response.status_code, 200)
677
+ soup = self.get_soup(response.content)
678
+
679
+ radios = soup.select('input[type="radio"][name="preview-size"]')
680
+ self.assertEqual(len(radios), 3)
681
+
682
+ self.assertEqual(
683
+ [
684
+ "Preview in mobile size",
685
+ "Preview in tablet size",
686
+ "Preview in desktop size",
687
+ ],
688
+ [radio["aria-label"] for radio in radios],
689
+ )
690
+
691
+ self.assertEqual("375", radios[0]["data-device-width"])
692
+ self.assertTrue(radios[0].has_attr("checked"))
693
+
694
+ def test_custom_preview_sizes(self):
695
+ self.test_snippet = CustomPreviewSizesModel.objects.create(
696
+ text="Preview-enabled with custom sizes",
697
+ )
698
+
699
+ response = self.get()
700
+ self.assertEqual(response.status_code, 200)
701
+ soup = self.get_soup(response.content)
702
+
703
+ radios = soup.select('input[type="radio"][name="preview-size"]')
704
+ self.assertEqual(len(radios), 2)
705
+
706
+ self.assertEqual("412", radios[0]["data-device-width"])
707
+ self.assertEqual("Custom mobile preview", radios[0]["aria-label"])
708
+ self.assertFalse(radios[0].has_attr("checked"))
709
+
710
+ self.assertEqual("1280", radios[1]["data-device-width"])
711
+ self.assertEqual("Original desktop", radios[1]["aria-label"])
712
+ self.assertTrue(radios[1].has_attr("checked"))
713
+
714
+
715
+ class TestEditTabbedSnippet(BaseTestSnippetEditView):
716
+ def setUp(self):
717
+ super().setUp()
718
+ self.test_snippet = AdvertWithTabbedInterface.objects.create(
719
+ text="test_advert",
720
+ url="http://www.example.com",
721
+ something_else="Model with tabbed interface",
722
+ )
723
+
724
+ def test_snippet_with_tabbed_interface(self):
725
+ response = self.get()
726
+
727
+ self.assertEqual(response.status_code, 200)
728
+ self.assertTemplateUsed(response, "wagtailsnippets/snippets/edit.html")
729
+ self.assertContains(response, 'role="tablist"')
730
+
731
+ self.assertContains(
732
+ response,
733
+ '<a id="tab-label-advert" href="#tab-advert" class="w-tabs__tab " role="tab" aria-selected="false" tabindex="-1" data-action="w-tabs#select:prevent" data-w-tabs-target="trigger">',
734
+ )
735
+ self.assertContains(
736
+ response,
737
+ '<a id="tab-label-other" href="#tab-other" class="w-tabs__tab " role="tab" aria-selected="false" tabindex="-1" data-action="w-tabs#select:prevent" data-w-tabs-target="trigger">',
738
+ )
739
+
740
+
741
+ class TestEditFileUploadSnippet(BaseTestSnippetEditView):
742
+ def setUp(self):
743
+ super().setUp()
744
+ self.test_snippet = FileUploadSnippet.objects.create(
745
+ file=ContentFile(b"Simple text document", "test.txt")
746
+ )
747
+
748
+ def test_edit_file_upload_multipart(self):
749
+ response = self.get()
750
+ self.assertContains(response, 'enctype="multipart/form-data"')
751
+
752
+ response = self.post(
753
+ post_data={
754
+ "file": SimpleUploadedFile("replacement.txt", b"Replacement document")
755
+ }
756
+ )
757
+ self.assertRedirects(
758
+ response,
759
+ reverse("wagtailsnippets_snippetstests_fileuploadsnippet:list"),
760
+ )
761
+ snippet = FileUploadSnippet.objects.get()
762
+ self.assertEqual(snippet.file.read(), b"Replacement document")
763
+
764
+
765
+ @override_settings(WAGTAIL_I18N_ENABLED=True)
766
+ class TestLocaleSelectorOnEdit(BaseTestSnippetEditView):
767
+ fixtures = ["test.json"]
768
+
769
+ LOCALE_SELECTOR_LABEL = "Switch locales"
770
+ LOCALE_INDICATOR_HTML = '<h3 id="status-sidebar-english"'
771
+
772
+ def setUp(self):
773
+ super().setUp()
774
+ self.test_snippet = TranslatableSnippet.objects.create(text="This is a test")
775
+ self.fr_locale = Locale.objects.create(language_code="fr")
776
+ self.test_snippet_fr = self.test_snippet.copy_for_translation(self.fr_locale)
777
+ self.test_snippet_fr.save()
778
+
779
+ def test_locale_selector(self):
780
+ response = self.get()
781
+ self.assertContains(response, self.LOCALE_SELECTOR_LABEL)
782
+ self.assertContains(response, self.LOCALE_INDICATOR_HTML)
783
+
784
+ def test_locale_selector_without_translation(self):
785
+ self.test_snippet_fr.delete()
786
+ response = self.get()
787
+ # The "Switch locale" button should not be shown
788
+ self.assertNotContains(response, self.LOCALE_SELECTOR_LABEL)
789
+ # Locale status still available and says "No other translations"
790
+ self.assertContains(response, self.LOCALE_INDICATOR_HTML)
791
+ self.assertContains(response, "No other translations")
792
+
793
+ @override_settings(WAGTAIL_I18N_ENABLED=False)
794
+ def test_locale_selector_not_present_when_i18n_disabled(self):
795
+ response = self.get()
796
+ self.assertNotContains(response, self.LOCALE_SELECTOR_LABEL)
797
+ self.assertNotContains(response, self.LOCALE_INDICATOR_HTML)
798
+
799
+ def test_locale_selector_not_present_on_non_translatable_snippet(self):
800
+ self.test_snippet = Advert.objects.get(pk=1)
801
+ response = self.get()
802
+ self.assertNotContains(response, self.LOCALE_SELECTOR_LABEL)
803
+ self.assertNotContains(response, self.LOCALE_INDICATOR_HTML)
804
+
805
+
806
+ class TestEditRevisionSnippet(BaseTestSnippetEditView):
807
+ def setUp(self):
808
+ super().setUp()
809
+ self.test_snippet = RevisableModel.objects.create(text="foo")
810
+
811
+ def test_get_hydrate_create_view(self):
812
+ latest_revision = self.test_snippet.save_revision(user=self.user)
813
+ response = self.get(params={"_w_hydrate_create_view": "1"})
814
+ self.assertEqual(response.status_code, 200)
815
+ self.assertTemplateUsed(response, "wagtailadmin/generic/edit_partials.html")
816
+ soup = self.get_soup(response.content)
817
+
818
+ # Should reload only the status side panel
819
+ side_panels = soup.select(
820
+ "template[data-controller='w-teleport']"
821
+ "[data-w-teleport-target-value^='[data-side-panel=']"
822
+ "[data-w-teleport-mode-value='innerHTML']"
823
+ )
824
+ self.assertEqual(len(side_panels), 1)
825
+ status_side_panel = side_panels[0]
826
+ self.assertEqual(
827
+ status_side_panel["data-w-teleport-target-value"],
828
+ "[data-side-panel='status']",
829
+ )
830
+
831
+ # Workflow and privacy features are not available
832
+ workflow_status_dialog = soup.find("div", id="workflow-status-dialog")
833
+ self.assertIsNone(workflow_status_dialog)
834
+ set_privacy_dialog = soup.find("div", id="set-privacy")
835
+ self.assertIsNone(set_privacy_dialog)
836
+
837
+ breadcrumbs = soup.find(
838
+ "template",
839
+ {
840
+ "data-controller": "w-teleport",
841
+ "data-w-teleport-target-value": "header [data-w-breadcrumbs]",
842
+ "data-w-teleport-mode-value": "outerHTML",
843
+ },
844
+ )
845
+ self.assertIsNotNone(breadcrumbs)
846
+ # Should include header buttons as they were not rendered in the create view
847
+ self.assertIsNotNone(breadcrumbs.select_one("#w-slim-header-buttons"))
848
+
849
+ # Should render the history link button as it wasn't rendered in the create view
850
+ history_link = soup.find(
851
+ "template",
852
+ {
853
+ "data-controller": "w-teleport",
854
+ "data-w-teleport-target-value": "[data-side-panel-toggle]:last-of-type",
855
+ "data-w-teleport-mode-value": "afterend",
856
+ },
857
+ )
858
+ history_url = reverse(
859
+ self.test_snippet.snippet_viewset.get_url_name("history"),
860
+ args=(quote(self.test_snippet.pk),),
861
+ )
862
+ self.assertIsNotNone(history_link)
863
+ self.assertIsNotNone(history_link.select_one(f"a[href='{history_url}']"))
864
+
865
+ form_title_heading = soup.find(
866
+ "template",
867
+ {
868
+ "data-controller": "w-teleport",
869
+ "data-w-teleport-target-value": "#header-title span",
870
+ "data-w-teleport-mode-value": "textContent",
871
+ },
872
+ )
873
+ self.assertIsNotNone(form_title_heading)
874
+ self.assertEqual(form_title_heading.text.strip(), str(self.test_snippet))
875
+ header_title = soup.find(
876
+ "template",
877
+ {
878
+ "data-controller": "w-teleport",
879
+ "data-w-teleport-target-value": "head title",
880
+ "data-w-teleport-mode-value": "textContent",
881
+ },
882
+ )
883
+ self.assertIsNotNone(header_title)
884
+ self.assertEqual(header_title.text.strip(), f"Editing: {self.test_snippet}")
885
+
886
+ # Should include loaded revision ID and timestamp in the form for
887
+ # subsequent autosave requests
888
+ form_adds = soup.find(
889
+ "template",
890
+ {
891
+ "data-controller": "w-teleport",
892
+ "data-w-teleport-target-value": "form[data-edit-form]",
893
+ "data-w-teleport-mode-value": "afterbegin",
894
+ },
895
+ )
896
+ self.assertIsNotNone(form_adds)
897
+ self.assertEqual(
898
+ form_adds.select_one("input[name='loaded_revision_id']")["value"],
899
+ str(latest_revision.pk),
900
+ )
901
+ self.assertEqual(
902
+ form_adds.select_one("input[name='loaded_revision_created_at']")["value"],
903
+ latest_revision.created_at.isoformat(),
904
+ )
905
+
906
+ # Should load the editing sessions module as it was not in the create view
907
+ editing_sessions = soup.find(
908
+ "template",
909
+ {
910
+ "data-controller": "w-teleport",
911
+ "data-w-teleport-target-value": "#w-autosave-indicator",
912
+ "data-w-teleport-mode-value": "afterend",
913
+ },
914
+ )
915
+ self.assertIsNotNone(editing_sessions)
916
+ # with the revision info
917
+ self.assertEqual(
918
+ editing_sessions.select_one("input[name='revision_id']")["value"],
919
+ str(latest_revision.pk),
920
+ )
921
+ self.assertEqual(
922
+ editing_sessions.select_one("input[name='revision_created_at']")["value"],
923
+ latest_revision.created_at.isoformat(),
924
+ )
925
+
926
+ def test_edit_snippet_with_revision(self):
927
+ response = self.post(post_data={"text": "bar"})
928
+ self.assertRedirects(
929
+ response, reverse("wagtailsnippets_tests_revisablemodel:list")
930
+ )
931
+
932
+ # The instance should be updated
933
+ snippets = RevisableModel.objects.filter(text="bar")
934
+ self.assertEqual(snippets.count(), 1)
935
+
936
+ # The revision should be created
937
+ revisions = self.test_snippet.revisions
938
+ revision = revisions.first()
939
+ self.assertEqual(revisions.count(), 1)
940
+ self.assertEqual(revision.content["text"], "bar")
941
+
942
+ # The log entry should have the revision attached
943
+ log_entries = ModelLogEntry.objects.for_instance(self.test_snippet).filter(
944
+ action="wagtail.edit"
945
+ )
946
+ self.assertEqual(log_entries.count(), 1)
947
+ self.assertEqual(log_entries.first().revision, revision)
948
+
949
+ def test_edit_snippet_with_revision_and_json_response(self):
950
+ initial_revision = self.test_snippet.save_revision(user=self.user)
951
+ self.assertEqual(self.test_snippet.revisions.count(), 1)
952
+ response = self.post(
953
+ post_data={
954
+ "text": "bar",
955
+ "loaded_revision_id": initial_revision.pk,
956
+ "loaded_revision_created_at": initial_revision.created_at.isoformat(),
957
+ },
958
+ headers={"Accept": "application/json"},
959
+ )
960
+
961
+ # Should be a 200 OK JSON response
962
+ self.assertEqual(response.status_code, 200)
963
+ self.assertEqual(response["Content-Type"], "application/json")
964
+ response_json = response.json()
965
+ self.assertIs(response_json["success"], True)
966
+ self.assertEqual(response_json["pk"], self.test_snippet.pk)
967
+
968
+ # Should create a new revision to be overwritten later
969
+ self.assertEqual(self.test_snippet.revisions.count(), 2)
970
+ self.assertNotEqual(response_json["revision_id"], initial_revision.pk)
971
+ revision = self.test_snippet.revisions.get(pk=response_json["revision_id"])
972
+ self.assertEqual(
973
+ response_json["revision_created_at"],
974
+ revision.created_at.isoformat(),
975
+ )
976
+ self.assertEqual(revision.content["text"], "bar")
977
+
978
+ # The instance should be updated
979
+ snippets = RevisableModel.objects.filter(text="bar")
980
+ self.assertEqual(snippets.count(), 1)
981
+
982
+ # The log entry should have the revision attached
983
+ log_entries = ModelLogEntry.objects.for_instance(self.test_snippet).filter(
984
+ action="wagtail.edit"
985
+ )
986
+ self.assertEqual(log_entries.count(), 1)
987
+ self.assertEqual(log_entries.first().revision, revision)
988
+
989
+ def test_save_outdated_revision_with_json_response(self):
990
+ self.test_snippet.text = "Initial revision"
991
+ revision = self.test_snippet.save_revision(user=self.user)
992
+ self.test_snippet.text = "Latest revision"
993
+ self.test_snippet.save_revision()
994
+ self.assertEqual(self.test_snippet.revisions.count(), 2)
995
+ response = self.post(
996
+ post_data={
997
+ "text": "Updated revision",
998
+ "loaded_revision_id": revision.pk,
999
+ },
1000
+ headers={"Accept": "application/json"},
1001
+ )
1002
+
1003
+ # Instead of creating a new revision for autosave (which means the user
1004
+ # would unknowingly replace a newer revision), we return an error
1005
+ # response that should be a 400 response
1006
+ self.assertEqual(response.status_code, 400)
1007
+ self.assertEqual(response["Content-Type"], "application/json")
1008
+ self.assertEqual(
1009
+ response.json(),
1010
+ {
1011
+ "success": False,
1012
+ "error_code": "invalid_revision",
1013
+ "error_message": "Saving will overwrite a newer version.",
1014
+ },
1015
+ )
1016
+
1017
+ self.assertEqual(self.test_snippet.revisions.count(), 2)
1018
+ revision.refresh_from_db()
1019
+ self.assertEqual(revision.content["text"], "Initial revision")
1020
+
1021
+ def test_save_outdated_revision_timestampwith_json_response(self):
1022
+ self.test_snippet.text = "Initial revision"
1023
+ revision = self.test_snippet.save_revision(user=self.user)
1024
+ loaded_revision_created_at = revision.created_at.isoformat()
1025
+ self.test_snippet.text = "Latest revision"
1026
+ self.test_snippet.save_revision(user=self.user, overwrite_revision=revision)
1027
+ self.assertEqual(self.test_snippet.revisions.count(), 1)
1028
+ response = self.post(
1029
+ post_data={
1030
+ "text": "Updated revision",
1031
+ "loaded_revision_id": revision.pk,
1032
+ "loaded_revision_created_at": loaded_revision_created_at,
1033
+ },
1034
+ headers={"Accept": "application/json"},
1035
+ )
1036
+
1037
+ # Instead of creating a new revision for autosave (which means the user
1038
+ # would unknowingly replace a newer revision), we return an error
1039
+ # response that should be a 400 response
1040
+ self.assertEqual(response.status_code, 400)
1041
+ self.assertEqual(response["Content-Type"], "application/json")
1042
+ self.assertEqual(
1043
+ response.json(),
1044
+ {
1045
+ "success": False,
1046
+ "error_code": "invalid_revision",
1047
+ "error_message": "Saving will overwrite a newer version.",
1048
+ },
1049
+ )
1050
+
1051
+ self.assertEqual(self.test_snippet.revisions.count(), 1)
1052
+ revision.refresh_from_db()
1053
+ self.assertEqual(revision.content["text"], "Latest revision")
1054
+
1055
+ def test_overwrite_revision_with_json_response(self):
1056
+ self.test_snippet.text = "Initial revision"
1057
+ initial_revision = self.test_snippet.save_revision()
1058
+ self.test_snippet.text = "Changed via a previous autosave"
1059
+ revision = self.test_snippet.save_revision(user=self.user)
1060
+ self.assertEqual(self.test_snippet.revisions.count(), 2)
1061
+ response = self.post(
1062
+ post_data={
1063
+ "text": "Updated revision",
1064
+ # The page was originally loaded with initial_revision, but
1065
+ # a successful autosave created a new revision which we now
1066
+ # want to overwrite with a new autosave request
1067
+ "loaded_revision_id": initial_revision.pk,
1068
+ "overwrite_revision_id": revision.pk,
1069
+ },
1070
+ headers={"Accept": "application/json"},
1071
+ )
1072
+
1073
+ # Should be a 200 OK JSON response
1074
+ self.assertEqual(response.status_code, 200)
1075
+ self.assertEqual(response["Content-Type"], "application/json")
1076
+ revision.refresh_from_db()
1077
+ response_json = response.json()
1078
+ self.assertIs(response_json["success"], True)
1079
+ self.assertEqual(response_json["pk"], self.test_snippet.pk)
1080
+ self.assertEqual(response_json["revision_id"], revision.pk)
1081
+ self.assertEqual(
1082
+ response_json["revision_created_at"],
1083
+ revision.created_at.isoformat(),
1084
+ )
1085
+ self.assertEqual(response_json["field_updates"], {})
1086
+ soup = self.get_soup(response_json["html"])
1087
+ status_side_panel = soup.find(
1088
+ "template",
1089
+ {
1090
+ "data-controller": "w-teleport",
1091
+ "data-w-teleport-target-value": "[data-side-panel='status']",
1092
+ "data-w-teleport-mode-value": "innerHTML",
1093
+ },
1094
+ )
1095
+ self.assertIsNotNone(status_side_panel)
1096
+ breadcrumbs = soup.find(
1097
+ "template",
1098
+ {
1099
+ "data-controller": "w-teleport",
1100
+ "data-w-teleport-target-value": "header [data-w-breadcrumbs]",
1101
+ "data-w-teleport-mode-value": "outerHTML",
1102
+ },
1103
+ )
1104
+ self.assertIsNotNone(breadcrumbs)
1105
+ form_title_heading = soup.find(
1106
+ "template",
1107
+ {
1108
+ "data-controller": "w-teleport",
1109
+ "data-w-teleport-target-value": "#header-title span",
1110
+ "data-w-teleport-mode-value": "textContent",
1111
+ },
1112
+ )
1113
+ self.assertIsNotNone(form_title_heading)
1114
+ self.assertEqual(form_title_heading.text.strip(), "Updated revision")
1115
+ header_title = soup.find(
1116
+ "template",
1117
+ {
1118
+ "data-controller": "w-teleport",
1119
+ "data-w-teleport-target-value": "head title",
1120
+ "data-w-teleport-mode-value": "textContent",
1121
+ },
1122
+ )
1123
+ self.assertIsNotNone(header_title)
1124
+ self.assertEqual(header_title.text.strip(), "Editing: Updated revision")
1125
+
1126
+ self.assertEqual(self.test_snippet.revisions.count(), 2)
1127
+ revision.refresh_from_db()
1128
+ self.assertEqual(revision.content["text"], "Updated revision")
1129
+
1130
+ def test_overwrite_non_latest_revision(self):
1131
+ self.test_snippet.text = "Initial revision"
1132
+ initial_revision = self.test_snippet.save_revision(user=self.user)
1133
+ self.test_snippet.text = "First update via autosave"
1134
+ user_revision = self.test_snippet.save_revision(user=self.user)
1135
+ self.test_snippet.text = "Someone else's changed text"
1136
+ later_revision = self.test_snippet.save_revision()
1137
+ self.assertEqual(self.test_snippet.revisions.count(), 3)
1138
+
1139
+ post_data = {
1140
+ "text": "Updated revision",
1141
+ "loaded_revision_id": initial_revision.id,
1142
+ "overwrite_revision_id": user_revision.id,
1143
+ }
1144
+ response = self.post(
1145
+ post_data=post_data,
1146
+ headers={"Accept": "application/json"},
1147
+ )
1148
+
1149
+ # Should be a 400 response
1150
+ self.assertEqual(response.status_code, 400)
1151
+ self.assertEqual(response["Content-Type"], "application/json")
1152
+ self.assertEqual(
1153
+ response.json(),
1154
+ {
1155
+ "success": False,
1156
+ "error_code": "invalid_revision",
1157
+ "error_message": "Saving will overwrite a newer version.",
1158
+ },
1159
+ )
1160
+
1161
+ # Live DB record should be unchanged
1162
+ # (neither save_revision nor the failed form post should have updated it)
1163
+ self.test_snippet.refresh_from_db()
1164
+ self.assertEqual(self.test_snippet.text, "foo")
1165
+
1166
+ # The passed revision for overwriting, and the actual latest revision, should both be unchanged
1167
+ self.assertEqual(self.test_snippet.revisions.count(), 3)
1168
+ user_revision.refresh_from_db()
1169
+ self.assertEqual(user_revision.content["text"], "First update via autosave")
1170
+ later_revision.refresh_from_db()
1171
+ self.assertEqual(later_revision.content["text"], "Someone else's changed text")
1172
+ self.assertEqual(self.test_snippet.get_latest_revision().id, later_revision.id)
1173
+
1174
+ def test_overwrite_nonexistent_revision(self):
1175
+ self.test_snippet.text = "Initial revision"
1176
+ user_revision = self.test_snippet.save_revision(user=self.user)
1177
+ self.assertEqual(self.test_snippet.revisions.count(), 1)
1178
+
1179
+ post_data = {
1180
+ "text": "Updated revision",
1181
+ "overwrite_revision_id": 999999,
1182
+ }
1183
+ response = self.post(
1184
+ post_data=post_data,
1185
+ headers={"Accept": "application/json"},
1186
+ )
1187
+
1188
+ # Should be a 400 response
1189
+ self.assertEqual(response.status_code, 400)
1190
+ self.assertEqual(response["Content-Type"], "application/json")
1191
+ self.assertEqual(
1192
+ response.json(),
1193
+ {
1194
+ "success": False,
1195
+ "error_code": "invalid_revision",
1196
+ # We only naively check whether overwrite_revision_id matches
1197
+ # the latest revision ID, and if it doesn't, we assume there's
1198
+ # a newer revision.
1199
+ "error_message": "Saving will overwrite a newer version.",
1200
+ },
1201
+ )
1202
+
1203
+ # Live DB record should be unchanged
1204
+ # (neither save_revision nor the failed form post should have updated it)
1205
+ self.test_snippet.refresh_from_db()
1206
+ self.assertEqual(self.test_snippet.text, "foo")
1207
+
1208
+ # The latest revision should be unchanged
1209
+ self.assertEqual(self.test_snippet.revisions.count(), 1)
1210
+ latest_revision = self.test_snippet.get_latest_revision()
1211
+ self.assertEqual(latest_revision.id, user_revision.id)
1212
+ self.assertEqual(latest_revision.content["text"], "Initial revision")
1213
+
1214
+
1215
+ class TestEditDraftStateSnippet(BaseTestSnippetEditView):
1216
+ STATUS_TOGGLE_BADGE_REGEX = (
1217
+ r'data-side-panel-toggle="status"[^<]+<svg[^<]+<use[^<]+</use[^<]+</svg[^<]+'
1218
+ r"<div data-side-panel-toggle-counter[^>]+w-bg-critical-200[^>]+>\s*%(num_errors)s\s*</div>"
1219
+ )
1220
+
1221
+ def setUp(self):
1222
+ super().setUp()
1223
+ self.test_snippet = DraftStateCustomPrimaryKeyModel.objects.create(
1224
+ custom_id="custom/1", text="Draft-enabled Foo", live=False
1225
+ )
1226
+
1227
+ def test_get(self):
1228
+ revision = self.test_snippet.save_revision()
1229
+ response = self.get()
1230
+
1231
+ self.assertEqual(response.status_code, 200)
1232
+ self.assertTemplateUsed(response, "wagtailsnippets/snippets/edit.html")
1233
+
1234
+ # The save button should be labelled "Save draft"
1235
+ self.assertContains(response, "Save draft")
1236
+ # The publish button should exist
1237
+ self.assertContains(response, "Publish")
1238
+ # The publish button should have name="action-publish"
1239
+ self.assertContains(
1240
+ response,
1241
+ '<button\n type="submit"\n name="action-publish"\n value="action-publish"\n class="button action-save button-longrunning"\n data-controller="w-progress"\n data-action="w-progress#activate"\n',
1242
+ )
1243
+
1244
+ # The status side panel should show "No publishing schedule set" info
1245
+ self.assertContains(response, "No publishing schedule set")
1246
+
1247
+ # Should show the "Set schedule" button
1248
+ self.assertSchedulingDialogRendered(response, label="Set schedule")
1249
+
1250
+ # Should show the correct subtitle in the dialog
1251
+ self.assertContains(
1252
+ response,
1253
+ "Choose when this draft state custom primary key model should go live and/or expire",
1254
+ )
1255
+
1256
+ # Should not show the Unpublish action menu item
1257
+ unpublish_url = reverse(
1258
+ "wagtailsnippets_tests_draftstatecustomprimarykeymodel:unpublish",
1259
+ args=(quote(self.test_snippet.pk),),
1260
+ )
1261
+ self.assertNotContains(
1262
+ response,
1263
+ f'<a class="button" href="{unpublish_url}">',
1264
+ )
1265
+ self.assertNotContains(response, "Unpublish")
1266
+
1267
+ soup = self.get_soup(response.content)
1268
+ form = soup.select_one("form[data-edit-form]")
1269
+ self.assertIsNotNone(form)
1270
+ loaded_revision = form.select_one("input[name='loaded_revision_id']")
1271
+ self.assertIsNotNone(loaded_revision)
1272
+ self.assertEqual(int(loaded_revision["value"]), revision.pk)
1273
+ loaded_timestamp = form.select_one("input[name='loaded_revision_created_at']")
1274
+ self.assertIsNotNone(loaded_timestamp)
1275
+ self.assertEqual(loaded_timestamp["value"], revision.created_at.isoformat())
1276
+
1277
+ # Autosave defaults to enabled with 500ms interval
1278
+ soup = self.get_soup(response.content)
1279
+ form = soup.select_one("form[data-edit-form]")
1280
+ self.assertIsNotNone(form)
1281
+ self.assertIn("w-autosave", form["data-controller"].split())
1282
+ self.assertTrue(
1283
+ {
1284
+ "w-unsaved:add->w-autosave#save:prevent",
1285
+ "w-autosave:success->w-unsaved#clear",
1286
+ }.issubset(form["data-action"].split())
1287
+ )
1288
+ self.assertEqual(form.attrs.get("data-w-autosave-interval-value"), "500")
1289
+
1290
+ def test_get_hydrate_create_view(self):
1291
+ # Use FullFeaturedSnippet to test the UI hydration of all features
1292
+ snippet = FullFeaturedSnippet.objects.create(
1293
+ text="Hello world",
1294
+ country_code="UK",
1295
+ some_number=42,
1296
+ )
1297
+ latest_revision = snippet.save_revision(user=self.user)
1298
+ edit_url = reverse(
1299
+ snippet.snippet_viewset.get_url_name("edit"),
1300
+ args=(quote(snippet.pk),),
1301
+ )
1302
+ response = self.client.get(edit_url, {"_w_hydrate_create_view": "1"})
1303
+ self.assertEqual(response.status_code, 200)
1304
+ self.assertTemplateUsed(response, "wagtailadmin/generic/edit_partials.html")
1305
+ soup = self.get_soup(response.content)
1306
+
1307
+ # Should reload the status and preview side panels only
1308
+ side_panels = soup.select(
1309
+ "template[data-controller='w-teleport']"
1310
+ "[data-w-teleport-target-value^='[data-side-panel=']"
1311
+ "[data-w-teleport-mode-value='innerHTML']"
1312
+ )
1313
+ self.assertEqual(len(side_panels), 2)
1314
+ status_side_panel = side_panels[0]
1315
+ self.assertEqual(
1316
+ status_side_panel["data-w-teleport-target-value"],
1317
+ "[data-side-panel='status']",
1318
+ )
1319
+
1320
+ # Under normal circumstances, a newly-created snippet would never
1321
+ # immediately enter a workflow without a full-page reload, so don't
1322
+ # bother rendering the workflow status dialog when hydrating a create view
1323
+ workflow_status_dialog = soup.find("div", id="workflow-status-dialog")
1324
+ self.assertIsNone(workflow_status_dialog)
1325
+ # Privacy features are not available for snippets
1326
+ set_privacy_dialog = soup.find("div", id="set-privacy")
1327
+ self.assertIsNone(set_privacy_dialog)
1328
+
1329
+ # We need to change the preview URL to use the one for editing, but there is
1330
+ # no way to declaratively change attributes via partial rendering yet, and we
1331
+ # need to restart the controller anyway, so just re-render the whole panel
1332
+ preview_side_panel = side_panels[1]
1333
+ self.assertEqual(
1334
+ preview_side_panel["data-w-teleport-target-value"],
1335
+ "[data-side-panel='preview']",
1336
+ )
1337
+ preview_url = reverse(
1338
+ snippet.snippet_viewset.get_url_name("preview_on_edit"),
1339
+ args=(quote(snippet.pk),),
1340
+ )
1341
+ self.assertIsNotNone(
1342
+ preview_side_panel.select_one(f"[data-w-preview-url-value='{preview_url}']")
1343
+ )
1344
+
1345
+ breadcrumbs = soup.find(
1346
+ "template",
1347
+ {
1348
+ "data-controller": "w-teleport",
1349
+ "data-w-teleport-target-value": "header [data-w-breadcrumbs]",
1350
+ "data-w-teleport-mode-value": "outerHTML",
1351
+ },
1352
+ )
1353
+ self.assertIsNotNone(breadcrumbs)
1354
+ # Should include header buttons as they were not rendered in the create view
1355
+ self.assertIsNotNone(breadcrumbs.select_one("#w-slim-header-buttons"))
1356
+
1357
+ # Should render the history link button as it wasn't rendered in the create view
1358
+ history_link = soup.find(
1359
+ "template",
1360
+ {
1361
+ "data-controller": "w-teleport",
1362
+ "data-w-teleport-target-value": "[data-side-panel-toggle]:last-of-type",
1363
+ "data-w-teleport-mode-value": "afterend",
1364
+ },
1365
+ )
1366
+ history_url = reverse(
1367
+ snippet.snippet_viewset.get_url_name("history"),
1368
+ args=(quote(snippet.pk),),
1369
+ )
1370
+ self.assertIsNotNone(history_link)
1371
+ self.assertIsNotNone(history_link.select_one(f"a[href='{history_url}']"))
1372
+
1373
+ form_title_heading = soup.find(
1374
+ "template",
1375
+ {
1376
+ "data-controller": "w-teleport",
1377
+ "data-w-teleport-target-value": "#header-title span",
1378
+ "data-w-teleport-mode-value": "textContent",
1379
+ },
1380
+ )
1381
+ self.assertIsNotNone(form_title_heading)
1382
+ self.assertEqual(form_title_heading.text.strip(), str(snippet))
1383
+ header_title = soup.find(
1384
+ "template",
1385
+ {
1386
+ "data-controller": "w-teleport",
1387
+ "data-w-teleport-target-value": "head title",
1388
+ "data-w-teleport-mode-value": "textContent",
1389
+ },
1390
+ )
1391
+ self.assertIsNotNone(header_title)
1392
+ self.assertEqual(header_title.text.strip(), f"Editing: {snippet}")
1393
+
1394
+ # Should include loaded revision ID and timestamp in the form for
1395
+ # subsequent autosave requests
1396
+ form_adds = soup.find(
1397
+ "template",
1398
+ {
1399
+ "data-controller": "w-teleport",
1400
+ "data-w-teleport-target-value": "form[data-edit-form]",
1401
+ "data-w-teleport-mode-value": "afterbegin",
1402
+ },
1403
+ )
1404
+ self.assertIsNotNone(form_adds)
1405
+ self.assertEqual(
1406
+ form_adds.select_one("input[name='loaded_revision_id']")["value"],
1407
+ str(latest_revision.pk),
1408
+ )
1409
+ self.assertEqual(
1410
+ form_adds.select_one("input[name='loaded_revision_created_at']")["value"],
1411
+ latest_revision.created_at.isoformat(),
1412
+ )
1413
+
1414
+ # Should load the editing sessions module as it was not in the create view
1415
+ editing_sessions = soup.find(
1416
+ "template",
1417
+ {
1418
+ "data-controller": "w-teleport",
1419
+ "data-w-teleport-target-value": "#w-autosave-indicator",
1420
+ "data-w-teleport-mode-value": "afterend",
1421
+ },
1422
+ )
1423
+ self.assertIsNotNone(editing_sessions)
1424
+ # with the revision info
1425
+ self.assertEqual(
1426
+ editing_sessions.select_one("input[name='revision_id']")["value"],
1427
+ str(latest_revision.pk),
1428
+ )
1429
+ self.assertEqual(
1430
+ editing_sessions.select_one("input[name='revision_created_at']")["value"],
1431
+ latest_revision.created_at.isoformat(),
1432
+ )
1433
+
1434
+ @override_settings(WAGTAIL_AUTOSAVE_INTERVAL=0)
1435
+ def test_autosave_disabled(self):
1436
+ response = self.get()
1437
+ self.assertEqual(response.status_code, 200)
1438
+ soup = self.get_soup(response.content)
1439
+ form = soup.select_one("form[data-edit-form]")
1440
+ self.assertIsNotNone(form)
1441
+ self.assertNotIn("w-autosave", form["data-controller"].split())
1442
+ self.assertNotIn("w-autosave", form["data-action"])
1443
+ self.assertIsNone(form.attrs.get("data-w-autosave-interval-value"))
1444
+
1445
+ @override_settings(WAGTAIL_AUTOSAVE_INTERVAL=2000)
1446
+ def test_autosave_custom_interval(self):
1447
+ response = self.get()
1448
+ self.assertEqual(response.status_code, 200)
1449
+ soup = self.get_soup(response.content)
1450
+ form = soup.select_one("form[data-edit-form]")
1451
+ self.assertIsNotNone(form)
1452
+ self.assertIn("w-autosave", form["data-controller"].split())
1453
+ self.assertTrue(
1454
+ {
1455
+ "w-unsaved:add->w-autosave#save:prevent",
1456
+ "w-autosave:success->w-unsaved#clear",
1457
+ }.issubset(form["data-action"].split())
1458
+ )
1459
+ self.assertEqual(form.attrs.get("data-w-autosave-interval-value"), "2000")
1460
+
1461
+ def test_save_draft(self):
1462
+ response = self.post(post_data={"text": "Draft-enabled Bar"})
1463
+ self.test_snippet.refresh_from_db()
1464
+ revisions = Revision.objects.for_instance(self.test_snippet)
1465
+ latest_revision = self.test_snippet.latest_revision
1466
+
1467
+ self.assertRedirects(response, self.get_edit_url())
1468
+
1469
+ # The instance should be updated, since it is still a draft
1470
+ self.assertEqual(self.test_snippet.text, "Draft-enabled Bar")
1471
+
1472
+ # The instance should be a draft
1473
+ self.assertFalse(self.test_snippet.live)
1474
+ self.assertTrue(self.test_snippet.has_unpublished_changes)
1475
+ self.assertIsNone(self.test_snippet.first_published_at)
1476
+ self.assertIsNone(self.test_snippet.last_published_at)
1477
+ self.assertIsNone(self.test_snippet.live_revision)
1478
+
1479
+ # The revision should be created and set as latest_revision
1480
+ self.assertEqual(revisions.count(), 1)
1481
+ self.assertEqual(latest_revision, revisions.first())
1482
+
1483
+ # The revision content should contain the new data
1484
+ self.assertEqual(latest_revision.content["text"], "Draft-enabled Bar")
1485
+
1486
+ # A log entry should be created
1487
+ log_entry = (
1488
+ ModelLogEntry.objects.for_instance(self.test_snippet)
1489
+ .filter(action="wagtail.edit")
1490
+ .order_by("-timestamp")
1491
+ .first()
1492
+ )
1493
+ self.assertEqual(log_entry.revision, self.test_snippet.latest_revision)
1494
+ self.assertEqual(log_entry.label, "Draft-enabled Bar")
1495
+
1496
+ def test_skip_validation_on_save_draft(self):
1497
+ response = self.post(post_data={"text": ""})
1498
+ self.test_snippet.refresh_from_db()
1499
+ revisions = Revision.objects.for_instance(self.test_snippet)
1500
+ latest_revision = self.test_snippet.latest_revision
1501
+
1502
+ self.assertRedirects(response, self.get_edit_url())
1503
+
1504
+ # The instance should be updated, since it is still a draft
1505
+ self.assertEqual(self.test_snippet.text, "")
1506
+
1507
+ # The instance should be a draft
1508
+ self.assertFalse(self.test_snippet.live)
1509
+ self.assertTrue(self.test_snippet.has_unpublished_changes)
1510
+ self.assertIsNone(self.test_snippet.first_published_at)
1511
+ self.assertIsNone(self.test_snippet.last_published_at)
1512
+ self.assertIsNone(self.test_snippet.live_revision)
1513
+
1514
+ # The revision should be created and set as latest_revision
1515
+ self.assertEqual(revisions.count(), 1)
1516
+ self.assertEqual(latest_revision, revisions.first())
1517
+
1518
+ # The revision content should contain the new data
1519
+ self.assertEqual(latest_revision.content["text"], "")
1520
+
1521
+ # A log entry should be created (with a fallback label)
1522
+ log_entry = (
1523
+ ModelLogEntry.objects.for_instance(self.test_snippet)
1524
+ .filter(action="wagtail.edit")
1525
+ .order_by("-timestamp")
1526
+ .first()
1527
+ )
1528
+ self.assertEqual(log_entry.revision, self.test_snippet.latest_revision)
1529
+ self.assertEqual(
1530
+ log_entry.label,
1531
+ f"DraftStateCustomPrimaryKeyModel object ({self.test_snippet.pk})",
1532
+ )
1533
+
1534
+ def test_required_asterisk_on_reshowing_form(self):
1535
+ """
1536
+ If a form is reshown due to a validation error elsewhere, fields whose validation
1537
+ was deferred should still show the required asterisk.
1538
+ """
1539
+ snippet = FullFeaturedSnippet.objects.create(
1540
+ text="Hello world",
1541
+ country_code="UK",
1542
+ some_number=42,
1543
+ )
1544
+ response = self.client.post(
1545
+ reverse("some_namespace:edit", args=[snippet.pk]),
1546
+ {"text": "", "country_code": "UK", "some_number": "meef"},
1547
+ )
1548
+
1549
+ self.assertEqual(response.status_code, 200)
1550
+
1551
+ # The empty text should not cause a validation error, but the invalid number should
1552
+ self.assertNotContains(response, "This field is required.")
1553
+ self.assertContains(response, "Enter a whole number.", count=1)
1554
+
1555
+ soup = self.get_soup(response.content)
1556
+ self.assertTrue(soup.select_one('label[for="id_text"] > span.w-required-mark'))
1557
+
1558
+ def test_cannot_publish_invalid(self):
1559
+ # Connect a mock signal handler to published signal
1560
+ mock_handler = mock.MagicMock()
1561
+ published.connect(mock_handler)
1562
+
1563
+ try:
1564
+ response = self.post(
1565
+ post_data={
1566
+ "text": "",
1567
+ "action-publish": "action-publish",
1568
+ }
1569
+ )
1570
+
1571
+ self.test_snippet.refresh_from_db()
1572
+
1573
+ self.assertEqual(response.status_code, 200)
1574
+ self.assertContains(
1575
+ response,
1576
+ "The draft state custom primary key model could not be saved due to errors.",
1577
+ )
1578
+
1579
+ # The instance should be unchanged
1580
+ self.assertEqual(self.test_snippet.text, "Draft-enabled Foo")
1581
+ self.assertFalse(self.test_snippet.live)
1582
+
1583
+ # The published signal should not have been fired
1584
+ self.assertEqual(mock_handler.call_count, 0)
1585
+ finally:
1586
+ published.disconnect(mock_handler)
1587
+
1588
+ def test_publish(self):
1589
+ # Connect a mock signal handler to published signal
1590
+ mock_handler = mock.MagicMock()
1591
+ published.connect(mock_handler)
1592
+
1593
+ try:
1594
+ timestamp = now()
1595
+ with freeze_time(timestamp):
1596
+ response = self.post(
1597
+ post_data={
1598
+ "text": "Draft-enabled Bar, Published",
1599
+ "action-publish": "action-publish",
1600
+ }
1601
+ )
1602
+
1603
+ self.test_snippet.refresh_from_db()
1604
+ revisions = Revision.objects.for_instance(self.test_snippet)
1605
+ latest_revision = self.test_snippet.latest_revision
1606
+
1607
+ log_entries = ModelLogEntry.objects.filter(
1608
+ content_type=ContentType.objects.get_for_model(
1609
+ DraftStateCustomPrimaryKeyModel
1610
+ ),
1611
+ action="wagtail.publish",
1612
+ object_id=self.test_snippet.pk,
1613
+ )
1614
+ log_entry = log_entries.first()
1615
+
1616
+ self.assertRedirects(
1617
+ response,
1618
+ reverse("wagtailsnippets_tests_draftstatecustomprimarykeymodel:list"),
1619
+ )
1620
+
1621
+ # The instance should be updated
1622
+ self.assertEqual(self.test_snippet.text, "Draft-enabled Bar, Published")
1623
+
1624
+ # The instance should be live
1625
+ self.assertTrue(self.test_snippet.live)
1626
+ self.assertFalse(self.test_snippet.has_unpublished_changes)
1627
+ self.assertEqual(self.test_snippet.first_published_at, timestamp)
1628
+ self.assertEqual(self.test_snippet.last_published_at, timestamp)
1629
+ self.assertEqual(self.test_snippet.live_revision, latest_revision)
1630
+
1631
+ # The revision should be created and set as latest_revision
1632
+ self.assertEqual(revisions.count(), 1)
1633
+ self.assertEqual(latest_revision, revisions.first())
1634
+
1635
+ # The revision content should contain the new data
1636
+ self.assertEqual(
1637
+ latest_revision.content["text"],
1638
+ "Draft-enabled Bar, Published",
1639
+ )
1640
+
1641
+ # A log entry with wagtail.publish action should be created
1642
+ self.assertEqual(log_entries.count(), 1)
1643
+ self.assertEqual(log_entry.timestamp, timestamp)
1644
+
1645
+ # Check that the published signal was fired
1646
+ self.assertEqual(mock_handler.call_count, 1)
1647
+ mock_call = mock_handler.mock_calls[0][2]
1648
+
1649
+ self.assertEqual(mock_call["sender"], DraftStateCustomPrimaryKeyModel)
1650
+ self.assertEqual(mock_call["instance"], self.test_snippet)
1651
+ self.assertIsInstance(
1652
+ mock_call["instance"], DraftStateCustomPrimaryKeyModel
1653
+ )
1654
+ finally:
1655
+ published.disconnect(mock_handler)
1656
+
1657
+ def test_publish_bad_permissions(self):
1658
+ # Only add edit permission
1659
+ self.user.is_superuser = False
1660
+ edit_permission = Permission.objects.get(
1661
+ content_type__app_label="tests",
1662
+ codename="change_draftstatecustomprimarykeymodel",
1663
+ )
1664
+ admin_permission = Permission.objects.get(
1665
+ content_type__app_label="wagtailadmin",
1666
+ codename="access_admin",
1667
+ )
1668
+ self.user.user_permissions.add(edit_permission, admin_permission)
1669
+ self.user.save()
1670
+
1671
+ # Connect a mock signal handler to published signal
1672
+ mock_handler = mock.MagicMock()
1673
+ published.connect(mock_handler)
1674
+
1675
+ try:
1676
+ response = self.post(
1677
+ post_data={
1678
+ "text": "Edited draft Foo",
1679
+ "action-publish": "action-publish",
1680
+ }
1681
+ )
1682
+ self.test_snippet.refresh_from_db()
1683
+
1684
+ # Should remain on the edit page
1685
+ self.assertRedirects(response, self.get_edit_url())
1686
+
1687
+ # The instance should be edited, since it is still a draft
1688
+ self.assertEqual(self.test_snippet.text, "Edited draft Foo")
1689
+
1690
+ # The instance should not be live
1691
+ self.assertFalse(self.test_snippet.live)
1692
+ self.assertTrue(self.test_snippet.has_unpublished_changes)
1693
+
1694
+ # A revision should be created and set as latest_revision, but not live_revision
1695
+ self.assertIsNotNone(self.test_snippet.latest_revision)
1696
+ self.assertIsNone(self.test_snippet.live_revision)
1697
+
1698
+ # The revision content should contain the data
1699
+ self.assertEqual(
1700
+ self.test_snippet.latest_revision.content["text"],
1701
+ "Edited draft Foo",
1702
+ )
1703
+
1704
+ # Check that the published signal was not fired
1705
+ self.assertEqual(mock_handler.call_count, 0)
1706
+ finally:
1707
+ published.disconnect(mock_handler)
1708
+
1709
+ def test_publish_with_publish_permission(self):
1710
+ # Only add edit and publish permissions
1711
+ self.user.is_superuser = False
1712
+ edit_permission = Permission.objects.get(
1713
+ content_type__app_label="tests",
1714
+ codename="change_draftstatecustomprimarykeymodel",
1715
+ )
1716
+ publish_permission = Permission.objects.get(
1717
+ content_type__app_label="tests",
1718
+ codename="publish_draftstatecustomprimarykeymodel",
1719
+ )
1720
+ admin_permission = Permission.objects.get(
1721
+ content_type__app_label="wagtailadmin", codename="access_admin"
1722
+ )
1723
+ self.user.user_permissions.add(
1724
+ edit_permission,
1725
+ publish_permission,
1726
+ admin_permission,
1727
+ )
1728
+ self.user.save()
1729
+
1730
+ # Connect a mock signal handler to published signal
1731
+ mock_handler = mock.MagicMock()
1732
+ published.connect(mock_handler)
1733
+
1734
+ try:
1735
+ timestamp = now()
1736
+ with freeze_time(timestamp):
1737
+ response = self.post(
1738
+ post_data={
1739
+ "text": "Draft-enabled Bar, Published",
1740
+ "action-publish": "action-publish",
1741
+ }
1742
+ )
1743
+
1744
+ self.test_snippet.refresh_from_db()
1745
+ revisions = Revision.objects.for_instance(self.test_snippet)
1746
+ latest_revision = self.test_snippet.latest_revision
1747
+
1748
+ log_entries = ModelLogEntry.objects.filter(
1749
+ content_type=ContentType.objects.get_for_model(
1750
+ DraftStateCustomPrimaryKeyModel
1751
+ ),
1752
+ action="wagtail.publish",
1753
+ object_id=self.test_snippet.pk,
1754
+ )
1755
+ log_entry = log_entries.first()
1756
+
1757
+ self.assertRedirects(
1758
+ response,
1759
+ reverse("wagtailsnippets_tests_draftstatecustomprimarykeymodel:list"),
1760
+ )
1761
+
1762
+ # The instance should be updated
1763
+ self.assertEqual(self.test_snippet.text, "Draft-enabled Bar, Published")
1764
+
1765
+ # The instance should be live
1766
+ self.assertTrue(self.test_snippet.live)
1767
+ self.assertFalse(self.test_snippet.has_unpublished_changes)
1768
+ self.assertEqual(self.test_snippet.first_published_at, timestamp)
1769
+ self.assertEqual(self.test_snippet.last_published_at, timestamp)
1770
+ self.assertEqual(self.test_snippet.live_revision, latest_revision)
1771
+
1772
+ # The revision should be created and set as latest_revision
1773
+ self.assertEqual(revisions.count(), 1)
1774
+ self.assertEqual(latest_revision, revisions.first())
1775
+
1776
+ # The revision content should contain the new data
1777
+ self.assertEqual(
1778
+ latest_revision.content["text"],
1779
+ "Draft-enabled Bar, Published",
1780
+ )
1781
+
1782
+ # A log entry with wagtail.publish action should be created
1783
+ self.assertEqual(log_entries.count(), 1)
1784
+ self.assertEqual(log_entry.timestamp, timestamp)
1785
+
1786
+ # Check that the published signal was fired
1787
+ self.assertEqual(mock_handler.call_count, 1)
1788
+ mock_call = mock_handler.mock_calls[0][2]
1789
+
1790
+ self.assertEqual(mock_call["sender"], DraftStateCustomPrimaryKeyModel)
1791
+ self.assertEqual(mock_call["instance"], self.test_snippet)
1792
+ self.assertIsInstance(
1793
+ mock_call["instance"], DraftStateCustomPrimaryKeyModel
1794
+ )
1795
+ finally:
1796
+ published.disconnect(mock_handler)
1797
+
1798
+ def test_save_draft_then_publish(self):
1799
+ save_timestamp = now()
1800
+ with freeze_time(save_timestamp):
1801
+ self.test_snippet.text = "Draft-enabled Bar, In Draft"
1802
+ self.test_snippet.save_revision()
1803
+
1804
+ publish_timestamp = now()
1805
+ with freeze_time(publish_timestamp):
1806
+ response = self.post(
1807
+ post_data={
1808
+ "text": "Draft-enabled Bar, Now Published",
1809
+ "action-publish": "action-publish",
1810
+ }
1811
+ )
1812
+
1813
+ self.test_snippet.refresh_from_db()
1814
+ revisions = Revision.objects.for_instance(self.test_snippet).order_by("pk")
1815
+ latest_revision = self.test_snippet.latest_revision
1816
+
1817
+ self.assertRedirects(
1818
+ response,
1819
+ reverse("wagtailsnippets_tests_draftstatecustomprimarykeymodel:list"),
1820
+ )
1821
+
1822
+ # The instance should be updated
1823
+ self.assertEqual(self.test_snippet.text, "Draft-enabled Bar, Now Published")
1824
+
1825
+ # The instance should be live
1826
+ self.assertTrue(self.test_snippet.live)
1827
+ self.assertFalse(self.test_snippet.has_unpublished_changes)
1828
+ self.assertEqual(self.test_snippet.first_published_at, publish_timestamp)
1829
+ self.assertEqual(self.test_snippet.last_published_at, publish_timestamp)
1830
+ self.assertEqual(self.test_snippet.live_revision, latest_revision)
1831
+
1832
+ # The revision should be created and set as latest_revision
1833
+ self.assertEqual(revisions.count(), 2)
1834
+ self.assertEqual(latest_revision, revisions.last())
1835
+
1836
+ # The revision content should contain the new data
1837
+ self.assertEqual(
1838
+ latest_revision.content["text"],
1839
+ "Draft-enabled Bar, Now Published",
1840
+ )
1841
+
1842
+ def test_publish_then_save_draft(self):
1843
+ publish_timestamp = now()
1844
+ with freeze_time(publish_timestamp):
1845
+ self.test_snippet.text = "Draft-enabled Bar, Published"
1846
+ self.test_snippet.save_revision().publish()
1847
+
1848
+ save_timestamp = now()
1849
+ with freeze_time(save_timestamp):
1850
+ response = self.post(
1851
+ post_data={"text": "Draft-enabled Bar, Published and In Draft"}
1852
+ )
1853
+
1854
+ self.test_snippet.refresh_from_db()
1855
+ revisions = Revision.objects.for_instance(self.test_snippet).order_by("pk")
1856
+ latest_revision = self.test_snippet.latest_revision
1857
+
1858
+ self.assertRedirects(response, self.get_edit_url())
1859
+
1860
+ # The instance should be updated with the last published changes
1861
+ self.assertEqual(self.test_snippet.text, "Draft-enabled Bar, Published")
1862
+
1863
+ # The instance should be live
1864
+ self.assertTrue(self.test_snippet.live)
1865
+ # The instance should have unpublished changes
1866
+ self.assertTrue(self.test_snippet.has_unpublished_changes)
1867
+
1868
+ self.assertEqual(self.test_snippet.first_published_at, publish_timestamp)
1869
+ self.assertEqual(self.test_snippet.last_published_at, publish_timestamp)
1870
+
1871
+ # The live revision should be the first revision
1872
+ self.assertEqual(self.test_snippet.live_revision, revisions.first())
1873
+
1874
+ # The second revision should be created and set as latest_revision
1875
+ self.assertEqual(revisions.count(), 2)
1876
+ self.assertEqual(latest_revision, revisions.last())
1877
+
1878
+ # The revision content should contain the new data
1879
+ self.assertEqual(
1880
+ latest_revision.content["text"],
1881
+ "Draft-enabled Bar, Published and In Draft",
1882
+ )
1883
+
1884
+ def test_publish_twice(self):
1885
+ first_timestamp = now()
1886
+ with freeze_time(first_timestamp):
1887
+ self.test_snippet.text = "Draft-enabled Bar, Published Once"
1888
+ self.test_snippet.save_revision().publish()
1889
+
1890
+ second_timestamp = now() + datetime.timedelta(days=1)
1891
+ with freeze_time(second_timestamp):
1892
+ response = self.post(
1893
+ post_data={
1894
+ "text": "Draft-enabled Bar, Published Twice",
1895
+ "action-publish": "action-publish",
1896
+ }
1897
+ )
1898
+
1899
+ self.test_snippet.refresh_from_db()
1900
+
1901
+ revisions = Revision.objects.for_instance(self.test_snippet).order_by("pk")
1902
+ latest_revision = self.test_snippet.latest_revision
1903
+
1904
+ self.assertRedirects(
1905
+ response,
1906
+ reverse("wagtailsnippets_tests_draftstatecustomprimarykeymodel:list"),
1907
+ )
1908
+
1909
+ # The instance should be updated with the last published changes
1910
+ self.assertEqual(self.test_snippet.text, "Draft-enabled Bar, Published Twice")
1911
+
1912
+ # The instance should be live
1913
+ self.assertTrue(self.test_snippet.live)
1914
+ self.assertFalse(self.test_snippet.has_unpublished_changes)
1915
+
1916
+ # The first_published_at and last_published_at should be set correctly
1917
+ self.assertEqual(self.test_snippet.first_published_at, first_timestamp)
1918
+ self.assertEqual(self.test_snippet.last_published_at, second_timestamp)
1919
+
1920
+ # The live revision should be the second revision
1921
+ self.assertEqual(self.test_snippet.live_revision, revisions.last())
1922
+
1923
+ # The second revision should be created and set as latest_revision
1924
+ self.assertEqual(revisions.count(), 2)
1925
+ self.assertEqual(latest_revision, revisions.last())
1926
+
1927
+ # The revision content should contain the new data
1928
+ self.assertEqual(
1929
+ latest_revision.content["text"],
1930
+ "Draft-enabled Bar, Published Twice",
1931
+ )
1932
+
1933
+ def test_get_after_save_draft(self):
1934
+ self.post(post_data={"text": "Draft-enabled Bar"})
1935
+ response = self.get()
1936
+
1937
+ self.assertEqual(response.status_code, 200)
1938
+ self.assertTemplateUsed(response, "wagtailsnippets/snippets/edit.html")
1939
+
1940
+ # Should not show the Live status
1941
+ self.assertNotContains(
1942
+ response,
1943
+ '<h3 id="status-sidebar-live" class="w-label-1 !w-mt-0 w-mb-1"><span class="w-sr-only">Status: </span>Live</h3>',
1944
+ html=True,
1945
+ )
1946
+ # Should show the Draft status
1947
+ self.assertContains(
1948
+ response,
1949
+ '<h3 id="status-sidebar-draft" class="w-label-1 !w-mt-0 w-mb-1"><span class="w-sr-only">Status: </span>Draft</h3>',
1950
+ html=True,
1951
+ )
1952
+
1953
+ # Should not show the Unpublish action menu item
1954
+ unpublish_url = reverse(
1955
+ "wagtailsnippets_tests_draftstatecustomprimarykeymodel:unpublish",
1956
+ args=(quote(self.test_snippet.pk),),
1957
+ )
1958
+ self.assertNotContains(
1959
+ response,
1960
+ f'<a class="button" href="{unpublish_url}">',
1961
+ )
1962
+ self.assertNotContains(response, "Unpublish")
1963
+
1964
+ def test_get_after_publish(self):
1965
+ self.post(
1966
+ post_data={
1967
+ "text": "Draft-enabled Bar, Published",
1968
+ "action-publish": "action-publish",
1969
+ }
1970
+ )
1971
+ response = self.get()
1972
+
1973
+ self.assertEqual(response.status_code, 200)
1974
+ self.assertTemplateUsed(response, "wagtailsnippets/snippets/edit.html")
1975
+
1976
+ # Should show the Live status
1977
+ self.assertContains(
1978
+ response,
1979
+ '<h3 id="status-sidebar-live" class="w-label-1 !w-mt-0 w-mb-1"><span class="w-sr-only">Status: </span>Live</h3>',
1980
+ html=True,
1981
+ )
1982
+ # Should not show the Draft status
1983
+ self.assertNotContains(
1984
+ response,
1985
+ '<h3 id="status-sidebar-draft" class="w-label-1 !w-mt-0 w-mb-1"><span class="w-sr-only">Status: </span>Draft</h3>',
1986
+ html=True,
1987
+ )
1988
+
1989
+ # Should show the Unpublish action menu item
1990
+ unpublish_url = reverse(
1991
+ "wagtailsnippets_tests_draftstatecustomprimarykeymodel:unpublish",
1992
+ args=(quote(self.test_snippet.pk),),
1993
+ )
1994
+ self.assertContains(
1995
+ response,
1996
+ f'<a class="button" href="{unpublish_url}">',
1997
+ )
1998
+ self.assertContains(response, "Unpublish")
1999
+
2000
+ def test_get_after_publish_and_save_draft(self):
2001
+ self.post(
2002
+ post_data={
2003
+ "text": "Draft-enabled Bar, Published",
2004
+ "action-publish": "action-publish",
2005
+ }
2006
+ )
2007
+ self.post(post_data={"text": "Draft-enabled Bar, In Draft"})
2008
+ response = self.get()
2009
+ html = response.content.decode()
2010
+
2011
+ self.assertEqual(response.status_code, 200)
2012
+ self.assertTemplateUsed(response, "wagtailsnippets/snippets/edit.html")
2013
+
2014
+ # Should show the Live status
2015
+ self.assertContains(
2016
+ response,
2017
+ '<h3 id="status-sidebar-live" class="w-label-1 !w-mt-0 w-mb-1"><span class="w-sr-only">Status: </span>Live</h3>',
2018
+ html=True,
2019
+ )
2020
+ # Should show the Draft status
2021
+ self.assertContains(
2022
+ response,
2023
+ '<h3 id="status-sidebar-draft" class="w-label-1 !w-mt-0 w-mb-1"><span class="w-sr-only">Status: </span>Draft</h3>',
2024
+ html=True,
2025
+ )
2026
+
2027
+ # Should show the Unpublish action menu item
2028
+ unpublish_url = reverse(
2029
+ "wagtailsnippets_tests_draftstatecustomprimarykeymodel:unpublish",
2030
+ args=(quote(self.test_snippet.pk),),
2031
+ )
2032
+ self.assertContains(
2033
+ response,
2034
+ f'<a class="button" href="{unpublish_url}">',
2035
+ )
2036
+ self.assertContains(response, "Unpublish")
2037
+
2038
+ soup = self.get_soup(response.content)
2039
+ h2 = soup.select_one("#header-title")
2040
+ self.assertIsNotNone(h2)
2041
+ icon = h2.select_one("svg use")
2042
+ self.assertIsNotNone(icon)
2043
+ self.assertEqual(icon["href"], "#icon-snippet")
2044
+ self.assertEqual(h2.text.strip(), "Draft-enabled Bar, In Draft")
2045
+
2046
+ # Should use the latest draft content for the form
2047
+ self.assertTagInHTML(
2048
+ '<textarea name="text">Draft-enabled Bar, In Draft</textarea>',
2049
+ html,
2050
+ allow_extra_attrs=True,
2051
+ )
2052
+
2053
+ def test_edit_post_scheduled(self):
2054
+ self.test_snippet.save_revision().publish()
2055
+
2056
+ # put go_live_at and expire_at several days away from the current date, to avoid
2057
+ # false matches in content__ tests
2058
+ go_live_at = now() + datetime.timedelta(days=10)
2059
+ expire_at = now() + datetime.timedelta(days=20)
2060
+ response = self.post(
2061
+ post_data={
2062
+ "text": "Some content",
2063
+ "go_live_at": submittable_timestamp(go_live_at),
2064
+ "expire_at": submittable_timestamp(expire_at),
2065
+ }
2066
+ )
2067
+
2068
+ # Should be redirected to the edit page
2069
+ self.assertRedirects(
2070
+ response,
2071
+ reverse(
2072
+ "wagtailsnippets_tests_draftstatecustomprimarykeymodel:edit",
2073
+ args=[quote(self.test_snippet.pk)],
2074
+ ),
2075
+ )
2076
+
2077
+ self.test_snippet.refresh_from_db()
2078
+
2079
+ # The object will still be live
2080
+ self.assertTrue(self.test_snippet.live)
2081
+
2082
+ # A revision with approved_go_live_at should not exist
2083
+ self.assertFalse(
2084
+ Revision.objects.for_instance(self.test_snippet)
2085
+ .exclude(approved_go_live_at__isnull=True)
2086
+ .exists()
2087
+ )
2088
+
2089
+ # But a revision with go_live_at and expire_at in their content json *should* exist
2090
+ self.assertTrue(
2091
+ Revision.objects.for_instance(self.test_snippet)
2092
+ .filter(
2093
+ content__go_live_at__startswith=str(go_live_at.date()),
2094
+ )
2095
+ .exists()
2096
+ )
2097
+ self.assertTrue(
2098
+ Revision.objects.for_instance(self.test_snippet)
2099
+ .filter(
2100
+ content__expire_at__startswith=str(expire_at.date()),
2101
+ )
2102
+ .exists()
2103
+ )
2104
+
2105
+ # Get the edit page again
2106
+ response = self.get()
2107
+
2108
+ # Should show the draft go_live_at and expire_at under the "Once scheduled" label
2109
+ self.assertContains(
2110
+ response,
2111
+ '<div class="w-label-3 w-text-primary">Once scheduled:</div>',
2112
+ html=True,
2113
+ count=1,
2114
+ )
2115
+ self.assertContains(
2116
+ response,
2117
+ f'<span class="w-text-grey-600">Go-live:</span> {render_timestamp(go_live_at)}',
2118
+ html=True,
2119
+ count=1,
2120
+ )
2121
+ self.assertContains(
2122
+ response,
2123
+ f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(expire_at)}',
2124
+ html=True,
2125
+ count=1,
2126
+ )
2127
+ self.assertSchedulingDialogRendered(response)
2128
+
2129
+ def test_edit_scheduled_go_live_before_expiry(self):
2130
+ response = self.post(
2131
+ post_data={
2132
+ "text": "Some content",
2133
+ "go_live_at": submittable_timestamp(now() + datetime.timedelta(days=2)),
2134
+ "expire_at": submittable_timestamp(now() + datetime.timedelta(days=1)),
2135
+ }
2136
+ )
2137
+
2138
+ self.assertEqual(response.status_code, 200)
2139
+
2140
+ # Check that a form error was raised
2141
+ self.assertFormError(
2142
+ response.context["form"],
2143
+ "go_live_at",
2144
+ "Go live date/time must be before expiry date/time",
2145
+ )
2146
+ self.assertFormError(
2147
+ response.context["form"],
2148
+ "expire_at",
2149
+ "Go live date/time must be before expiry date/time",
2150
+ )
2151
+
2152
+ self.assertContains(
2153
+ response,
2154
+ '<div class="w-label-3 w-text-primary">Invalid schedule</div>',
2155
+ html=True,
2156
+ )
2157
+
2158
+ num_errors = 2
2159
+
2160
+ # Should show the correct number on the badge of the toggle button
2161
+ self.assertRegex(
2162
+ response.content.decode(),
2163
+ self.STATUS_TOGGLE_BADGE_REGEX % {"num_errors": num_errors},
2164
+ )
2165
+
2166
+ def test_edit_scheduled_expire_in_the_past(self):
2167
+ response = self.post(
2168
+ post_data={
2169
+ "text": "Some content",
2170
+ "expire_at": submittable_timestamp(now() + datetime.timedelta(days=-1)),
2171
+ }
2172
+ )
2173
+
2174
+ self.assertEqual(response.status_code, 200)
2175
+
2176
+ # Check that a form error was raised
2177
+ self.assertFormError(
2178
+ response.context["form"],
2179
+ "expire_at",
2180
+ "Expiry date/time must be in the future.",
2181
+ )
2182
+
2183
+ self.assertContains(
2184
+ response,
2185
+ '<div class="w-label-3 w-text-primary">Invalid schedule</div>',
2186
+ html=True,
2187
+ )
2188
+
2189
+ num_errors = 1
2190
+
2191
+ # Should show the correct number on the badge of the toggle button
2192
+ self.assertRegex(
2193
+ response.content.decode(),
2194
+ self.STATUS_TOGGLE_BADGE_REGEX % {"num_errors": num_errors},
2195
+ )
2196
+
2197
+ def test_edit_post_invalid_schedule_with_existing_draft_schedule(self):
2198
+ self.test_snippet.go_live_at = now() + datetime.timedelta(days=1)
2199
+ self.test_snippet.expire_at = now() + datetime.timedelta(days=2)
2200
+ latest_revision = self.test_snippet.save_revision()
2201
+
2202
+ go_live_at = now() + datetime.timedelta(days=10)
2203
+ expire_at = now() + datetime.timedelta(days=-20)
2204
+ response = self.post(
2205
+ post_data={
2206
+ "text": "Some edited content",
2207
+ "go_live_at": submittable_timestamp(go_live_at),
2208
+ "expire_at": submittable_timestamp(expire_at),
2209
+ }
2210
+ )
2211
+
2212
+ # Should render the edit page with errors instead of redirecting
2213
+ self.assertEqual(response.status_code, 200)
2214
+
2215
+ self.test_snippet.refresh_from_db()
2216
+
2217
+ # The snippet will not be live
2218
+ self.assertFalse(self.test_snippet.live)
2219
+
2220
+ # No new revision should have been created
2221
+ self.assertEqual(self.test_snippet.latest_revision_id, latest_revision.pk)
2222
+
2223
+ # Should not show the draft go_live_at and expire_at under the "Once scheduled" label
2224
+ self.assertNotContains(
2225
+ response,
2226
+ '<div class="w-label-3 w-text-primary">Once scheduled:</div>',
2227
+ html=True,
2228
+ )
2229
+ self.assertNotContains(
2230
+ response,
2231
+ '<span class="w-text-grey-600">Go-live:</span>',
2232
+ html=True,
2233
+ )
2234
+ self.assertNotContains(
2235
+ response,
2236
+ '<span class="w-text-grey-600">Expiry:</span>',
2237
+ html=True,
2238
+ )
2239
+
2240
+ # Should show the "Edit schedule" button
2241
+ html = response.content.decode()
2242
+ self.assertTagInHTML(
2243
+ '<button type="button" data-a11y-dialog-show="schedule-publishing-dialog">Edit schedule</button>',
2244
+ html,
2245
+ count=1,
2246
+ allow_extra_attrs=True,
2247
+ )
2248
+
2249
+ self.assertContains(
2250
+ response,
2251
+ '<div class="w-label-3 w-text-primary">Invalid schedule</div>',
2252
+ html=True,
2253
+ )
2254
+
2255
+ num_errors = 2
2256
+
2257
+ # Should show the correct number on the badge of the toggle button
2258
+ self.assertRegex(
2259
+ response.content.decode(),
2260
+ self.STATUS_TOGGLE_BADGE_REGEX % {"num_errors": num_errors},
2261
+ )
2262
+
2263
+ def test_first_published_at_editable(self):
2264
+ """Test that we can update the first_published_at via the edit form,
2265
+ for models that expose it."""
2266
+
2267
+ self.test_snippet.save_revision().publish()
2268
+ self.test_snippet.refresh_from_db()
2269
+
2270
+ initial_delta = self.test_snippet.first_published_at - now()
2271
+
2272
+ first_published_at = now() - datetime.timedelta(days=2)
2273
+
2274
+ self.post(
2275
+ post_data={
2276
+ "text": "I've been edited!",
2277
+ "action-publish": "action-publish",
2278
+ "first_published_at": submittable_timestamp(first_published_at),
2279
+ }
2280
+ )
2281
+
2282
+ self.test_snippet.refresh_from_db()
2283
+
2284
+ # first_published_at should have changed.
2285
+ new_delta = self.test_snippet.first_published_at - now()
2286
+ self.assertNotEqual(new_delta.days, initial_delta.days)
2287
+ # first_published_at should be 3 days ago.
2288
+ self.assertEqual(new_delta.days, -3)
2289
+
2290
+ def test_edit_post_publish_scheduled_unpublished(self):
2291
+ go_live_at = now() + datetime.timedelta(days=1)
2292
+ expire_at = now() + datetime.timedelta(days=2)
2293
+ response = self.post(
2294
+ post_data={
2295
+ "text": "Some content",
2296
+ "action-publish": "Publish",
2297
+ "go_live_at": submittable_timestamp(go_live_at),
2298
+ "expire_at": submittable_timestamp(expire_at),
2299
+ }
2300
+ )
2301
+
2302
+ # Should be redirected to the listing page
2303
+ self.assertRedirects(
2304
+ response,
2305
+ reverse("wagtailsnippets_tests_draftstatecustomprimarykeymodel:list"),
2306
+ )
2307
+
2308
+ self.test_snippet.refresh_from_db()
2309
+
2310
+ # The object should not be live
2311
+ self.assertFalse(self.test_snippet.live)
2312
+
2313
+ # Instead a revision with approved_go_live_at should now exist
2314
+ self.assertTrue(
2315
+ Revision.objects.for_instance(self.test_snippet)
2316
+ .exclude(approved_go_live_at__isnull=True)
2317
+ .exists()
2318
+ )
2319
+
2320
+ # The object SHOULD have the "has_unpublished_changes" flag set,
2321
+ # because the changes are not visible as a live object yet
2322
+ self.assertTrue(
2323
+ self.test_snippet.has_unpublished_changes,
2324
+ msg="An object scheduled for future publishing should have has_unpublished_changes=True",
2325
+ )
2326
+
2327
+ self.assertEqual(self.test_snippet.status_string, "scheduled")
2328
+
2329
+ response = self.get()
2330
+
2331
+ # Should show the go_live_at and expire_at without the "Once scheduled" label
2332
+ self.assertNotContains(
2333
+ response,
2334
+ '<div class="w-label-3 w-text-primary">Once scheduled:</div>',
2335
+ html=True,
2336
+ )
2337
+ self.assertContains(
2338
+ response,
2339
+ f'<span class="w-text-grey-600">Go-live:</span> {render_timestamp(go_live_at)}',
2340
+ html=True,
2341
+ count=1,
2342
+ )
2343
+ self.assertContains(
2344
+ response,
2345
+ f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(expire_at)}',
2346
+ html=True,
2347
+ count=1,
2348
+ )
2349
+ self.assertSchedulingDialogRendered(response)
2350
+
2351
+ def test_edit_post_publish_now_an_already_scheduled_unpublished(self):
2352
+ # First let's publish an object with a go_live_at in the future
2353
+ go_live_at = now() + datetime.timedelta(days=1)
2354
+ expire_at = now() + datetime.timedelta(days=2)
2355
+ response = self.post(
2356
+ post_data={
2357
+ "text": "Some content",
2358
+ "action-publish": "Publish",
2359
+ "go_live_at": submittable_timestamp(go_live_at),
2360
+ "expire_at": submittable_timestamp(expire_at),
2361
+ }
2362
+ )
2363
+
2364
+ # Should be redirected to the listing page
2365
+ self.assertRedirects(
2366
+ response,
2367
+ reverse("wagtailsnippets_tests_draftstatecustomprimarykeymodel:list"),
2368
+ )
2369
+
2370
+ self.test_snippet.refresh_from_db()
2371
+
2372
+ # The object should not be live
2373
+ self.assertFalse(self.test_snippet.live)
2374
+
2375
+ self.assertEqual(self.test_snippet.status_string, "scheduled")
2376
+
2377
+ # Instead a revision with approved_go_live_at should now exist
2378
+ self.assertTrue(
2379
+ Revision.objects.for_instance(self.test_snippet)
2380
+ .exclude(approved_go_live_at__isnull=True)
2381
+ .exists()
2382
+ )
2383
+
2384
+ # Now, let's edit it and publish it right now
2385
+ response = self.post(
2386
+ post_data={
2387
+ "text": "Some content",
2388
+ "action-publish": "Publish",
2389
+ "go_live_at": "",
2390
+ }
2391
+ )
2392
+
2393
+ # Should be redirected to the listing page
2394
+ self.assertRedirects(
2395
+ response,
2396
+ reverse("wagtailsnippets_tests_draftstatecustomprimarykeymodel:list"),
2397
+ )
2398
+
2399
+ self.test_snippet.refresh_from_db()
2400
+
2401
+ # The object should be live
2402
+ self.assertTrue(self.test_snippet.live)
2403
+
2404
+ # The revision with approved_go_live_at should no longer exist
2405
+ self.assertFalse(
2406
+ Revision.objects.for_instance(self.test_snippet)
2407
+ .exclude(approved_go_live_at__isnull=True)
2408
+ .exists()
2409
+ )
2410
+
2411
+ response = self.get()
2412
+ self.assertSchedulingDialogRendered(response)
2413
+
2414
+ def test_edit_post_publish_scheduled_published(self):
2415
+ self.test_snippet.save_revision().publish()
2416
+ self.test_snippet.refresh_from_db()
2417
+
2418
+ live_revision = self.test_snippet.live_revision
2419
+
2420
+ go_live_at = now() + datetime.timedelta(days=1)
2421
+ expire_at = now() + datetime.timedelta(days=2)
2422
+ response = self.post(
2423
+ post_data={
2424
+ "text": "I've been edited!",
2425
+ "action-publish": "Publish",
2426
+ "go_live_at": submittable_timestamp(go_live_at),
2427
+ "expire_at": submittable_timestamp(expire_at),
2428
+ }
2429
+ )
2430
+
2431
+ # Should be redirected to the listing page
2432
+ self.assertRedirects(
2433
+ response,
2434
+ reverse("wagtailsnippets_tests_draftstatecustomprimarykeymodel:list"),
2435
+ )
2436
+
2437
+ self.test_snippet = DraftStateCustomPrimaryKeyModel.objects.get(
2438
+ pk=self.test_snippet.pk
2439
+ )
2440
+
2441
+ # The object should still be live
2442
+ self.assertTrue(self.test_snippet.live)
2443
+
2444
+ self.assertEqual(self.test_snippet.status_string, "live + scheduled")
2445
+
2446
+ # A revision with approved_go_live_at should now exist
2447
+ self.assertTrue(
2448
+ Revision.objects.for_instance(self.test_snippet)
2449
+ .exclude(approved_go_live_at__isnull=True)
2450
+ .exists()
2451
+ )
2452
+
2453
+ # The object SHOULD have the "has_unpublished_changes" flag set,
2454
+ # because the changes are not visible as a live object yet
2455
+ self.assertTrue(
2456
+ self.test_snippet.has_unpublished_changes,
2457
+ msg="An object scheduled for future publishing should have has_unpublished_changes=True",
2458
+ )
2459
+
2460
+ self.assertNotEqual(
2461
+ self.test_snippet.get_latest_revision(),
2462
+ live_revision,
2463
+ "An object scheduled for future publishing should have a new revision, that is not the live revision",
2464
+ )
2465
+
2466
+ self.assertEqual(
2467
+ self.test_snippet.text,
2468
+ "Draft-enabled Foo",
2469
+ "A live object with a scheduled revision should still have the original content",
2470
+ )
2471
+
2472
+ response = self.get()
2473
+
2474
+ # Should show the go_live_at and expire_at without the "Once scheduled" label
2475
+ self.assertNotContains(
2476
+ response,
2477
+ '<div class="w-label-3 w-text-primary">Once scheduled:</div>',
2478
+ html=True,
2479
+ )
2480
+ self.assertContains(
2481
+ response,
2482
+ f'<span class="w-text-grey-600">Go-live:</span> {render_timestamp(go_live_at)}',
2483
+ html=True,
2484
+ count=1,
2485
+ )
2486
+ self.assertContains(
2487
+ response,
2488
+ f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(expire_at)}',
2489
+ html=True,
2490
+ count=1,
2491
+ )
2492
+ self.assertSchedulingDialogRendered(response)
2493
+
2494
+ def test_edit_post_publish_now_an_already_scheduled_published(self):
2495
+ self.test_snippet.save_revision().publish()
2496
+
2497
+ # First let's publish an object with a go_live_at in the future
2498
+ go_live_at = now() + datetime.timedelta(days=1)
2499
+ expire_at = now() + datetime.timedelta(days=2)
2500
+ response = self.post(
2501
+ post_data={
2502
+ "text": "Some content",
2503
+ "action-publish": "Publish",
2504
+ "go_live_at": submittable_timestamp(go_live_at),
2505
+ "expire_at": submittable_timestamp(expire_at),
2506
+ }
2507
+ )
2508
+
2509
+ # Should be redirected to the listing page
2510
+ self.assertRedirects(
2511
+ response,
2512
+ reverse("wagtailsnippets_tests_draftstatecustomprimarykeymodel:list"),
2513
+ )
2514
+
2515
+ self.test_snippet.refresh_from_db()
2516
+
2517
+ # The object should still be live
2518
+ self.assertTrue(self.test_snippet.live)
2519
+
2520
+ # A revision with approved_go_live_at should now exist
2521
+ self.assertTrue(
2522
+ Revision.objects.for_instance(self.test_snippet)
2523
+ .exclude(approved_go_live_at__isnull=True)
2524
+ .exists()
2525
+ )
2526
+
2527
+ self.assertEqual(
2528
+ self.test_snippet.text,
2529
+ "Draft-enabled Foo",
2530
+ "A live object with scheduled revisions should still have original content",
2531
+ )
2532
+
2533
+ # Now, let's edit it and publish it right now
2534
+ response = self.post(
2535
+ post_data={
2536
+ "text": "I've been updated!",
2537
+ "action-publish": "Publish",
2538
+ "go_live_at": "",
2539
+ }
2540
+ )
2541
+
2542
+ # Should be redirected to the listing page
2543
+ self.assertRedirects(
2544
+ response,
2545
+ reverse("wagtailsnippets_tests_draftstatecustomprimarykeymodel:list"),
2546
+ )
2547
+
2548
+ self.test_snippet.refresh_from_db()
2549
+
2550
+ # The object should be live
2551
+ self.assertTrue(self.test_snippet.live)
2552
+
2553
+ # The scheduled revision should no longer exist
2554
+ self.assertFalse(
2555
+ Revision.objects.for_instance(self.test_snippet)
2556
+ .exclude(approved_go_live_at__isnull=True)
2557
+ .exists()
2558
+ )
2559
+
2560
+ # The content should be updated
2561
+ self.assertEqual(self.test_snippet.text, "I've been updated!")
2562
+
2563
+ def test_edit_post_save_schedule_before_a_scheduled_expire(self):
2564
+ # First let's publish an object with *just* an expire_at in the future
2565
+ expire_at = now() + datetime.timedelta(days=20)
2566
+ response = self.post(
2567
+ post_data={
2568
+ "text": "Some content",
2569
+ "action-publish": "Publish",
2570
+ "expire_at": submittable_timestamp(expire_at),
2571
+ }
2572
+ )
2573
+
2574
+ # Should be redirected to the listing page
2575
+ self.assertRedirects(
2576
+ response,
2577
+ reverse("wagtailsnippets_tests_draftstatecustomprimarykeymodel:list"),
2578
+ )
2579
+
2580
+ self.test_snippet.refresh_from_db()
2581
+
2582
+ # The object should still be live
2583
+ self.assertTrue(self.test_snippet.live)
2584
+
2585
+ self.assertEqual(self.test_snippet.status_string, "live")
2586
+
2587
+ # The live object should have the expire_at field set
2588
+ self.assertEqual(
2589
+ self.test_snippet.expire_at,
2590
+ expire_at.replace(second=0, microsecond=0),
2591
+ )
2592
+
2593
+ # Now, let's save an object with a go_live_at in the future,
2594
+ # but before the existing expire_at
2595
+ go_live_at = now() + datetime.timedelta(days=10)
2596
+ new_expire_at = now() + datetime.timedelta(days=15)
2597
+ response = self.post(
2598
+ post_data={
2599
+ "text": "Some content",
2600
+ "go_live_at": submittable_timestamp(go_live_at),
2601
+ "expire_at": submittable_timestamp(new_expire_at),
2602
+ }
2603
+ )
2604
+
2605
+ # Should be redirected to the edit page
2606
+ self.assertRedirects(
2607
+ response,
2608
+ reverse(
2609
+ "wagtailsnippets_tests_draftstatecustomprimarykeymodel:edit",
2610
+ args=[quote(self.test_snippet.pk)],
2611
+ ),
2612
+ )
2613
+
2614
+ self.test_snippet.refresh_from_db()
2615
+
2616
+ # The object will still be live
2617
+ self.assertTrue(self.test_snippet.live)
2618
+
2619
+ # A revision with approved_go_live_at should not exist
2620
+ self.assertFalse(
2621
+ Revision.objects.for_instance(self.test_snippet)
2622
+ .exclude(approved_go_live_at__isnull=True)
2623
+ .exists()
2624
+ )
2625
+
2626
+ # But a revision with go_live_at and expire_at in their content json *should* exist
2627
+ self.assertTrue(
2628
+ Revision.objects.for_instance(self.test_snippet)
2629
+ .filter(content__go_live_at__startswith=str(go_live_at.date()))
2630
+ .exists()
2631
+ )
2632
+ self.assertTrue(
2633
+ Revision.objects.for_instance(self.test_snippet)
2634
+ .filter(content__expire_at__startswith=str(expire_at.date()))
2635
+ .exists()
2636
+ )
2637
+
2638
+ response = self.get()
2639
+
2640
+ # Should still show the active expire_at in the live object
2641
+ self.assertContains(
2642
+ response,
2643
+ f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(expire_at)}',
2644
+ html=True,
2645
+ count=1,
2646
+ )
2647
+
2648
+ # Should also show the draft go_live_at and expire_at under the "Once scheduled" label
2649
+ self.assertContains(
2650
+ response,
2651
+ '<div class="w-label-3 w-text-primary">Once scheduled:</div>',
2652
+ html=True,
2653
+ count=1,
2654
+ )
2655
+ self.assertContains(
2656
+ response,
2657
+ f'<span class="w-text-grey-600">Go-live:</span> {render_timestamp(go_live_at)}',
2658
+ html=True,
2659
+ count=1,
2660
+ )
2661
+ self.assertContains(
2662
+ response,
2663
+ f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(new_expire_at)}',
2664
+ html=True,
2665
+ count=1,
2666
+ )
2667
+ self.assertSchedulingDialogRendered(response)
2668
+
2669
+ def test_edit_post_publish_schedule_before_a_scheduled_expire(self):
2670
+ # First let's publish an object with *just* an expire_at in the future
2671
+ expire_at = now() + datetime.timedelta(days=20)
2672
+ response = self.post(
2673
+ post_data={
2674
+ "text": "Some content",
2675
+ "action-publish": "Publish",
2676
+ "expire_at": submittable_timestamp(expire_at),
2677
+ }
2678
+ )
2679
+
2680
+ # Should be redirected to the listing page
2681
+ self.assertRedirects(
2682
+ response,
2683
+ reverse("wagtailsnippets_tests_draftstatecustomprimarykeymodel:list"),
2684
+ )
2685
+
2686
+ self.test_snippet.refresh_from_db()
2687
+
2688
+ # The object should still be live
2689
+ self.assertTrue(self.test_snippet.live)
2690
+
2691
+ self.assertEqual(self.test_snippet.status_string, "live")
2692
+
2693
+ # The live object should have the expire_at field set
2694
+ self.assertEqual(
2695
+ self.test_snippet.expire_at,
2696
+ expire_at.replace(second=0, microsecond=0),
2697
+ )
2698
+
2699
+ # Now, let's publish an object with a go_live_at in the future,
2700
+ # but before the existing expire_at
2701
+ go_live_at = now() + datetime.timedelta(days=10)
2702
+ new_expire_at = now() + datetime.timedelta(days=15)
2703
+ response = self.post(
2704
+ post_data={
2705
+ "text": "Some content",
2706
+ "action-publish": "Publish",
2707
+ "go_live_at": submittable_timestamp(go_live_at),
2708
+ "expire_at": submittable_timestamp(new_expire_at),
2709
+ }
2710
+ )
2711
+
2712
+ # Should be redirected to the listing page
2713
+ self.assertRedirects(
2714
+ response,
2715
+ reverse("wagtailsnippets_tests_draftstatecustomprimarykeymodel:list"),
2716
+ )
2717
+
2718
+ self.test_snippet = DraftStateCustomPrimaryKeyModel.objects.get(
2719
+ pk=self.test_snippet.pk
2720
+ )
2721
+
2722
+ # The object should still be live
2723
+ self.assertTrue(self.test_snippet.live)
2724
+
2725
+ self.assertEqual(self.test_snippet.status_string, "live + scheduled")
2726
+
2727
+ # A revision with approved_go_live_at should now exist
2728
+ self.assertTrue(
2729
+ Revision.objects.for_instance(self.test_snippet)
2730
+ .exclude(approved_go_live_at__isnull=True)
2731
+ .exists()
2732
+ )
2733
+
2734
+ response = self.get()
2735
+
2736
+ # Should not show the active expire_at in the live object because the
2737
+ # scheduled revision is before the existing expire_at, which means it will
2738
+ # override the existing expire_at when it goes live
2739
+ self.assertNotContains(
2740
+ response,
2741
+ f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(expire_at)}',
2742
+ html=True,
2743
+ )
2744
+
2745
+ # Should show the go_live_at and expire_at without the "Once scheduled" label
2746
+ self.assertNotContains(
2747
+ response,
2748
+ '<div class="w-label-3 w-text-primary">Once scheduled:</div>',
2749
+ html=True,
2750
+ )
2751
+ self.assertContains(
2752
+ response,
2753
+ f'<span class="w-text-grey-600">Go-live:</span> {render_timestamp(go_live_at)}',
2754
+ html=True,
2755
+ count=1,
2756
+ )
2757
+ self.assertContains(
2758
+ response,
2759
+ f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(new_expire_at)}',
2760
+ html=True,
2761
+ count=1,
2762
+ )
2763
+ self.assertSchedulingDialogRendered(response)
2764
+
2765
+ def test_edit_post_publish_schedule_after_a_scheduled_expire(self):
2766
+ # First let's publish an object with *just* an expire_at in the future
2767
+ expire_at = now() + datetime.timedelta(days=20)
2768
+ response = self.post(
2769
+ post_data={
2770
+ "text": "Some content",
2771
+ "action-publish": "Publish",
2772
+ "expire_at": submittable_timestamp(expire_at),
2773
+ }
2774
+ )
2775
+
2776
+ # Should be redirected to the listing page
2777
+ self.assertRedirects(
2778
+ response,
2779
+ reverse("wagtailsnippets_tests_draftstatecustomprimarykeymodel:list"),
2780
+ )
2781
+
2782
+ self.test_snippet.refresh_from_db()
2783
+
2784
+ # The object should still be live
2785
+ self.assertTrue(self.test_snippet.live)
2786
+
2787
+ self.assertEqual(self.test_snippet.status_string, "live")
2788
+
2789
+ # The live object should have the expire_at field set
2790
+ self.assertEqual(
2791
+ self.test_snippet.expire_at,
2792
+ expire_at.replace(second=0, microsecond=0),
2793
+ )
2794
+
2795
+ # Now, let's publish an object with a go_live_at in the future,
2796
+ # but after the existing expire_at
2797
+ go_live_at = now() + datetime.timedelta(days=23)
2798
+ new_expire_at = now() + datetime.timedelta(days=25)
2799
+ response = self.post(
2800
+ post_data={
2801
+ "text": "Some content",
2802
+ "action-publish": "Publish",
2803
+ "go_live_at": submittable_timestamp(go_live_at),
2804
+ "expire_at": submittable_timestamp(new_expire_at),
2805
+ }
2806
+ )
2807
+
2808
+ # Should be redirected to the listing page
2809
+ self.assertRedirects(
2810
+ response,
2811
+ reverse("wagtailsnippets_tests_draftstatecustomprimarykeymodel:list"),
2812
+ )
2813
+
2814
+ self.test_snippet = DraftStateCustomPrimaryKeyModel.objects.get(
2815
+ pk=self.test_snippet.pk
2816
+ )
2817
+
2818
+ # The object should still be live
2819
+ self.assertTrue(self.test_snippet.live)
2820
+
2821
+ self.assertEqual(self.test_snippet.status_string, "live + scheduled")
2822
+
2823
+ # Instead a revision with approved_go_live_at should now exist
2824
+ self.assertTrue(
2825
+ Revision.objects.for_instance(self.test_snippet)
2826
+ .exclude(approved_go_live_at__isnull=True)
2827
+ .exists()
2828
+ )
2829
+
2830
+ response = self.get()
2831
+
2832
+ # Should still show the active expire_at in the live object because the
2833
+ # scheduled revision is after the existing expire_at, which means the
2834
+ # new expire_at won't take effect until the revision goes live.
2835
+ # This means the object will be:
2836
+ # unpublished (expired) -> published (scheduled) -> unpublished (expired again)
2837
+ self.assertContains(
2838
+ response,
2839
+ f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(expire_at)}',
2840
+ html=True,
2841
+ count=1,
2842
+ )
2843
+
2844
+ # Should show the go_live_at and expire_at without the "Once scheduled" label
2845
+ self.assertNotContains(
2846
+ response,
2847
+ '<div class="w-label-3 w-text-primary">Once scheduled:</div>',
2848
+ html=True,
2849
+ )
2850
+ self.assertContains(
2851
+ response,
2852
+ f'<span class="w-text-grey-600">Go-live:</span> {render_timestamp(go_live_at)}',
2853
+ html=True,
2854
+ count=1,
2855
+ )
2856
+ self.assertContains(
2857
+ response,
2858
+ f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(new_expire_at)}',
2859
+ html=True,
2860
+ count=1,
2861
+ )
2862
+ self.assertSchedulingDialogRendered(response)
2863
+
2864
+ def test_use_fallback_for_blank_string_representation(self):
2865
+ self.snippet = DraftStateModel.objects.create(text="", live=False)
2866
+
2867
+ response = self.client.get(
2868
+ reverse(
2869
+ "wagtailsnippets_tests_draftstatemodel:edit",
2870
+ args=[quote(self.snippet.pk)],
2871
+ ),
2872
+ )
2873
+
2874
+ title = f"DraftStateModel object ({self.snippet.pk})"
2875
+
2876
+ soup = self.get_soup(response.content)
2877
+ h2 = soup.select_one("#header-title")
2878
+ self.assertEqual(h2.text.strip(), title)
2879
+
2880
+ sublabel = soup.select_one(".w-breadcrumbs li:last-of-type")
2881
+ self.assertEqual(sublabel.get_text(strip=True), title)
2882
+
2883
+
2884
+ class TestScheduledForPublishLock(BaseTestSnippetEditView):
2885
+ def setUp(self):
2886
+ super().setUp()
2887
+ self.test_snippet = DraftStateModel.objects.create(
2888
+ text="Draft-enabled Foo", live=False
2889
+ )
2890
+ self.go_live_at = now() + datetime.timedelta(days=1)
2891
+ self.test_snippet.text = "I've been edited!"
2892
+ self.test_snippet.go_live_at = self.go_live_at
2893
+ self.latest_revision = self.test_snippet.save_revision()
2894
+ self.latest_revision.publish()
2895
+ self.test_snippet.refresh_from_db()
2896
+
2897
+ def test_edit_get_scheduled_for_publishing_with_publish_permission(self):
2898
+ self.user.is_superuser = False
2899
+
2900
+ edit_permission = Permission.objects.get(
2901
+ content_type__app_label="tests", codename="change_draftstatemodel"
2902
+ )
2903
+ publish_permission = Permission.objects.get(
2904
+ content_type__app_label="tests", codename="publish_draftstatemodel"
2905
+ )
2906
+ admin_permission = Permission.objects.get(
2907
+ content_type__app_label="wagtailadmin", codename="access_admin"
2908
+ )
2909
+
2910
+ self.user.user_permissions.add(
2911
+ edit_permission,
2912
+ publish_permission,
2913
+ admin_permission,
2914
+ )
2915
+ self.user.save()
2916
+
2917
+ response = self.get()
2918
+
2919
+ # Should show the go_live_at without the "Once scheduled" label
2920
+ self.assertNotContains(
2921
+ response,
2922
+ '<div class="w-label-3 w-text-primary">Once scheduled:</div>',
2923
+ html=True,
2924
+ )
2925
+
2926
+ self.assertContains(
2927
+ response,
2928
+ f'<span class="w-text-grey-600">Go-live:</span> {render_timestamp(self.go_live_at)}',
2929
+ html=True,
2930
+ count=1,
2931
+ )
2932
+
2933
+ # Should show the lock message
2934
+ self.assertContains(
2935
+ response,
2936
+ "Draft state model 'I&#x27;ve been edited!' is locked and has been scheduled to go live at",
2937
+ count=1,
2938
+ )
2939
+
2940
+ # Should show the lock information in the status side panel
2941
+ self.assertContains(response, "Locked by schedule")
2942
+ self.assertContains(
2943
+ response,
2944
+ '<div class="w-help-text">Currently locked and will go live on the scheduled date</div>',
2945
+ html=True,
2946
+ count=1,
2947
+ )
2948
+
2949
+ html = response.content.decode()
2950
+
2951
+ # Should not show the "Edit schedule" button
2952
+ self.assertTagInHTML(
2953
+ '<button type="button" data-a11y-dialog-show="schedule-publishing-dialog">Edit schedule</button>',
2954
+ html,
2955
+ count=0,
2956
+ allow_extra_attrs=True,
2957
+ )
2958
+
2959
+ # Should show button to cancel scheduled publishing
2960
+ unschedule_url = reverse(
2961
+ "wagtailsnippets_tests_draftstatemodel:revisions_unschedule",
2962
+ args=[self.test_snippet.pk, self.latest_revision.pk],
2963
+ )
2964
+ self.assertTagInHTML(
2965
+ f'<button data-action="w-action#post" data-controller="w-action" data-w-action-url-value="{unschedule_url}">Cancel scheduled publish</button>',
2966
+ html,
2967
+ count=1,
2968
+ allow_extra_attrs=True,
2969
+ )
2970
+
2971
+ def test_edit_get_scheduled_for_publishing_without_publish_permission(self):
2972
+ self.user.is_superuser = False
2973
+
2974
+ edit_permission = Permission.objects.get(
2975
+ content_type__app_label="tests", codename="change_draftstatemodel"
2976
+ )
2977
+ admin_permission = Permission.objects.get(
2978
+ content_type__app_label="wagtailadmin", codename="access_admin"
2979
+ )
2980
+
2981
+ self.user.user_permissions.add(edit_permission, admin_permission)
2982
+ self.user.save()
2983
+
2984
+ response = self.get()
2985
+
2986
+ # Should show the go_live_at without the "Once scheduled" label
2987
+ self.assertNotContains(
2988
+ response,
2989
+ '<div class="w-label-3 w-text-primary">Once scheduled:</div>',
2990
+ html=True,
2991
+ )
2992
+
2993
+ self.assertContains(
2994
+ response,
2995
+ f'<span class="w-text-grey-600">Go-live:</span> {render_timestamp(self.go_live_at)}',
2996
+ html=True,
2997
+ count=1,
2998
+ )
2999
+
3000
+ # Should show the lock message
3001
+ self.assertContains(
3002
+ response,
3003
+ "Draft state model 'I&#x27;ve been edited!' is locked and has been scheduled to go live at",
3004
+ count=1,
3005
+ )
3006
+
3007
+ # Should show the lock information in the status side panel
3008
+ self.assertContains(response, "Locked by schedule")
3009
+ self.assertContains(
3010
+ response,
3011
+ '<div class="w-help-text">Currently locked and will go live on the scheduled date</div>',
3012
+ html=True,
3013
+ count=1,
3014
+ )
3015
+
3016
+ html = response.content.decode()
3017
+
3018
+ # Should not show the "Edit schedule" button
3019
+ self.assertTagInHTML(
3020
+ '<button type="button" data-a11y-dialog-show="schedule-publishing-dialog">Edit schedule</button>',
3021
+ html,
3022
+ count=0,
3023
+ allow_extra_attrs=True,
3024
+ )
3025
+
3026
+ # Should not show button to cancel scheduled publishing
3027
+ unschedule_url = reverse(
3028
+ "wagtailsnippets_tests_draftstatemodel:revisions_unschedule",
3029
+ args=[self.test_snippet.pk, self.latest_revision.pk],
3030
+ )
3031
+ self.assertTagInHTML(
3032
+ f'<button data-action="w-action#post" data-controller="w-action" data-w-action-url-value="{unschedule_url}">Cancel scheduled publish</button>',
3033
+ html,
3034
+ count=0,
3035
+ allow_extra_attrs=True,
3036
+ )
3037
+
3038
+ def test_edit_post_scheduled_for_publishing(self):
3039
+ response = self.post(
3040
+ post_data={
3041
+ "text": "I'm edited while it's locked for scheduled publishing!",
3042
+ "go_live_at": submittable_timestamp(self.go_live_at),
3043
+ }
3044
+ )
3045
+
3046
+ self.test_snippet.refresh_from_db()
3047
+
3048
+ # Should not create a new revision,
3049
+ # so the latest revision's content should still be the same
3050
+ self.assertEqual(self.test_snippet.latest_revision, self.latest_revision)
3051
+ self.assertEqual(
3052
+ self.test_snippet.latest_revision.content["text"],
3053
+ "I've been edited!",
3054
+ )
3055
+
3056
+ # Should show a message explaining why the changes were not saved
3057
+ self.assertContains(
3058
+ response,
3059
+ "The draft state model could not be saved as it is locked",
3060
+ count=1,
3061
+ )
3062
+
3063
+ # Should not show the lock message, as we already have the error message
3064
+ self.assertNotContains(
3065
+ response,
3066
+ "Draft state model 'I&#x27;ve been edited!' is locked and has been scheduled to go live at",
3067
+ )
3068
+
3069
+ # Should show the lock information in the status side panel
3070
+ self.assertContains(response, "Locked by schedule")
3071
+ self.assertContains(
3072
+ response,
3073
+ '<div class="w-help-text">Currently locked and will go live on the scheduled date</div>',
3074
+ html=True,
3075
+ count=1,
3076
+ )
3077
+
3078
+ html = response.content.decode()
3079
+
3080
+ # Should not show the "Edit schedule" button
3081
+ self.assertTagInHTML(
3082
+ '<button type="button" data-a11y-dialog-show="schedule-publishing-dialog">Edit schedule</button>',
3083
+ html,
3084
+ count=0,
3085
+ allow_extra_attrs=True,
3086
+ )
3087
+
3088
+ # Should not show button to cancel scheduled publishing as the lock message isn't shown
3089
+ unschedule_url = reverse(
3090
+ "wagtailsnippets_tests_draftstatemodel:revisions_unschedule",
3091
+ args=[self.test_snippet.pk, self.latest_revision.pk],
3092
+ )
3093
+ self.assertTagInHTML(
3094
+ f'<button data-action="w-action#post" data-controller="w-action" data-w-action-url-value="{unschedule_url}">Cancel scheduled publish</button>',
3095
+ html,
3096
+ count=0,
3097
+ allow_extra_attrs=True,
3098
+ )
3099
+
3100
+
3101
+ class TestSnippetViewWithCustomPrimaryKey(WagtailTestUtils, TestCase):
3102
+ fixtures = ["test.json"]
3103
+
3104
+ def setUp(self):
3105
+ super().setUp()
3106
+ self.login()
3107
+ self.snippet_a = StandardSnippetWithCustomPrimaryKey.objects.create(
3108
+ snippet_id="snippet/01", text="Hello"
3109
+ )
3110
+ self.snippet_b = StandardSnippetWithCustomPrimaryKey.objects.create(
3111
+ snippet_id="abc_407269_1", text="Goodbye"
3112
+ )
3113
+
3114
+ def get(self, snippet, params=None):
3115
+ args = [quote(snippet.pk)]
3116
+ return self.client.get(
3117
+ reverse(snippet.snippet_viewset.get_url_name("edit"), args=args),
3118
+ params,
3119
+ )
3120
+
3121
+ def post(self, snippet, post_data=None):
3122
+ args = [quote(snippet.pk)]
3123
+ return self.client.post(
3124
+ reverse(snippet.snippet_viewset.get_url_name("edit"), args=args),
3125
+ post_data,
3126
+ )
3127
+
3128
+ def create(self, snippet, post_data=None, model=Advert):
3129
+ return self.client.post(
3130
+ reverse(snippet.snippet_viewset.get_url_name("add")),
3131
+ post_data,
3132
+ )
3133
+
3134
+ def test_show_edit_view(self):
3135
+ for snippet in [self.snippet_a, self.snippet_b]:
3136
+ with self.subTest(snippet=snippet):
3137
+ response = self.get(snippet)
3138
+ self.assertEqual(response.status_code, 200)
3139
+ self.assertTemplateUsed(response, "wagtailsnippets/snippets/edit.html")
3140
+
3141
+ def test_edit_invalid(self):
3142
+ response = self.post(self.snippet_a, post_data={"foo": "bar"})
3143
+ soup = self.get_soup(response.content)
3144
+ header_messages = soup.css.select(".messages[role='status'] ul > li")
3145
+
3146
+ # the top level message should indicate that the page could not be saved
3147
+ self.assertEqual(len(header_messages), 1)
3148
+ message = header_messages[0]
3149
+ self.assertIn(
3150
+ "The standard snippet with custom primary key could not be saved due to errors.",
3151
+ message.get_text(),
3152
+ )
3153
+
3154
+ # the top level message should provide a go to error button
3155
+ buttons = message.find_all("button")
3156
+ self.assertEqual(len(buttons), 1)
3157
+ self.assertEqual(buttons[0].attrs["data-controller"], "w-count w-focus")
3158
+ self.assertEqual(
3159
+ set(buttons[0].attrs["data-action"].split()),
3160
+ {"click->w-focus#focus", "wagtail:panel-init@document->w-count#count"},
3161
+ )
3162
+ self.assertIn("Go to the first error", buttons[0].get_text())
3163
+
3164
+ # the errors should appear against the fields with issues
3165
+ error_messages = soup.css.select(".error-message")
3166
+ self.assertEqual(len(error_messages), 2)
3167
+ error_message = error_messages[0]
3168
+ self.assertEqual(error_message.parent["id"], "panel-child-snippet_id-errors")
3169
+ self.assertIn("This field is required", error_message.get_text())
3170
+
3171
+ def test_edit(self):
3172
+ response = self.post(
3173
+ self.snippet_a,
3174
+ post_data={"text": "Edited snippet", "snippet_id": "snippet_id_edited"},
3175
+ )
3176
+ self.assertRedirects(
3177
+ response,
3178
+ reverse(
3179
+ "wagtailsnippets_snippetstests_standardsnippetwithcustomprimarykey:list"
3180
+ ),
3181
+ )
3182
+
3183
+ snippets = StandardSnippetWithCustomPrimaryKey.objects.all()
3184
+ self.assertEqual(snippets.count(), 3)
3185
+ # Saving with a new primary key creates a new instance
3186
+ self.assertTrue(snippets.filter(snippet_id="snippet_id_edited").exists())
3187
+ self.assertTrue(snippets.filter(snippet_id="snippet/01").exists())
3188
+
3189
+ def test_create(self):
3190
+ response = self.create(
3191
+ self.snippet_a,
3192
+ post_data={"text": "test snippet", "snippet_id": "snippet/02"},
3193
+ )
3194
+ self.assertRedirects(
3195
+ response,
3196
+ reverse(
3197
+ "wagtailsnippets_snippetstests_standardsnippetwithcustomprimarykey:list"
3198
+ ),
3199
+ )
3200
+
3201
+ snippets = StandardSnippetWithCustomPrimaryKey.objects.all()
3202
+ self.assertEqual(snippets.count(), 3)
3203
+ self.assertEqual(snippets.order_by("snippet_id").last().text, "test snippet")
3204
+
3205
+ def test_get_delete(self):
3206
+ for snippet in [self.snippet_a, self.snippet_b]:
3207
+ with self.subTest(snippet=snippet):
3208
+ response = self.client.get(
3209
+ reverse(
3210
+ "wagtailsnippets_snippetstests_standardsnippetwithcustomprimarykey:delete",
3211
+ args=[quote(snippet.pk)],
3212
+ )
3213
+ )
3214
+ self.assertEqual(response.status_code, 200)
3215
+ self.assertTemplateUsed(
3216
+ response, "wagtailadmin/generic/confirm_delete.html"
3217
+ )
3218
+
3219
+ def test_usage_link(self):
3220
+ for snippet in [self.snippet_a, self.snippet_b]:
3221
+ with self.subTest(snippet=snippet):
3222
+ response = self.client.get(
3223
+ reverse(
3224
+ "wagtailsnippets_snippetstests_standardsnippetwithcustomprimarykey:delete",
3225
+ args=[quote(snippet.pk)],
3226
+ )
3227
+ )
3228
+ self.assertEqual(response.status_code, 200)
3229
+ self.assertTemplateUsed(
3230
+ response, "wagtailadmin/generic/confirm_delete.html"
3231
+ )
3232
+ self.assertContains(
3233
+ response,
3234
+ "This standard snippet with custom primary key is referenced 0 times",
3235
+ )
3236
+ self.assertContains(
3237
+ response,
3238
+ reverse(
3239
+ "wagtailsnippets_snippetstests_standardsnippetwithcustomprimarykey:usage",
3240
+ args=[quote(snippet.pk)],
3241
+ )
3242
+ + "?describe_on_delete=1",
3243
+ )