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,1159 @@
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.core.files.uploadedfile import SimpleUploadedFile
7
+ from django.core.handlers.wsgi import WSGIRequest
8
+ from django.http import HttpRequest, HttpResponse, JsonResponse
9
+ from django.test import TestCase
10
+ from django.test.utils import override_settings
11
+ from django.urls import reverse
12
+ from django.utils.timezone import now
13
+ from freezegun import freeze_time
14
+ from taggit.models import Tag
15
+
16
+ from wagtail.models import Locale, ModelLogEntry, Revision
17
+ from wagtail.signals import published
18
+ from wagtail.snippets.action_menu import (
19
+ ActionMenuItem,
20
+ get_base_snippet_action_menu_items,
21
+ )
22
+ from wagtail.test.snippets.models import (
23
+ FileUploadSnippet,
24
+ )
25
+ from wagtail.test.testapp.models import (
26
+ Advert,
27
+ DraftStateModel,
28
+ RevisableModel,
29
+ )
30
+ from wagtail.test.utils import WagtailTestUtils
31
+ from wagtail.test.utils.timestamps import submittable_timestamp
32
+
33
+
34
+ class TestSnippetCreateView(WagtailTestUtils, TestCase):
35
+ def setUp(self):
36
+ self.user = self.login()
37
+
38
+ def get(self, params=None, model=Advert, headers=None):
39
+ return self.client.get(
40
+ reverse(model.snippet_viewset.get_url_name("add")), params, headers=headers
41
+ )
42
+
43
+ def post(self, post_data=None, model=Advert, headers=None):
44
+ return self.client.post(
45
+ reverse(model.snippet_viewset.get_url_name("add")),
46
+ post_data,
47
+ headers=headers,
48
+ )
49
+
50
+ def test_get_with_limited_permissions(self):
51
+ self.user.is_superuser = False
52
+ self.user.user_permissions.add(
53
+ Permission.objects.get(
54
+ content_type__app_label="wagtailadmin", codename="access_admin"
55
+ )
56
+ )
57
+ self.user.save()
58
+
59
+ response = self.get()
60
+ self.assertEqual(response.status_code, 302)
61
+
62
+ def test_simple(self):
63
+ response = self.get()
64
+ self.assertEqual(response.status_code, 200)
65
+ self.assertTemplateUsed(response, "wagtailsnippets/snippets/create.html")
66
+ self.assertNotContains(response, 'role="tablist"', html=True)
67
+
68
+ soup = self.get_soup(response.content)
69
+
70
+ # Should have the unsaved controller set up
71
+ editor_form = soup.select_one("#w-editor-form")
72
+ self.assertIsNotNone(editor_form)
73
+ self.assertIn("w-unsaved", editor_form.attrs.get("data-controller").split())
74
+ self.assertTrue(
75
+ {
76
+ "w-unsaved#submit",
77
+ "beforeunload@window->w-unsaved#confirm",
78
+ }.issubset(editor_form.attrs.get("data-action").split())
79
+ )
80
+ self.assertEqual(
81
+ editor_form.attrs.get("data-w-unsaved-confirmation-value"),
82
+ "true",
83
+ )
84
+ self.assertEqual(
85
+ editor_form.attrs.get("data-w-unsaved-force-value"),
86
+ "false",
87
+ )
88
+
89
+ def test_snippet_with_tabbed_interface(self):
90
+ response = self.client.get(
91
+ reverse("wagtailsnippets_tests_advertwithtabbedinterface:add")
92
+ )
93
+
94
+ self.assertEqual(response.status_code, 200)
95
+ self.assertTemplateUsed(response, "wagtailsnippets/snippets/create.html")
96
+ self.assertContains(response, 'role="tablist"')
97
+ self.assertContains(
98
+ response,
99
+ '<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">',
100
+ )
101
+ self.assertContains(
102
+ response,
103
+ '<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">',
104
+ )
105
+ self.assertContains(response, "Other panels help text")
106
+ self.assertContains(response, "Top-level help text")
107
+
108
+ def test_create_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.post(
118
+ post_data={"text": "test text", "url": "http://www.example.com/"}
119
+ )
120
+ self.assertEqual(response.status_code, 302)
121
+
122
+ def test_create_invalid(self):
123
+ response = self.post(post_data={"foo": "bar"})
124
+
125
+ soup = self.get_soup(response.content)
126
+
127
+ header_messages = soup.css.select(".messages[role='status'] ul > li")
128
+
129
+ # there should be one header message that indicates the issue and has a go to error button
130
+ self.assertEqual(len(header_messages), 1)
131
+ message = header_messages[0]
132
+ self.assertIn(
133
+ "The advert could not be created due to errors.", message.get_text()
134
+ )
135
+ buttons = message.find_all("button")
136
+ self.assertEqual(len(buttons), 1)
137
+ self.assertEqual(buttons[0].attrs["data-controller"], "w-count w-focus")
138
+ self.assertEqual(
139
+ set(buttons[0].attrs["data-action"].split()),
140
+ {"click->w-focus#focus", "wagtail:panel-init@document->w-count#count"},
141
+ )
142
+ self.assertIn("Go to the first error", buttons[0].get_text())
143
+
144
+ # field specific error should be shown
145
+ error_messages = soup.css.select(".error-message")
146
+ self.assertEqual(len(error_messages), 1)
147
+ error_message = error_messages[0]
148
+ self.assertEqual(error_message.parent["id"], "panel-child-text-errors")
149
+ self.assertIn("This field is required", error_message.get_text())
150
+
151
+ # Should have the unsaved controller set up
152
+ editor_form = soup.select_one("#w-editor-form")
153
+ self.assertIsNotNone(editor_form)
154
+ self.assertIn("w-unsaved", editor_form.attrs.get("data-controller").split())
155
+ self.assertTrue(
156
+ {
157
+ "w-unsaved#submit",
158
+ "beforeunload@window->w-unsaved#confirm",
159
+ }.issubset(editor_form.attrs.get("data-action").split())
160
+ )
161
+ self.assertEqual(
162
+ editor_form.attrs.get("data-w-unsaved-confirmation-value"),
163
+ "true",
164
+ )
165
+ self.assertEqual(
166
+ editor_form.attrs.get("data-w-unsaved-force-value"),
167
+ # The form is invalid, we want to force it to be "dirty" on initial load
168
+ "true",
169
+ )
170
+
171
+ def test_create_invalid_with_json_response(self):
172
+ response = self.post(
173
+ post_data={"foo": "bar"}, headers={"Accept": "application/json"}
174
+ )
175
+ self.assertEqual(response.status_code, 400)
176
+ self.assertEqual(response["Content-Type"], "application/json")
177
+ self.assertEqual(
178
+ response.json(),
179
+ {
180
+ "success": False,
181
+ "error_code": "validation_error",
182
+ "error_message": "There are validation errors, click save to highlight them.",
183
+ },
184
+ )
185
+
186
+ def test_create(self):
187
+ response = self.post(
188
+ post_data={"text": "test_advert", "url": "http://www.example.com/"}
189
+ )
190
+ self.assertRedirects(response, reverse("wagtailsnippets_tests_advert:list"))
191
+
192
+ snippets = Advert.objects.filter(text="test_advert")
193
+ self.assertEqual(snippets.count(), 1)
194
+ self.assertEqual(snippets.first().url, "http://www.example.com/")
195
+
196
+ def test_create_with_json_response(self):
197
+ response = self.post(
198
+ post_data={"text": "test_advert", "url": "http://www.example.com/"},
199
+ headers={"Accept": "application/json"},
200
+ )
201
+ self.assertEqual(response.status_code, 200)
202
+ self.assertEqual(response["Content-Type"], "application/json")
203
+
204
+ snippets = Advert.objects.filter(text="test_advert")
205
+ self.assertEqual(snippets.count(), 1)
206
+ snippet = snippets.first()
207
+ self.assertEqual(snippet.url, "http://www.example.com/")
208
+
209
+ response_json = response.json()
210
+ self.assertEqual(response_json["success"], True)
211
+ self.assertEqual(response_json["pk"], snippet.pk)
212
+ self.assertEqual(response_json["field_updates"], {})
213
+ self.assertEqual(
214
+ response_json["url"],
215
+ reverse(snippet.snippet_viewset.get_url_name("edit"), args=(snippet.pk,)),
216
+ )
217
+
218
+ def test_create_with_tags(self):
219
+ tags = ["hello", "world"]
220
+ response = self.post(
221
+ post_data={
222
+ "text": "test_advert",
223
+ "url": "http://example.com/",
224
+ "tags": ", ".join(tags),
225
+ }
226
+ )
227
+
228
+ self.assertRedirects(response, reverse("wagtailsnippets_tests_advert:list"))
229
+
230
+ snippet = Advert.objects.get(text="test_advert")
231
+
232
+ expected_tags = list(Tag.objects.order_by("name").filter(name__in=tags))
233
+ self.assertEqual(len(expected_tags), 2)
234
+ self.assertEqual(list(snippet.tags.order_by("name")), expected_tags)
235
+
236
+ def test_create_file_upload_multipart(self):
237
+ response = self.get(model=FileUploadSnippet)
238
+ self.assertContains(response, 'enctype="multipart/form-data"')
239
+
240
+ response = self.post(
241
+ model=FileUploadSnippet,
242
+ post_data={"file": SimpleUploadedFile("test.txt", b"Uploaded file")},
243
+ )
244
+ self.assertRedirects(
245
+ response,
246
+ reverse("wagtailsnippets_snippetstests_fileuploadsnippet:list"),
247
+ )
248
+ snippet = FileUploadSnippet.objects.get()
249
+ self.assertEqual(snippet.file.read(), b"Uploaded file")
250
+
251
+ def test_create_with_revision(self):
252
+ response = self.post(
253
+ model=RevisableModel, post_data={"text": "create_revisable"}
254
+ )
255
+ self.assertRedirects(
256
+ response, reverse("wagtailsnippets_tests_revisablemodel:list")
257
+ )
258
+
259
+ snippets = RevisableModel.objects.filter(text="create_revisable")
260
+ snippet = snippets.first()
261
+ self.assertEqual(snippets.count(), 1)
262
+
263
+ # The revision should be created
264
+ revisions = snippet.revisions
265
+ revision = revisions.first()
266
+ self.assertEqual(revisions.count(), 1)
267
+ self.assertEqual(revision.content["text"], "create_revisable")
268
+
269
+ # The log entry should have the revision attached
270
+ log_entries = ModelLogEntry.objects.for_instance(snippet).filter(
271
+ action="wagtail.create"
272
+ )
273
+ self.assertEqual(log_entries.count(), 1)
274
+ self.assertEqual(log_entries.first().revision, revision)
275
+
276
+ def test_before_create_snippet_hook_get(self):
277
+ def hook_func(request, model):
278
+ self.assertIsInstance(request, HttpRequest)
279
+ self.assertEqual(model, Advert)
280
+ return HttpResponse("Overridden!")
281
+
282
+ with self.register_hook("before_create_snippet", hook_func):
283
+ response = self.get()
284
+
285
+ self.assertEqual(response.status_code, 200)
286
+ self.assertEqual(response.content, b"Overridden!")
287
+
288
+ def test_before_create_snippet_hook_get_with_json_response(self):
289
+ def non_json_hook_func(request, model):
290
+ self.assertIsInstance(request, HttpRequest)
291
+ self.assertEqual(model, Advert)
292
+ return HttpResponse("Overridden!")
293
+
294
+ def json_hook_func(request, model):
295
+ self.assertIsInstance(request, HttpRequest)
296
+ self.assertEqual(model, Advert)
297
+ return JsonResponse({"status": "purple"})
298
+
299
+ with self.register_hook("before_create_snippet", non_json_hook_func):
300
+ response = self.get(headers={"Accept": "application/json"})
301
+ self.assertEqual(
302
+ response.json(),
303
+ {
304
+ "success": False,
305
+ "error_code": "blocked_by_hook",
306
+ "error_message": "Request to create advert was blocked by hook.",
307
+ },
308
+ )
309
+
310
+ with self.register_hook("before_create_snippet", json_hook_func):
311
+ response = self.get(headers={"Accept": "application/json"})
312
+ self.assertEqual(response.json(), {"status": "purple"})
313
+
314
+ def test_before_create_snippet_hook_post(self):
315
+ def hook_func(request, model):
316
+ self.assertIsInstance(request, HttpRequest)
317
+ self.assertEqual(model, Advert)
318
+ return HttpResponse("Overridden!")
319
+
320
+ with self.register_hook("before_create_snippet", hook_func):
321
+ post_data = {"text": "Hook test", "url": "http://www.example.com/"}
322
+ response = self.post(post_data=post_data)
323
+
324
+ self.assertEqual(response.status_code, 200)
325
+ self.assertEqual(response.content, b"Overridden!")
326
+
327
+ # Request intercepted before advert was created
328
+ self.assertFalse(Advert.objects.exists())
329
+
330
+ def test_before_create_snippet_hook_post_with_json_response(self):
331
+ def non_json_hook_func(request, model):
332
+ self.assertIsInstance(request, HttpRequest)
333
+ self.assertEqual(model, Advert)
334
+ return HttpResponse("Overridden!")
335
+
336
+ def json_hook_func(request, model):
337
+ self.assertIsInstance(request, HttpRequest)
338
+ self.assertEqual(model, Advert)
339
+ return JsonResponse({"status": "purple"})
340
+
341
+ with self.register_hook("before_create_snippet", non_json_hook_func):
342
+ post_data = {"text": "Hook test", "url": "http://www.example.com/"}
343
+ response = self.post(
344
+ post_data=post_data,
345
+ headers={"Accept": "application/json"},
346
+ )
347
+
348
+ self.assertEqual(response.status_code, 400)
349
+ self.assertEqual(
350
+ response.json(),
351
+ {
352
+ "success": False,
353
+ "error_code": "blocked_by_hook",
354
+ "error_message": "Request to create advert was blocked by hook.",
355
+ },
356
+ )
357
+
358
+ # Request intercepted before advert was created
359
+ self.assertFalse(Advert.objects.exists())
360
+
361
+ with self.register_hook("before_create_snippet", json_hook_func):
362
+ post_data = {"text": "Hook test", "url": "http://www.example.com/"}
363
+ response = self.post(
364
+ post_data=post_data,
365
+ headers={"Accept": "application/json"},
366
+ )
367
+
368
+ self.assertEqual(response.status_code, 200)
369
+ self.assertEqual(response.json(), {"status": "purple"})
370
+
371
+ # Request intercepted before advert was created
372
+ self.assertFalse(Advert.objects.exists())
373
+
374
+ def test_after_create_snippet_hook(self):
375
+ def hook_func(request, instance):
376
+ self.assertIsInstance(request, HttpRequest)
377
+ self.assertEqual(instance.text, "Hook test")
378
+ self.assertEqual(instance.url, "http://www.example.com/")
379
+ return HttpResponse("Overridden!")
380
+
381
+ with self.register_hook("after_create_snippet", hook_func):
382
+ post_data = {"text": "Hook test", "url": "http://www.example.com/"}
383
+ response = self.post(post_data=post_data)
384
+
385
+ self.assertEqual(response.status_code, 200)
386
+ self.assertEqual(response.content, b"Overridden!")
387
+
388
+ # Request intercepted after advert was created
389
+ self.assertTrue(Advert.objects.exists())
390
+
391
+ def test_after_create_snippet_hook_post_with_json_response(self):
392
+ def non_json_hook_func(request, instance):
393
+ self.assertIsInstance(request, HttpRequest)
394
+ self.assertEqual(instance.text, "Hook test")
395
+ self.assertEqual(instance.url, "http://www.example.com/")
396
+ return HttpResponse("Overridden!")
397
+
398
+ def json_hook_func(request, instance):
399
+ self.assertIsInstance(request, HttpRequest)
400
+ self.assertEqual(instance.text, "Another hook test")
401
+ self.assertEqual(instance.url, "http://www.example.com/")
402
+ return JsonResponse({"status": "purple"})
403
+
404
+ with self.register_hook("after_create_snippet", non_json_hook_func):
405
+ post_data = {"text": "Hook test", "url": "http://www.example.com/"}
406
+ response = self.post(
407
+ post_data=post_data,
408
+ headers={"Accept": "application/json"},
409
+ )
410
+
411
+ self.assertEqual(response.status_code, 200)
412
+ # hook response is ignored, since it's not a JSON response
413
+ self.assertEqual(response.json()["success"], True)
414
+
415
+ # Request intercepted after advert was created
416
+ self.assertTrue(Advert.objects.filter(text="Hook test").exists())
417
+
418
+ with self.register_hook("after_create_snippet", json_hook_func):
419
+ post_data = {"text": "Another hook test", "url": "http://www.example.com/"}
420
+ response = self.post(
421
+ post_data=post_data,
422
+ headers={"Accept": "application/json"},
423
+ )
424
+
425
+ self.assertEqual(response.status_code, 200)
426
+ # hook response is used, since it's a JSON response
427
+ self.assertEqual(response.json(), {"status": "purple"})
428
+
429
+ # Request intercepted after advert was created
430
+ self.assertTrue(Advert.objects.filter(text="Another hook test").exists())
431
+
432
+ def test_register_snippet_action_menu_item(self):
433
+ class TestSnippetActionMenuItem(ActionMenuItem):
434
+ label = "Test"
435
+ name = "test"
436
+ icon_name = "check"
437
+ classname = "custom-class"
438
+
439
+ def is_shown(self, context):
440
+ return True
441
+
442
+ def hook_func(model):
443
+ return TestSnippetActionMenuItem(order=0)
444
+
445
+ with self.register_hook("register_snippet_action_menu_item", hook_func):
446
+ get_base_snippet_action_menu_items.cache_clear()
447
+
448
+ response = self.get()
449
+
450
+ get_base_snippet_action_menu_items.cache_clear()
451
+
452
+ self.assertContains(
453
+ response,
454
+ '<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>',
455
+ html=True,
456
+ )
457
+
458
+ def test_register_snippet_action_menu_item_as_none(self):
459
+ def hook_func(model):
460
+ return None
461
+
462
+ with self.register_hook("register_snippet_action_menu_item", hook_func):
463
+ get_base_snippet_action_menu_items.cache_clear()
464
+
465
+ response = self.get()
466
+
467
+ get_base_snippet_action_menu_items.cache_clear()
468
+ self.assertEqual(response.status_code, 200)
469
+
470
+ def test_construct_snippet_action_menu(self):
471
+ class TestSnippetActionMenuItem(ActionMenuItem):
472
+ label = "Test"
473
+ name = "test"
474
+ icon_name = "check"
475
+ classname = "custom-class"
476
+
477
+ def is_shown(self, context):
478
+ return True
479
+
480
+ class Media:
481
+ js = ["js/some-default-item.js"]
482
+ css = {"all": ["css/some-default-item.css"]}
483
+
484
+ def hook_func(menu_items, request, context):
485
+ self.assertIsInstance(menu_items, list)
486
+ self.assertIsInstance(request, WSGIRequest)
487
+ self.assertEqual(context["view"], "create")
488
+ self.assertEqual(context["model"], Advert)
489
+
490
+ # Replace save menu item
491
+ menu_items[:] = [TestSnippetActionMenuItem(order=0)]
492
+
493
+ with self.register_hook("construct_snippet_action_menu", hook_func):
494
+ response = self.get()
495
+
496
+ soup = self.get_soup(response.content)
497
+ custom_action = soup.select_one("form button[name='test']")
498
+ self.assertIsNotNone(custom_action)
499
+
500
+ # We're replacing the save button, so it should not be in a dropdown
501
+ # as it's the main action
502
+ dropdown_parent = custom_action.find_parent(attrs={"class": "w-dropdown"})
503
+ self.assertIsNone(dropdown_parent)
504
+
505
+ self.assertEqual(custom_action.text.strip(), "Test")
506
+ self.assertEqual(custom_action.attrs.get("class"), ["button", "custom-class"])
507
+ icon = custom_action.select_one("svg use[href='#icon-check']")
508
+ self.assertIsNotNone(icon)
509
+
510
+ # Should contain media files
511
+ js = soup.select_one("script[src='/static/js/some-default-item.js']")
512
+ self.assertIsNotNone(js)
513
+ css = soup.select_one("link[href='/static/css/some-default-item.css']")
514
+ self.assertIsNotNone(css)
515
+
516
+ save_item = soup.select_one("form button[name='action-save']")
517
+ self.assertIsNone(save_item)
518
+
519
+ def test_create_shows_status_side_panel_skeleton(self):
520
+ self.user.first_name = "Chrismansyah"
521
+ self.user.last_name = "Rahadi"
522
+ self.user.save()
523
+ response = self.get(model=RevisableModel)
524
+ soup = self.get_soup(response.content)
525
+ panel = soup.select_one('[data-side-panel="status"]')
526
+ self.assertIsNotNone(panel)
527
+
528
+ def assert_panel_section(label_id, label_text, description):
529
+ section = panel.select_one(f'[aria-labelledby="{label_id}"]')
530
+ self.assertIsNotNone(section)
531
+ label = section.select_one(f"#{label_id}")
532
+ self.assertIsNotNone(label)
533
+ self.assertEqual(label.get_text(separator="\n", strip=True), label_text)
534
+ self.assertEqual(
535
+ section.get_text(separator="\n", strip=True),
536
+ f"{label_text}\n{description}",
537
+ )
538
+
539
+ assert_panel_section(
540
+ "status-sidebar-live",
541
+ "Live",
542
+ "To be created by Chrismansyah Rahadi",
543
+ )
544
+
545
+ usage_section = panel.select("section")[-1]
546
+ self.assertIsNotNone(usage_section)
547
+ self.assertEqual(
548
+ usage_section.get_text(separator="\n", strip=True),
549
+ "Usage\nUsed 0 times",
550
+ )
551
+
552
+
553
+ @override_settings(WAGTAIL_I18N_ENABLED=True)
554
+ class TestLocaleSelectorOnCreate(WagtailTestUtils, TestCase):
555
+ fixtures = ["test.json"]
556
+
557
+ def setUp(self):
558
+ self.fr_locale = Locale.objects.create(language_code="fr")
559
+ self.user = self.login()
560
+
561
+ def test_locale_selector(self):
562
+ response = self.client.get(
563
+ reverse("wagtailsnippets_snippetstests_translatablesnippet:add")
564
+ )
565
+
566
+ self.assertContains(response, "Switch locales")
567
+
568
+ switch_to_french_url = (
569
+ reverse("wagtailsnippets_snippetstests_translatablesnippet:add")
570
+ + "?locale=fr"
571
+ )
572
+ self.assertContains(
573
+ response,
574
+ f'<a href="{switch_to_french_url}" lang="fr">',
575
+ )
576
+
577
+ def test_locale_selector_with_existing_locale(self):
578
+ response = self.client.get(
579
+ reverse("wagtailsnippets_snippetstests_translatablesnippet:add")
580
+ + "?locale=fr"
581
+ )
582
+
583
+ self.assertContains(response, "Switch locales")
584
+
585
+ switch_to_english_url = (
586
+ reverse("wagtailsnippets_snippetstests_translatablesnippet:add")
587
+ + "?locale=en"
588
+ )
589
+ self.assertContains(
590
+ response,
591
+ f'<a href="{switch_to_english_url}" lang="en">',
592
+ )
593
+
594
+ @override_settings(WAGTAIL_I18N_ENABLED=False)
595
+ def test_locale_selector_not_present_when_i18n_disabled(self):
596
+ response = self.client.get(
597
+ reverse("wagtailsnippets_snippetstests_translatablesnippet:add")
598
+ )
599
+
600
+ self.assertNotContains(response, "Switch locales")
601
+
602
+ switch_to_french_url = (
603
+ reverse("wagtailsnippets_snippetstests_translatablesnippet:add")
604
+ + "?locale=fr"
605
+ )
606
+ self.assertNotContains(
607
+ response,
608
+ f'<a href="{switch_to_french_url}" lang="fr">',
609
+ )
610
+
611
+ def test_locale_selector_not_present_on_non_translatable_snippet(self):
612
+ response = self.client.get(reverse("wagtailsnippets_tests_advert:add"))
613
+
614
+ self.assertNotContains(response, "Switch locales")
615
+
616
+ switch_to_french_url = (
617
+ reverse("wagtailsnippets_snippetstests_translatablesnippet:add")
618
+ + "?locale=fr"
619
+ )
620
+ self.assertNotContains(
621
+ response,
622
+ f'<a href="{switch_to_french_url}" lang="fr">',
623
+ )
624
+
625
+
626
+ class TestCreateDraftStateSnippet(WagtailTestUtils, TestCase):
627
+ STATUS_TOGGLE_BADGE_REGEX = (
628
+ r'data-side-panel-toggle="status"[^<]+<svg[^<]+<use[^<]+</use[^<]+</svg[^<]+'
629
+ r"<div data-side-panel-toggle-counter[^>]+w-bg-critical-200[^>]+>\s*%(num_errors)s\s*</div>"
630
+ )
631
+
632
+ def setUp(self):
633
+ self.user = self.login()
634
+
635
+ def get(self):
636
+ return self.client.get(reverse("wagtailsnippets_tests_draftstatemodel:add"))
637
+
638
+ def post(self, post_data=None):
639
+ return self.client.post(
640
+ reverse("wagtailsnippets_tests_draftstatemodel:add"),
641
+ post_data,
642
+ )
643
+
644
+ def test_get(self):
645
+ add_url = reverse("wagtailsnippets_tests_draftstatemodel:add")
646
+ response = self.get()
647
+
648
+ self.assertEqual(response.status_code, 200)
649
+ self.assertTemplateUsed(response, "wagtailsnippets/snippets/create.html")
650
+
651
+ # The save button should be labelled "Save draft"
652
+ self.assertContains(response, "Save draft")
653
+ # The publish button should exist
654
+ self.assertContains(response, "Publish")
655
+ # The publish button should have name="action-publish"
656
+ self.assertContains(
657
+ response,
658
+ '<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',
659
+ )
660
+ # The status side panel should be rendered so that the
661
+ # publishing schedule can be configured
662
+ self.assertContains(
663
+ response,
664
+ '<div class="form-side__panel" data-side-panel="status" hidden>',
665
+ )
666
+
667
+ # The status side panel should show "No publishing schedule set" info
668
+ self.assertContains(response, "No publishing schedule set")
669
+
670
+ # Should show the "Set schedule" button
671
+ html = response.content.decode()
672
+ self.assertTagInHTML(
673
+ '<button type="button" data-a11y-dialog-show="schedule-publishing-dialog">Set schedule</button>',
674
+ html,
675
+ count=1,
676
+ allow_extra_attrs=True,
677
+ )
678
+ # Should show the dialog template pointing to the [data-edit-form] selector as the root
679
+ soup = self.get_soup(html)
680
+ dialog = soup.select_one(
681
+ """
682
+ template[data-controller="w-teleport"][data-w-teleport-target-value="[data-edit-form]"]
683
+ #schedule-publishing-dialog
684
+ """
685
+ )
686
+ self.assertIsNotNone(dialog)
687
+ # Should render the main form with data-edit-form attribute
688
+ self.assertTagInHTML(
689
+ f'<form action="{add_url}" method="POST" data-edit-form>',
690
+ html,
691
+ count=1,
692
+ allow_extra_attrs=True,
693
+ )
694
+ self.assertTagInHTML(
695
+ '<div id="schedule-publishing-dialog" class="w-dialog publishing" data-controller="w-dialog">',
696
+ html,
697
+ count=1,
698
+ allow_extra_attrs=True,
699
+ )
700
+
701
+ # Should show the correct subtitle in the dialog
702
+ self.assertContains(
703
+ response, "Choose when this draft state model should go live and/or expire"
704
+ )
705
+
706
+ # Should not show the Unpublish action menu item
707
+ unpublish_url = "/admin/snippets/tests/draftstatemodel/unpublish/"
708
+ self.assertNotContains(response, unpublish_url)
709
+ self.assertNotContains(response, "Unpublish")
710
+
711
+ def test_save_draft(self):
712
+ response = self.post(post_data={"text": "Draft-enabled Foo"})
713
+ snippet = DraftStateModel.objects.get(text="Draft-enabled Foo")
714
+
715
+ self.assertRedirects(
716
+ response,
717
+ reverse("wagtailsnippets_tests_draftstatemodel:edit", args=[snippet.pk]),
718
+ )
719
+
720
+ # The instance should be created
721
+ self.assertEqual(snippet.text, "Draft-enabled Foo")
722
+
723
+ # The instance should be a draft
724
+ self.assertFalse(snippet.live)
725
+ self.assertTrue(snippet.has_unpublished_changes)
726
+ self.assertIsNone(snippet.first_published_at)
727
+ self.assertIsNone(snippet.last_published_at)
728
+ self.assertIsNone(snippet.live_revision)
729
+
730
+ # A revision should be created and set as latest_revision
731
+ self.assertIsNotNone(snippet.latest_revision)
732
+
733
+ # The revision content should contain the data
734
+ self.assertEqual(snippet.latest_revision.content["text"], "Draft-enabled Foo")
735
+
736
+ # A log entry should be created
737
+ log_entry = ModelLogEntry.objects.for_instance(snippet).get(
738
+ action="wagtail.create"
739
+ )
740
+ self.assertEqual(log_entry.revision, snippet.latest_revision)
741
+ self.assertEqual(log_entry.label, "Draft-enabled Foo")
742
+
743
+ def test_create_skips_validation_when_saving_draft(self):
744
+ response = self.post(post_data={"text": ""})
745
+ snippet = DraftStateModel.objects.get(text="")
746
+
747
+ self.assertRedirects(
748
+ response,
749
+ reverse(
750
+ "wagtailsnippets_tests_draftstatemodel:edit", args=[quote(snippet.pk)]
751
+ ),
752
+ )
753
+
754
+ self.assertFalse(snippet.live)
755
+
756
+ # A log entry should be created (with a fallback label)
757
+ log_entry = ModelLogEntry.objects.for_instance(snippet).get(
758
+ action="wagtail.create"
759
+ )
760
+ self.assertEqual(log_entry.revision, snippet.latest_revision)
761
+ self.assertEqual(log_entry.label, f"DraftStateModel object ({snippet.pk})")
762
+
763
+ def test_required_asterisk_on_reshowing_form(self):
764
+ """
765
+ If a form is reshown due to a validation error elsewhere, fields whose validation
766
+ was deferred should still show the required asterisk.
767
+ """
768
+ response = self.client.post(
769
+ reverse("some_namespace:add"),
770
+ {"text": "", "country_code": "UK", "some_number": "meef"},
771
+ )
772
+
773
+ self.assertEqual(response.status_code, 200)
774
+
775
+ # The empty text should not cause a validation error, but the invalid number should
776
+ self.assertNotContains(response, "This field is required.")
777
+ self.assertContains(response, "Enter a whole number.", count=1)
778
+
779
+ soup = self.get_soup(response.content)
780
+ self.assertTrue(soup.select_one('label[for="id_text"] > span.w-required-mark'))
781
+
782
+ def test_create_will_not_publish_invalid_snippet(self):
783
+ response = self.post(
784
+ post_data={"text": "", "action-publish": "Publish"},
785
+ )
786
+ self.assertEqual(response.status_code, 200)
787
+ self.assertContains(
788
+ response, "The draft state model could not be created due to errors."
789
+ )
790
+
791
+ snippets = DraftStateModel.objects.filter(text="")
792
+ self.assertEqual(snippets.count(), 0)
793
+
794
+ def test_publish(self):
795
+ # Connect a mock signal handler to published signal
796
+ mock_handler = mock.MagicMock()
797
+ published.connect(mock_handler)
798
+
799
+ try:
800
+ timestamp = now()
801
+ with freeze_time(timestamp):
802
+ response = self.post(
803
+ post_data={
804
+ "text": "Draft-enabled Foo, Published",
805
+ "action-publish": "action-publish",
806
+ }
807
+ )
808
+ snippet = DraftStateModel.objects.get(text="Draft-enabled Foo, Published")
809
+
810
+ self.assertRedirects(
811
+ response, reverse("wagtailsnippets_tests_draftstatemodel:list")
812
+ )
813
+
814
+ # The instance should be created
815
+ self.assertEqual(snippet.text, "Draft-enabled Foo, Published")
816
+
817
+ # The instance should be live
818
+ self.assertTrue(snippet.live)
819
+ self.assertFalse(snippet.has_unpublished_changes)
820
+ self.assertEqual(snippet.first_published_at, timestamp)
821
+ self.assertEqual(snippet.last_published_at, timestamp)
822
+
823
+ # A revision should be created and set as both latest_revision and live_revision
824
+ self.assertIsNotNone(snippet.live_revision)
825
+ self.assertEqual(snippet.live_revision, snippet.latest_revision)
826
+
827
+ # The revision content should contain the new data
828
+ self.assertEqual(
829
+ snippet.live_revision.content["text"],
830
+ "Draft-enabled Foo, Published",
831
+ )
832
+
833
+ # Check that the published signal was fired
834
+ self.assertEqual(mock_handler.call_count, 1)
835
+ mock_call = mock_handler.mock_calls[0][2]
836
+
837
+ self.assertEqual(mock_call["sender"], DraftStateModel)
838
+ self.assertEqual(mock_call["instance"], snippet)
839
+ self.assertIsInstance(mock_call["instance"], DraftStateModel)
840
+ finally:
841
+ published.disconnect(mock_handler)
842
+
843
+ def test_publish_bad_permissions(self):
844
+ # Only add create and edit permission
845
+ self.user.is_superuser = False
846
+ add_permission = Permission.objects.get(
847
+ content_type__app_label="tests",
848
+ codename="add_draftstatemodel",
849
+ )
850
+ edit_permission = Permission.objects.get(
851
+ content_type__app_label="tests",
852
+ codename="change_draftstatemodel",
853
+ )
854
+ admin_permission = Permission.objects.get(
855
+ content_type__app_label="wagtailadmin",
856
+ codename="access_admin",
857
+ )
858
+ self.user.user_permissions.add(
859
+ add_permission,
860
+ edit_permission,
861
+ admin_permission,
862
+ )
863
+ self.user.save()
864
+
865
+ # Connect a mock signal handler to published signal
866
+ mock_handler = mock.MagicMock()
867
+ published.connect(mock_handler)
868
+
869
+ try:
870
+ response = self.post(
871
+ post_data={
872
+ "text": "Draft-enabled Foo",
873
+ "action-publish": "action-publish",
874
+ }
875
+ )
876
+ snippet = DraftStateModel.objects.get(text="Draft-enabled Foo")
877
+
878
+ # Should be taken to the edit page
879
+ self.assertRedirects(
880
+ response,
881
+ reverse(
882
+ "wagtailsnippets_tests_draftstatemodel:edit",
883
+ args=[snippet.pk],
884
+ ),
885
+ )
886
+
887
+ # The instance should still be created
888
+ self.assertEqual(snippet.text, "Draft-enabled Foo")
889
+
890
+ # The instance should not be live
891
+ self.assertFalse(snippet.live)
892
+ self.assertTrue(snippet.has_unpublished_changes)
893
+
894
+ # A revision should be created and set as latest_revision, but not live_revision
895
+ self.assertIsNotNone(snippet.latest_revision)
896
+ self.assertIsNone(snippet.live_revision)
897
+
898
+ # The revision content should contain the data
899
+ self.assertEqual(
900
+ snippet.latest_revision.content["text"],
901
+ "Draft-enabled Foo",
902
+ )
903
+
904
+ # Check that the published signal was not fired
905
+ self.assertEqual(mock_handler.call_count, 0)
906
+ finally:
907
+ published.disconnect(mock_handler)
908
+
909
+ def test_publish_with_publish_permission(self):
910
+ # Use create and publish permissions instead of relying on superuser flag
911
+ self.user.is_superuser = False
912
+ add_permission = Permission.objects.get(
913
+ content_type__app_label="tests",
914
+ codename="add_draftstatemodel",
915
+ )
916
+ publish_permission = Permission.objects.get(
917
+ content_type__app_label="tests",
918
+ codename="publish_draftstatemodel",
919
+ )
920
+ admin_permission = Permission.objects.get(
921
+ content_type__app_label="wagtailadmin",
922
+ codename="access_admin",
923
+ )
924
+ self.user.user_permissions.add(
925
+ add_permission,
926
+ publish_permission,
927
+ admin_permission,
928
+ )
929
+ self.user.save()
930
+
931
+ # Connect a mock signal handler to published signal
932
+ mock_handler = mock.MagicMock()
933
+ published.connect(mock_handler)
934
+
935
+ try:
936
+ timestamp = now()
937
+ with freeze_time(timestamp):
938
+ response = self.post(
939
+ post_data={
940
+ "text": "Draft-enabled Foo, Published",
941
+ "action-publish": "action-publish",
942
+ }
943
+ )
944
+ snippet = DraftStateModel.objects.get(text="Draft-enabled Foo, Published")
945
+
946
+ self.assertRedirects(
947
+ response, reverse("wagtailsnippets_tests_draftstatemodel:list")
948
+ )
949
+
950
+ # The instance should be created
951
+ self.assertEqual(snippet.text, "Draft-enabled Foo, Published")
952
+
953
+ # The instance should be live
954
+ self.assertTrue(snippet.live)
955
+ self.assertFalse(snippet.has_unpublished_changes)
956
+ self.assertEqual(snippet.first_published_at, timestamp)
957
+ self.assertEqual(snippet.last_published_at, timestamp)
958
+
959
+ # A revision should be created and set as both latest_revision and live_revision
960
+ self.assertIsNotNone(snippet.live_revision)
961
+ self.assertEqual(snippet.live_revision, snippet.latest_revision)
962
+
963
+ # The revision content should contain the new data
964
+ self.assertEqual(
965
+ snippet.live_revision.content["text"],
966
+ "Draft-enabled Foo, Published",
967
+ )
968
+
969
+ # Check that the published signal was fired
970
+ self.assertEqual(mock_handler.call_count, 1)
971
+ mock_call = mock_handler.mock_calls[0][2]
972
+
973
+ self.assertEqual(mock_call["sender"], DraftStateModel)
974
+ self.assertEqual(mock_call["instance"], snippet)
975
+ self.assertIsInstance(mock_call["instance"], DraftStateModel)
976
+ finally:
977
+ published.disconnect(mock_handler)
978
+
979
+ def test_create_scheduled(self):
980
+ go_live_at = now() + datetime.timedelta(days=1)
981
+ expire_at = now() + datetime.timedelta(days=2)
982
+ response = self.post(
983
+ post_data={
984
+ "text": "Some content",
985
+ "go_live_at": submittable_timestamp(go_live_at),
986
+ "expire_at": submittable_timestamp(expire_at),
987
+ }
988
+ )
989
+
990
+ snippet = DraftStateModel.objects.get(text="Some content")
991
+
992
+ # Should be redirected to the edit page
993
+ self.assertRedirects(
994
+ response,
995
+ reverse("wagtailsnippets_tests_draftstatemodel:edit", args=[snippet.pk]),
996
+ )
997
+
998
+ # Should be saved as draft with the scheduled publishing dates
999
+ self.assertEqual(snippet.go_live_at.date(), go_live_at.date())
1000
+ self.assertEqual(snippet.expire_at.date(), expire_at.date())
1001
+ self.assertIs(snippet.expired, False)
1002
+ self.assertEqual(snippet.status_string, "draft")
1003
+
1004
+ # No revisions with approved_go_live_at
1005
+ self.assertFalse(
1006
+ Revision.objects.for_instance(snippet)
1007
+ .exclude(approved_go_live_at__isnull=True)
1008
+ .exists()
1009
+ )
1010
+
1011
+ def test_create_scheduled_go_live_before_expiry(self):
1012
+ response = self.post(
1013
+ post_data={
1014
+ "text": "Some content",
1015
+ "go_live_at": submittable_timestamp(now() + datetime.timedelta(days=2)),
1016
+ "expire_at": submittable_timestamp(now() + datetime.timedelta(days=1)),
1017
+ }
1018
+ )
1019
+
1020
+ self.assertEqual(response.status_code, 200)
1021
+
1022
+ # Check that a form error was raised
1023
+ self.assertFormError(
1024
+ response.context["form"],
1025
+ "go_live_at",
1026
+ "Go live date/time must be before expiry date/time",
1027
+ )
1028
+ self.assertFormError(
1029
+ response.context["form"],
1030
+ "expire_at",
1031
+ "Go live date/time must be before expiry date/time",
1032
+ )
1033
+
1034
+ self.assertContains(
1035
+ response,
1036
+ '<div class="w-label-3 w-text-primary">Invalid schedule</div>',
1037
+ html=True,
1038
+ )
1039
+
1040
+ num_errors = 2
1041
+
1042
+ # Should show the correct number on the badge of the toggle button
1043
+ self.assertRegex(
1044
+ response.content.decode(),
1045
+ self.STATUS_TOGGLE_BADGE_REGEX % {"num_errors": num_errors},
1046
+ )
1047
+
1048
+ def test_create_scheduled_expire_in_the_past(self):
1049
+ response = self.post(
1050
+ post_data={
1051
+ "text": "Some content",
1052
+ "expire_at": submittable_timestamp(now() + datetime.timedelta(days=-1)),
1053
+ }
1054
+ )
1055
+
1056
+ self.assertEqual(response.status_code, 200)
1057
+
1058
+ # Check that a form error was raised
1059
+ self.assertFormError(
1060
+ response.context["form"],
1061
+ "expire_at",
1062
+ "Expiry date/time must be in the future.",
1063
+ )
1064
+
1065
+ self.assertContains(
1066
+ response,
1067
+ '<div class="w-label-3 w-text-primary">Invalid schedule</div>',
1068
+ html=True,
1069
+ )
1070
+
1071
+ num_errors = 1
1072
+
1073
+ # Should show the correct number on the badge of the toggle button
1074
+ self.assertRegex(
1075
+ response.content.decode(),
1076
+ self.STATUS_TOGGLE_BADGE_REGEX % {"num_errors": num_errors},
1077
+ )
1078
+
1079
+ def test_create_post_publish_scheduled(self):
1080
+ go_live_at = now() + datetime.timedelta(days=1)
1081
+ expire_at = now() + datetime.timedelta(days=2)
1082
+ response = self.post(
1083
+ post_data={
1084
+ "text": "Some content",
1085
+ "action-publish": "Publish",
1086
+ "go_live_at": submittable_timestamp(go_live_at),
1087
+ "expire_at": submittable_timestamp(expire_at),
1088
+ }
1089
+ )
1090
+
1091
+ # Should be redirected to the listing page
1092
+ self.assertRedirects(
1093
+ response, reverse("wagtailsnippets_tests_draftstatemodel:list")
1094
+ )
1095
+
1096
+ # Find the object and check it
1097
+ snippet = DraftStateModel.objects.get(text="Some content")
1098
+ self.assertEqual(snippet.go_live_at.date(), go_live_at.date())
1099
+ self.assertEqual(snippet.expire_at.date(), expire_at.date())
1100
+ self.assertIs(snippet.expired, False)
1101
+
1102
+ # A revision with approved_go_live_at should exist now
1103
+ self.assertTrue(
1104
+ Revision.objects.for_instance(snippet)
1105
+ .exclude(approved_go_live_at__isnull=True)
1106
+ .exists()
1107
+ )
1108
+ # But snippet won't be live
1109
+ self.assertFalse(snippet.live)
1110
+ self.assertFalse(snippet.first_published_at)
1111
+ self.assertEqual(snippet.status_string, "scheduled")
1112
+
1113
+ def test_create_shows_status_side_panel_skeleton(self):
1114
+ self.user.first_name = "Chrismansyah"
1115
+ self.user.last_name = "Rahadi"
1116
+ self.user.save()
1117
+ response = self.get()
1118
+ soup = self.get_soup(response.content)
1119
+ panel = soup.select_one('[data-side-panel="status"]')
1120
+ self.assertIsNotNone(panel)
1121
+
1122
+ def assert_panel_section(label_id, label_text, description):
1123
+ section = panel.select_one(f'[aria-labelledby="{label_id}"]')
1124
+ self.assertIsNotNone(section)
1125
+ label = section.select_one(f"#{label_id}")
1126
+ self.assertIsNotNone(label)
1127
+ self.assertEqual(label.get_text(separator="\n", strip=True), label_text)
1128
+ self.assertEqual(
1129
+ section.get_text(separator="\n", strip=True),
1130
+ f"{label_text}\n{description}",
1131
+ )
1132
+
1133
+ assert_panel_section(
1134
+ "status-sidebar-draft",
1135
+ "Draft",
1136
+ "To be created by Chrismansyah Rahadi",
1137
+ )
1138
+
1139
+ usage_section = panel.select("section")[-1]
1140
+ self.assertIsNotNone(usage_section)
1141
+ self.assertEqual(
1142
+ usage_section.get_text(separator="\n", strip=True),
1143
+ "Usage\nUsed 0 times",
1144
+ )
1145
+
1146
+
1147
+ class TestInlinePanelMedia(WagtailTestUtils, TestCase):
1148
+ """
1149
+ Test that form media required by InlinePanels is correctly pulled in to the edit page
1150
+ """
1151
+
1152
+ def test_inline_panel_media(self):
1153
+ self.login()
1154
+
1155
+ response = self.client.get(
1156
+ reverse("wagtailsnippets_snippetstests_multisectionrichtextsnippet:add")
1157
+ )
1158
+ self.assertEqual(response.status_code, 200)
1159
+ self.assertContains(response, "wagtailadmin/js/draftail.js")