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