wagtail 6.1.3__py3-none-any.whl → 6.2rc1__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 (257) hide show
  1. wagtail/__init__.py +1 -1
  2. wagtail/actions/copy_for_translation.py +15 -1
  3. wagtail/admin/checks.py +20 -30
  4. wagtail/admin/forms/pages.py +10 -0
  5. wagtail/admin/icons.py +43 -0
  6. wagtail/admin/locale/en/LC_MESSAGES/django.po +405 -295
  7. wagtail/admin/locale/en/LC_MESSAGES/djangojs.po +21 -3
  8. wagtail/admin/locale/sl/LC_MESSAGES/django.mo +0 -0
  9. wagtail/admin/locale/sl/LC_MESSAGES/django.po +30 -0
  10. wagtail/admin/menu.py +2 -2
  11. wagtail/admin/migrations/0004_editingsession.py +57 -0
  12. wagtail/admin/migrations/0005_editingsession_is_editing.py +18 -0
  13. wagtail/admin/models.py +36 -3
  14. wagtail/admin/rich_text/editors/draftail/__init__.py +2 -20
  15. wagtail/admin/static/wagtailadmin/css/core.css +1 -1
  16. wagtail/admin/static/wagtailadmin/js/bulk-actions.js +1 -1
  17. wagtail/admin/static/wagtailadmin/js/chooser-modal.js +1 -1
  18. wagtail/admin/static/wagtailadmin/js/chooser-widget-telepath.js +1 -1
  19. wagtail/admin/static/wagtailadmin/js/chooser-widget.js +1 -1
  20. wagtail/admin/static/wagtailadmin/js/comments.js +1 -1
  21. wagtail/admin/static/wagtailadmin/js/core.js +1 -1
  22. wagtail/admin/static/wagtailadmin/js/date-time-chooser.js +1 -1
  23. wagtail/admin/static/wagtailadmin/js/draftail.js +1 -1
  24. wagtail/admin/static/wagtailadmin/js/expanding-formset.js +1 -1
  25. wagtail/admin/static/wagtailadmin/js/filtered-select.js +1 -1
  26. wagtail/admin/static/wagtailadmin/js/modal-workflow.js +1 -1
  27. wagtail/admin/static/wagtailadmin/js/page-chooser-modal.js +1 -1
  28. wagtail/admin/static/wagtailadmin/js/page-chooser-telepath.js +1 -1
  29. wagtail/admin/static/wagtailadmin/js/page-chooser.js +1 -1
  30. wagtail/admin/static/wagtailadmin/js/preview-panel.js +2 -1
  31. wagtail/admin/static/wagtailadmin/js/preview-panel.js.LICENSE.txt +11 -0
  32. wagtail/admin/static/wagtailadmin/js/privacy-switch.js +1 -1
  33. wagtail/admin/static/wagtailadmin/js/sidebar.js +1 -1
  34. wagtail/admin/static/wagtailadmin/js/task-chooser-modal.js +1 -1
  35. wagtail/admin/static/wagtailadmin/js/task-chooser.js +1 -1
  36. wagtail/admin/static/wagtailadmin/js/telepath/blocks.js +1 -1
  37. wagtail/admin/static/wagtailadmin/js/telepath/widgets.js +1 -1
  38. wagtail/admin/static/wagtailadmin/js/userbar.js +2 -1
  39. wagtail/admin/static/wagtailadmin/js/userbar.js.LICENSE.txt +11 -0
  40. wagtail/admin/static/wagtailadmin/js/vendor.js +1 -1
  41. wagtail/admin/static/wagtailadmin/js/vendor.js.LICENSE.txt +0 -12
  42. wagtail/admin/static/wagtailadmin/js/wagtailadmin.js +1 -1
  43. wagtail/admin/static/wagtailadmin/js/workflow-action.js +1 -1
  44. wagtail/admin/templates/wagtailadmin/collection_privacy/ancestor_privacy.html +2 -6
  45. wagtail/admin/templates/wagtailadmin/generic/index_results.html +1 -17
  46. wagtail/admin/templates/wagtailadmin/generic/listing_results.html +20 -1
  47. wagtail/admin/templates/wagtailadmin/home/workflow_objects_to_moderate.html +2 -11
  48. wagtail/admin/templates/wagtailadmin/page_privacy/ancestor_privacy.html +2 -6
  49. wagtail/admin/templates/wagtailadmin/page_privacy/no_privacy.html +2 -0
  50. wagtail/admin/templates/wagtailadmin/pages/_editor_js.html +0 -1
  51. wagtail/admin/templates/wagtailadmin/pages/action_menu/menu.html +1 -1
  52. wagtail/admin/templates/wagtailadmin/reports/aging_pages_results.html +54 -0
  53. wagtail/admin/templates/wagtailadmin/reports/base_page_report.html +1 -17
  54. wagtail/admin/templates/wagtailadmin/reports/base_page_report_results.html +10 -0
  55. wagtail/admin/templates/wagtailadmin/reports/base_report.html +1 -40
  56. wagtail/admin/templates/wagtailadmin/reports/base_report_results.html +1 -0
  57. wagtail/admin/templates/wagtailadmin/reports/listing/_list_page_report.html +21 -27
  58. wagtail/admin/templates/wagtailadmin/reports/listing/_list_page_types_usage.html +48 -54
  59. wagtail/admin/templates/wagtailadmin/reports/{locked_pages.html → locked_pages_results.html} +3 -3
  60. wagtail/admin/templates/wagtailadmin/reports/page_types_usage_results.html +10 -0
  61. wagtail/admin/templates/wagtailadmin/reports/site_history_results.html +53 -0
  62. wagtail/admin/templates/wagtailadmin/reports/workflow_results.html +74 -0
  63. wagtail/admin/templates/wagtailadmin/reports/workflow_tasks_results.html +56 -0
  64. wagtail/admin/templates/wagtailadmin/shared/_workflow_init.html +8 -44
  65. wagtail/admin/templates/wagtailadmin/shared/avatar.html +11 -1
  66. wagtail/admin/templates/wagtailadmin/shared/dialog/dialog.html +5 -4
  67. wagtail/admin/templates/wagtailadmin/shared/dropdown/dropdown_button.html +2 -1
  68. wagtail/admin/templates/wagtailadmin/shared/editing_sessions/list.html +132 -0
  69. wagtail/admin/templates/wagtailadmin/shared/editing_sessions/module.html +44 -0
  70. wagtail/admin/templates/wagtailadmin/shared/headers/slim_header.html +7 -1
  71. wagtail/admin/templates/wagtailadmin/shared/page_status_tag_new.html +1 -1
  72. wagtail/admin/templates/wagtailadmin/shared/side_panels/checks.html +32 -16
  73. wagtail/admin/templates/wagtailadmin/skeleton.html +1 -1
  74. wagtail/admin/templates/wagtailadmin/userbar/item_accessibility.html +9 -11
  75. wagtail/admin/templatetags/wagtailadmin_tags.py +13 -2
  76. wagtail/admin/tests/formats/en/__init__.py +0 -0
  77. wagtail/admin/tests/formats/en/formats.py +1 -0
  78. wagtail/admin/tests/pages/test_create_page.py +47 -0
  79. wagtail/admin/tests/pages/test_edit_page.py +10 -8
  80. wagtail/admin/tests/pages/test_parent_page_chooser_view.py +45 -1
  81. wagtail/admin/tests/test_checks.py +53 -3
  82. wagtail/admin/tests/test_collections_views.py +62 -1
  83. wagtail/admin/tests/test_edit_handlers.py +37 -0
  84. wagtail/admin/tests/test_editing_sessions.py +1336 -0
  85. wagtail/admin/tests/test_icon_sprite.py +12 -21
  86. wagtail/admin/tests/test_page_chooser.py +309 -7
  87. wagtail/admin/tests/test_privacy.py +82 -0
  88. wagtail/admin/tests/test_reports_views.py +464 -70
  89. wagtail/admin/tests/test_userbar.py +93 -6
  90. wagtail/admin/tests/test_workflows.py +223 -33
  91. wagtail/admin/tests/viewsets/test_model_viewset.py +151 -2
  92. wagtail/admin/ui/editing_sessions.py +57 -0
  93. wagtail/admin/urls/__init__.py +9 -15
  94. wagtail/admin/urls/editing_sessions.py +17 -0
  95. wagtail/admin/urls/reports.py +33 -1
  96. wagtail/admin/userbar.py +77 -20
  97. wagtail/admin/views/chooser.py +49 -22
  98. wagtail/admin/views/collections.py +0 -11
  99. wagtail/admin/views/editing_sessions.py +193 -0
  100. wagtail/admin/views/generic/__init__.py +1 -0
  101. wagtail/admin/views/generic/history.py +9 -3
  102. wagtail/admin/views/generic/mixins.py +44 -3
  103. wagtail/admin/views/generic/models.py +46 -72
  104. wagtail/admin/views/generic/permissions.py +20 -10
  105. wagtail/admin/views/home.py +2 -31
  106. wagtail/admin/views/page_privacy.py +20 -5
  107. wagtail/admin/views/pages/choose_parent.py +62 -0
  108. wagtail/admin/views/pages/edit.py +28 -0
  109. wagtail/admin/views/reports/aging_pages.py +6 -10
  110. wagtail/admin/views/reports/audit_logging.py +13 -42
  111. wagtail/admin/views/reports/base.py +31 -4
  112. wagtail/admin/views/reports/locked_pages.py +5 -8
  113. wagtail/admin/views/reports/page_types_usage.py +6 -10
  114. wagtail/admin/views/reports/workflows.py +36 -12
  115. wagtail/admin/viewsets/base.py +8 -3
  116. wagtail/admin/viewsets/chooser.py +1 -1
  117. wagtail/admin/viewsets/model.py +26 -1
  118. wagtail/admin/wagtail_hooks.py +2 -1
  119. wagtail/api/v2/filters.py +6 -0
  120. wagtail/api/v2/tests/test_documents.py +1 -1
  121. wagtail/api/v2/tests/test_images.py +1 -1
  122. wagtail/api/v2/tests/test_pages.py +11 -1
  123. wagtail/api/v2/utils.py +2 -2
  124. wagtail/blocks/base.py +35 -12
  125. wagtail/blocks/definition_lookup.py +85 -0
  126. wagtail/blocks/list_block.py +12 -0
  127. wagtail/blocks/migrations/migrate_operation.py +2 -0
  128. wagtail/blocks/stream_block.py +19 -0
  129. wagtail/blocks/struct_block.py +19 -0
  130. wagtail/contrib/forms/locale/en/LC_MESSAGES/django.po +1 -1
  131. wagtail/contrib/frontend_cache/backends/__init__.py +5 -0
  132. wagtail/contrib/frontend_cache/backends/azure.py +179 -0
  133. wagtail/contrib/frontend_cache/backends/base.py +28 -0
  134. wagtail/contrib/frontend_cache/backends/cloudflare.py +114 -0
  135. wagtail/contrib/frontend_cache/backends/cloudfront.py +99 -0
  136. wagtail/contrib/frontend_cache/backends/http.py +64 -0
  137. wagtail/contrib/frontend_cache/tests.py +59 -17
  138. wagtail/contrib/frontend_cache/utils.py +26 -8
  139. wagtail/contrib/redirects/filters.py +15 -1
  140. wagtail/contrib/redirects/locale/en/LC_MESSAGES/django.po +37 -72
  141. wagtail/contrib/redirects/models.py +6 -5
  142. wagtail/contrib/redirects/templates/wagtailredirects/edit.html +1 -38
  143. wagtail/contrib/redirects/tests/test_redirects.py +141 -1
  144. wagtail/contrib/redirects/urls.py +1 -2
  145. wagtail/contrib/redirects/views.py +39 -80
  146. wagtail/contrib/routable_page/models.py +6 -4
  147. wagtail/contrib/routable_page/tests.py +11 -0
  148. wagtail/contrib/search_promotions/locale/en/LC_MESSAGES/django.po +1 -1
  149. wagtail/contrib/settings/locale/en/LC_MESSAGES/django.po +4 -4
  150. wagtail/contrib/simple_translation/locale/en/LC_MESSAGES/django.po +5 -1
  151. wagtail/contrib/simple_translation/models.py +2 -1
  152. wagtail/contrib/styleguide/locale/en/LC_MESSAGES/django.po +7 -7
  153. wagtail/contrib/table_block/locale/en/LC_MESSAGES/django.po +1 -1
  154. wagtail/contrib/table_block/static/table_block/js/table.js +1 -1
  155. wagtail/contrib/typed_table_block/blocks.py +19 -0
  156. wagtail/contrib/typed_table_block/locale/en/LC_MESSAGES/django.po +10 -10
  157. wagtail/contrib/typed_table_block/static/typed_table_block/js/typed_table_block.js +1 -1
  158. wagtail/contrib/typed_table_block/tests.py +38 -0
  159. wagtail/coreutils.py +1 -1
  160. wagtail/documents/__init__.py +1 -1
  161. wagtail/documents/locale/en/LC_MESSAGES/django.po +8 -8
  162. wagtail/documents/locale/sl/LC_MESSAGES/django.mo +0 -0
  163. wagtail/documents/locale/sl/LC_MESSAGES/django.po +20 -0
  164. wagtail/documents/models.py +5 -1
  165. wagtail/documents/static/wagtaildocs/js/document-chooser-modal.js +1 -1
  166. wagtail/documents/static/wagtaildocs/js/document-chooser-telepath.js +1 -1
  167. wagtail/documents/static/wagtaildocs/js/document-chooser.js +1 -1
  168. wagtail/documents/tests/test_models.py +5 -1
  169. wagtail/embeds/apps.py +2 -0
  170. wagtail/embeds/embeds.py +12 -14
  171. wagtail/embeds/finders/__init__.py +2 -0
  172. wagtail/embeds/finders/facebook.py +17 -33
  173. wagtail/embeds/finders/instagram.py +19 -16
  174. wagtail/embeds/locale/en/LC_MESSAGES/django.po +1 -1
  175. wagtail/embeds/signal_handlers.py +13 -0
  176. wagtail/embeds/tests/test_embeds.py +7 -7
  177. wagtail/fields.py +58 -14
  178. wagtail/images/__init__.py +1 -1
  179. wagtail/images/locale/en/LC_MESSAGES/django.po +34 -34
  180. wagtail/images/locale/sl/LC_MESSAGES/django.mo +0 -0
  181. wagtail/images/locale/sl/LC_MESSAGES/django.po +20 -0
  182. wagtail/images/models.py +2 -0
  183. wagtail/images/static/wagtailimages/js/image-chooser-modal.js +1 -1
  184. wagtail/images/static/wagtailimages/js/image-chooser-telepath.js +1 -1
  185. wagtail/images/static/wagtailimages/js/image-chooser.js +1 -1
  186. wagtail/images/templates/wagtailimages/images/edit.html +4 -4
  187. wagtail/images/tests/test_admin_views.py +26 -2
  188. wagtail/images/views/chooser.py +6 -1
  189. wagtail/locale/en/LC_MESSAGES/django.po +84 -80
  190. wagtail/locales/locale/en/LC_MESSAGES/django.po +2 -2
  191. wagtail/locales/tests.py +16 -0
  192. wagtail/locales/wagtail_hooks.py +0 -9
  193. wagtail/migrations/0094_alter_page_locale.py +19 -0
  194. wagtail/models/__init__.py +11 -5
  195. wagtail/models/i18n.py +6 -1
  196. wagtail/project_template/requirements.txt +1 -1
  197. wagtail/search/locale/en/LC_MESSAGES/django.po +1 -1
  198. wagtail/signals.py +4 -0
  199. wagtail/sites/locale/en/LC_MESSAGES/django.po +2 -2
  200. wagtail/sites/tests.py +15 -0
  201. wagtail/sites/wagtail_hooks.py +0 -9
  202. wagtail/snippets/locale/en/LC_MESSAGES/django.po +9 -9
  203. wagtail/snippets/permissions.py +5 -3
  204. wagtail/snippets/static/wagtailsnippets/js/snippet-chooser-telepath.js +1 -1
  205. wagtail/snippets/static/wagtailsnippets/js/snippet-chooser.js +1 -1
  206. wagtail/snippets/templates/wagtailsnippets/snippets/action_menu/menu.html +1 -1
  207. wagtail/snippets/tests/test_snippets.py +78 -12
  208. wagtail/snippets/tests/test_viewset.py +22 -0
  209. wagtail/snippets/views/snippets.py +19 -14
  210. wagtail/snippets/wagtail_hooks.py +2 -10
  211. wagtail/templatetags/wagtailcore_tags.py +3 -0
  212. wagtail/test/dummy_external_storage.py +1 -1
  213. wagtail/test/i18n/migrations/0003_alter_clusterabletestmodel_locale_and_more.py +40 -0
  214. wagtail/test/routablepage/models.py +4 -0
  215. wagtail/test/snippets/migrations/0012_alter_translatablesnippet_locale.py +20 -0
  216. wagtail/test/testapp/migrations/0038_sociallink.py +52 -0
  217. wagtail/test/testapp/migrations/0039_alter_eventcategory_locale_and_more.py +45 -0
  218. wagtail/test/testapp/models.py +24 -0
  219. wagtail/test/testapp/views.py +1 -0
  220. wagtail/test/testapp/wagtail_hooks.py +9 -0
  221. wagtail/test/urls_multilang.py +6 -1
  222. wagtail/test/urls_multilang_non_root.py +11 -0
  223. wagtail/tests/streamfield_migrations/test_migrations.py +53 -12
  224. wagtail/tests/test_audit_log.py +72 -2
  225. wagtail/tests/test_blocks.py +103 -0
  226. wagtail/tests/test_signals.py +49 -2
  227. wagtail/tests/test_streamfield.py +153 -0
  228. wagtail/tests/test_utils.py +14 -0
  229. wagtail/tests/tests.py +5 -0
  230. wagtail/users/apps.py +1 -0
  231. wagtail/users/forms.py +7 -0
  232. wagtail/users/locale/en/LC_MESSAGES/django.po +55 -50
  233. wagtail/users/models.py +1 -0
  234. wagtail/users/templates/wagtailusers/groups/includes/formatted_permissions.html +3 -2
  235. wagtail/users/templatetags/wagtailusers_tags.py +9 -0
  236. wagtail/users/tests/__init__.py +7 -1
  237. wagtail/users/tests/test_admin_views.py +117 -32
  238. wagtail/users/views/groups.py +4 -0
  239. wagtail/users/views/users.py +58 -14
  240. wagtail/users/wagtail_hooks.py +7 -123
  241. wagtail/utils/utils.py +1 -0
  242. wagtail/utils/version.py +5 -2
  243. {wagtail-6.1.3.dist-info → wagtail-6.2rc1.dist-info}/METADATA +3 -3
  244. {wagtail-6.1.3.dist-info → wagtail-6.2rc1.dist-info}/RECORD +248 -222
  245. wagtail/admin/templates/wagtailadmin/reports/aging_pages.html +0 -58
  246. wagtail/admin/templates/wagtailadmin/reports/page_types_usage.html +0 -18
  247. wagtail/admin/templates/wagtailadmin/reports/site_history.html +0 -57
  248. wagtail/admin/templates/wagtailadmin/reports/workflow.html +0 -81
  249. wagtail/admin/templates/wagtailadmin/reports/workflow_tasks.html +0 -63
  250. wagtail/contrib/frontend_cache/backends.py +0 -400
  251. wagtail/contrib/redirects/templates/wagtailredirects/list.html +0 -43
  252. wagtail/contrib/redirects/templates/wagtailredirects/reports/redirects_report.html +0 -14
  253. wagtail/contrib/redirects/tests/test_reports_view.py +0 -82
  254. {wagtail-6.1.3.dist-info → wagtail-6.2rc1.dist-info}/LICENSE +0 -0
  255. {wagtail-6.1.3.dist-info → wagtail-6.2rc1.dist-info}/WHEEL +0 -0
  256. {wagtail-6.1.3.dist-info → wagtail-6.2rc1.dist-info}/entry_points.txt +0 -0
  257. {wagtail-6.1.3.dist-info → wagtail-6.2rc1.dist-info}/top_level.txt +0 -0
@@ -13,6 +13,7 @@ from django.utils.html import escape
13
13
  from django.utils.timezone import make_aware
14
14
  from openpyxl import load_workbook
15
15
 
16
+ from wagtail import hooks
16
17
  from wagtail.admin.admin_url_finder import AdminURLFinder
17
18
  from wagtail.log_actions import log
18
19
  from wagtail.models import ModelLogEntry
@@ -58,6 +59,38 @@ class TestModelViewSetGroup(WagtailTestUtils, TestCase):
58
59
  "/admin/blockcounts/streammodel/",
59
60
  )
60
61
 
62
+ def test_menu_item_with_only_view_permission(self):
63
+ self.user.is_superuser = False
64
+ self.user.save()
65
+ admin_permission = Permission.objects.get(
66
+ content_type__app_label="wagtailadmin",
67
+ codename="access_admin",
68
+ )
69
+ view_permission = Permission.objects.get(
70
+ content_type=ContentType.objects.get_for_model(JSONStreamModel),
71
+ codename=get_permission_codename("view", JSONStreamModel._meta),
72
+ )
73
+ self.user.user_permissions.add(admin_permission, view_permission)
74
+
75
+ response = self.client.get(reverse("wagtailadmin_home"))
76
+
77
+ # The group menu item is still shown
78
+ self.assertContains(
79
+ response,
80
+ '"name": "tests", "label": "Tests", "icon_name": "folder-open-inverse"',
81
+ )
82
+
83
+ # The menu item for the model is shown
84
+ self.assertContains(response, "Json Stream Models")
85
+ self.assertContains(response, reverse("streammodel:index"))
86
+ self.assertEqual(reverse("streammodel:index"), "/admin/streammodel/")
87
+
88
+ # The other items in the group are not shown as the user doesn't have permission
89
+ self.assertNotContains(response, "JSON MinMaxCount StreamModel")
90
+ self.assertNotContains(response, reverse("minmaxcount_streammodel:index"))
91
+ self.assertNotContains(response, "JSON BlockCounts StreamModel")
92
+ self.assertNotContains(response, reverse("blockcounts_streammodel:index"))
93
+
61
94
 
62
95
  class TestTemplateConfiguration(WagtailTestUtils, TestCase):
63
96
  def setUp(self):
@@ -1358,6 +1391,25 @@ class TestInspectView(WagtailTestUtils, TestCase):
1358
1391
  self.assertEqual(fields, expected_fields)
1359
1392
  self.assertEqual(values, expected_values)
1360
1393
 
1394
+ def test_view_permission_registered(self):
1395
+ content_type = ContentType.objects.get_for_model(FeatureCompleteToy)
1396
+ qs = Permission.objects.none()
1397
+ for fn in hooks.get_hooks("register_permissions"):
1398
+ qs |= fn()
1399
+ registered_user_permissions = qs.filter(content_type=content_type)
1400
+ self.assertEqual(
1401
+ set(registered_user_permissions.values_list("codename", flat=True)),
1402
+ {
1403
+ "add_featurecompletetoy",
1404
+ "change_featurecompletetoy",
1405
+ "delete_featurecompletetoy",
1406
+ # The "view" permission should be registered if inspect view is enabled
1407
+ "view_featurecompletetoy",
1408
+ # Any custom permissions should be registered too
1409
+ "can_set_release_date",
1410
+ },
1411
+ )
1412
+
1361
1413
  def test_disabled(self):
1362
1414
  # An alternate viewset for the same model without inspect_view_enabled = True
1363
1415
  with self.assertRaises(NoReverseMatch):
@@ -1375,7 +1427,7 @@ class TestInspectView(WagtailTestUtils, TestCase):
1375
1427
  self.assertEqual(response.status_code, 302)
1376
1428
  self.assertRedirects(response, reverse("wagtailadmin_home"))
1377
1429
 
1378
- def test_only_add_permission(self):
1430
+ def assert_minimal_permission(self, permission):
1379
1431
  self.user.is_superuser = False
1380
1432
  self.user.user_permissions.add(
1381
1433
  Permission.objects.get(
@@ -1383,7 +1435,7 @@ class TestInspectView(WagtailTestUtils, TestCase):
1383
1435
  ),
1384
1436
  Permission.objects.get(
1385
1437
  content_type__app_label=self.object._meta.app_label,
1386
- codename=get_permission_codename("add", self.object._meta),
1438
+ codename=get_permission_codename(permission, self.object._meta),
1387
1439
  ),
1388
1440
  )
1389
1441
  self.user.save()
@@ -1406,6 +1458,12 @@ class TestInspectView(WagtailTestUtils, TestCase):
1406
1458
  self.assertEqual(len(soup.find_all("a", attrs={"href": self.edit_url})), 0)
1407
1459
  self.assertEqual(len(soup.find_all("a", attrs={"href": self.delete_url})), 0)
1408
1460
 
1461
+ def test_only_add_permission(self):
1462
+ self.assert_minimal_permission("add")
1463
+
1464
+ def test_only_view_permission(self):
1465
+ self.assert_minimal_permission("view")
1466
+
1409
1467
 
1410
1468
  class TestListingButtons(WagtailTestUtils, TestCase):
1411
1469
  def setUp(self):
@@ -1464,6 +1522,76 @@ class TestListingButtons(WagtailTestUtils, TestCase):
1464
1522
  self.assertEqual(rendered_button.attrs.get("aria-label"), aria_label)
1465
1523
  self.assertEqual(rendered_button.attrs.get("href"), url)
1466
1524
 
1525
+ def test_title_cell_not_link_to_edit_view_when_no_edit_permission(self):
1526
+ self.user.is_superuser = False
1527
+ self.user.save()
1528
+ admin_permission = Permission.objects.get(
1529
+ content_type__app_label="wagtailadmin",
1530
+ codename="access_admin",
1531
+ )
1532
+ add_permission = Permission.objects.get(
1533
+ content_type__app_label=self.object._meta.app_label,
1534
+ codename=get_permission_codename("add", self.object._meta),
1535
+ )
1536
+ self.user.user_permissions.add(admin_permission, add_permission)
1537
+
1538
+ response = self.client.get(reverse("fctoy-alt2:index"))
1539
+ self.assertEqual(response.status_code, 200)
1540
+ soup = self.get_soup(response.content)
1541
+ title_wrapper = soup.select_one("#listing-results td.title .title-wrapper")
1542
+ self.assertIsNotNone(title_wrapper)
1543
+
1544
+ # fctoy-alt2 doesn't have inspect view enabled, so the title cell should
1545
+ # not link anywhere
1546
+ self.assertIsNone(title_wrapper.select_one("a"))
1547
+ self.assertEqual(title_wrapper.text.strip(), str(self.object))
1548
+
1549
+ # There should be no edit link at all on the page
1550
+ self.assertNotContains(
1551
+ response,
1552
+ reverse("fctoy-alt2:edit", args=[quote(self.object.pk)]),
1553
+ )
1554
+
1555
+ def test_title_cell_links_to_inspect_view_when_no_edit_permission(self):
1556
+ self.user.is_superuser = False
1557
+ self.user.save()
1558
+ admin_permission = Permission.objects.get(
1559
+ content_type__app_label="wagtailadmin",
1560
+ codename="access_admin",
1561
+ )
1562
+ view_permission = Permission.objects.get(
1563
+ content_type__app_label=self.object._meta.app_label,
1564
+ codename=get_permission_codename("view", self.object._meta),
1565
+ )
1566
+ self.user.user_permissions.add(admin_permission, view_permission)
1567
+
1568
+ response = self.client.get(reverse("feature_complete_toy:index"))
1569
+ self.assertEqual(response.status_code, 200)
1570
+ soup = self.get_soup(response.content)
1571
+ title_wrapper = soup.select_one("#listing-results td.title .title-wrapper")
1572
+ self.assertIsNotNone(title_wrapper)
1573
+ link = title_wrapper.select_one("a")
1574
+ self.assertIsNotNone(link)
1575
+ self.assertEqual(link.text.strip(), self.object.name)
1576
+ self.assertEqual(
1577
+ link.get("href"),
1578
+ reverse("feature_complete_toy:inspect", args=[quote(self.object.pk)]),
1579
+ )
1580
+
1581
+ # Should contain the inspect link twice:
1582
+ # once in the title cell and once in the dropdown
1583
+ self.assertContains(
1584
+ response,
1585
+ reverse("feature_complete_toy:inspect", args=[quote(self.object.pk)]),
1586
+ count=2,
1587
+ )
1588
+
1589
+ # There should be no edit link at all on the page
1590
+ self.assertNotContains(
1591
+ response,
1592
+ reverse("feature_complete_toy:edit", args=[quote(self.object.pk)]),
1593
+ )
1594
+
1467
1595
  def test_copy_disabled(self):
1468
1596
  response = self.client.get(reverse("fctoy_alt1:index"))
1469
1597
 
@@ -1508,6 +1636,27 @@ class TestListingButtons(WagtailTestUtils, TestCase):
1508
1636
  self.assertEqual(rendered_button.attrs.get("aria-label"), aria_label)
1509
1637
  self.assertEqual(rendered_button.attrs.get("href"), url)
1510
1638
 
1639
+ def test_dropdown_not_rendered_when_no_child_buttons_exist(self):
1640
+ self.user.is_superuser = False
1641
+ self.user.save()
1642
+ admin_permission = Permission.objects.get(
1643
+ content_type__app_label="wagtailadmin",
1644
+ codename="access_admin",
1645
+ )
1646
+ add_permission = Permission.objects.get(
1647
+ content_type__app_label=self.object._meta.app_label,
1648
+ codename=get_permission_codename("add", self.object._meta),
1649
+ )
1650
+ self.user.user_permissions.add(admin_permission, add_permission)
1651
+
1652
+ # The alt3 viewset doesn't have "copy" and "inspect" views enabled,
1653
+ # so when only "add" permission is granted, the dropdown should have
1654
+ # no items and thus not be rendered at all
1655
+ response = self.client.get(reverse("fctoy-alt3:index"))
1656
+ soup = self.get_soup(response.content)
1657
+ actions = soup.select_one("tbody tr td ul.actions")
1658
+ self.assertIsNone(actions)
1659
+
1511
1660
 
1512
1661
  class TestCopyView(WagtailTestUtils, TestCase):
1513
1662
  def setUp(self):
@@ -0,0 +1,57 @@
1
+ from django.conf import settings
2
+
3
+ from wagtail.admin.ui.components import Component
4
+
5
+
6
+ class EditingSessionsModule(Component):
7
+ template_name = "wagtailadmin/shared/editing_sessions/module.html"
8
+
9
+ def __init__(
10
+ self,
11
+ current_session,
12
+ ping_url,
13
+ release_url,
14
+ other_sessions,
15
+ content_type,
16
+ revision_id=None,
17
+ ):
18
+ self.current_session = current_session
19
+ self.ping_url = ping_url
20
+ self.release_url = release_url
21
+ self.sessions_list = EditingSessionsList(
22
+ current_session, other_sessions, content_type
23
+ )
24
+ self.content_type = content_type
25
+ self.revision_id = revision_id
26
+
27
+ def get_context_data(self, parent_context):
28
+ ping_interval = getattr(
29
+ settings,
30
+ "WAGTAIL_EDITING_SESSION_PING_INTERVAL",
31
+ 10000,
32
+ )
33
+ return {
34
+ "current_session": self.current_session,
35
+ "ping_url": self.ping_url,
36
+ "release_url": self.release_url,
37
+ "ping_interval": str(ping_interval), # avoid the need to | unlocalize
38
+ "sessions_list": self.sessions_list,
39
+ "content_type": self.content_type,
40
+ "revision_id": self.revision_id,
41
+ }
42
+
43
+
44
+ class EditingSessionsList(Component):
45
+ template_name = "wagtailadmin/shared/editing_sessions/list.html"
46
+
47
+ def __init__(self, current_session, other_sessions, content_type):
48
+ self.current_session = current_session
49
+ self.sessions = other_sessions
50
+ self.content_type = content_type
51
+
52
+ def get_context_data(self, parent_context):
53
+ return {
54
+ "current_session": self.current_session,
55
+ "sessions": self.sessions,
56
+ "content_type": self.content_type,
57
+ }
@@ -1,5 +1,4 @@
1
1
  import functools
2
- import hashlib
3
2
 
4
3
  from django.conf import settings
5
4
  from django.http import Http404
@@ -13,6 +12,7 @@ from wagtail import hooks
13
12
  from wagtail.admin.api import urls as api_urls
14
13
  from wagtail.admin.auth import require_admin_access
15
14
  from wagtail.admin.urls import collections as wagtailadmin_collections_urls
15
+ from wagtail.admin.urls import editing_sessions as wagtailadmin_editing_sessions_urls
16
16
  from wagtail.admin.urls import pages as wagtailadmin_pages_urls
17
17
  from wagtail.admin.urls import password_reset as wagtailadmin_password_reset_urls
18
18
  from wagtail.admin.urls import reports as wagtailadmin_reports_urls
@@ -111,6 +111,13 @@ urlpatterns = [
111
111
  dismissibles.DismissiblesView.as_view(),
112
112
  name="wagtailadmin_dismissibles",
113
113
  ),
114
+ path(
115
+ "editing-sessions/",
116
+ include(
117
+ wagtailadmin_editing_sessions_urls,
118
+ namespace="wagtailadmin_editing_sessions",
119
+ ),
120
+ ),
114
121
  ]
115
122
 
116
123
 
@@ -124,23 +131,10 @@ for fn in hooks.get_hooks("register_admin_urls"):
124
131
  # Add "wagtailadmin.access_admin" permission check
125
132
  urlpatterns = decorate_urlpatterns(urlpatterns, require_admin_access)
126
133
 
127
- sprite_hash = None
128
-
129
-
130
- def get_sprite_hash():
131
- global sprite_hash
132
- if not sprite_hash:
133
- content = str(home.sprite(None).content, "utf-8")
134
- # SECRET_KEY is used to prevent exposing the Wagtail version
135
- sprite_hash = hashlib.sha1(
136
- (content + settings.SECRET_KEY).encode("utf-8")
137
- ).hexdigest()[:8]
138
- return sprite_hash
139
-
140
134
 
141
135
  # These url patterns do not require an authenticated admin user
142
136
  urlpatterns += [
143
- path(f"sprite-{get_sprite_hash()}/", home.sprite, name="wagtailadmin_sprite"),
137
+ path("sprite/", home.sprite, name="wagtailadmin_sprite"),
144
138
  path("login/", account.LoginView.as_view(), name="wagtailadmin_login"),
145
139
  # Password reset
146
140
  path("password_reset/", include(wagtailadmin_password_reset_urls)),
@@ -0,0 +1,17 @@
1
+ from django.urls import path
2
+
3
+ from wagtail.admin.views.editing_sessions import ping, release
4
+
5
+ app_name = "wagtailadmin_editing_sessions"
6
+ urlpatterns = [
7
+ path(
8
+ "ping/<str:app_label>/<str:model_name>/<str:object_id>/<int:session_id>/",
9
+ ping,
10
+ name="ping",
11
+ ),
12
+ path(
13
+ "release/<int:session_id>/",
14
+ release,
15
+ name="release",
16
+ ),
17
+ ]
@@ -11,11 +11,43 @@ from wagtail.admin.views.reports.workflows import WorkflowTasksView, WorkflowVie
11
11
  app_name = "wagtailadmin_reports"
12
12
  urlpatterns = [
13
13
  path("locked/", LockedPagesView.as_view(), name="locked_pages"),
14
+ path(
15
+ "locked/results/",
16
+ LockedPagesView.as_view(results_only=True),
17
+ name="locked_pages_results",
18
+ ),
14
19
  path("workflow/", WorkflowView.as_view(), name="workflow"),
20
+ path(
21
+ "workflow/results/",
22
+ WorkflowView.as_view(results_only=True),
23
+ name="workflow_results",
24
+ ),
15
25
  path("workflow_tasks/", WorkflowTasksView.as_view(), name="workflow_tasks"),
26
+ path(
27
+ "workflow_tasks/results/",
28
+ WorkflowTasksView.as_view(results_only=True),
29
+ name="workflow_tasks_results",
30
+ ),
16
31
  path("site-history/", LogEntriesView.as_view(), name="site_history"),
32
+ path(
33
+ "site-history/results/",
34
+ LogEntriesView.as_view(results_only=True),
35
+ name="site_history_results",
36
+ ),
17
37
  path("aging-pages/", AgingPagesView.as_view(), name="aging_pages"),
18
38
  path(
19
- "page-types-usage/", PageTypesUsageReportView.as_view(), name="page_types_usage"
39
+ "aging-pages/results/",
40
+ AgingPagesView.as_view(results_only=True),
41
+ name="aging_pages_results",
42
+ ),
43
+ path(
44
+ "page-types-usage/",
45
+ PageTypesUsageReportView.as_view(),
46
+ name="page_types_usage",
47
+ ),
48
+ path(
49
+ "page-types-usage/results/",
50
+ PageTypesUsageReportView.as_view(results_only=True),
51
+ name="page_types_usage_results",
20
52
  ),
21
53
  ]
wagtail/admin/userbar.py CHANGED
@@ -63,30 +63,71 @@ class AccessibilityItem(BaseItem):
63
63
  #: For more details, see `Axe documentation <https://github.com/dequelabs/axe-core/blob/master/doc/API.md#options-parameter-examples>`__.
64
64
  axe_rules = {}
65
65
 
66
+ #: A list to add custom Axe rules or override their properties,
67
+ #: alongside with ``axe_custom_checks``. Includes Wagtail’s custom rules.
68
+ #: For more details, see `Axe documentation <https://github.com/dequelabs/axe-core/blob/master/doc/API.md#api-name-axeconfigure>`_.
69
+ axe_custom_rules = [
70
+ {
71
+ "id": "alt-text-quality",
72
+ "impact": "serious",
73
+ "selector": "img[alt]",
74
+ "tags": ["best-practice"],
75
+ "any": ["check-image-alt-text"],
76
+ # If omitted, defaults to True and overrides configs in `axe_run_only`.
77
+ "enabled": True,
78
+ },
79
+ ]
80
+
81
+ #: A list to add custom Axe checks or override their properties.
82
+ #: Should be used in conjunction with ``axe_custom_rules``.
83
+ #: For more details, see `Axe documentation <https://github.com/dequelabs/axe-core/blob/master/doc/API.md#api-name-axeconfigure>`_.
84
+ axe_custom_checks = [
85
+ {
86
+ "id": "check-image-alt-text",
87
+ "options": {"pattern": "\\.(avif|gif|jpg|jpeg|png|svg|webp)$|_"},
88
+ },
89
+ ]
90
+
66
91
  #: A dictionary that maps axe-core rule IDs to custom translatable strings
67
92
  #: to use as the error messages. If an enabled rule does not exist in this
68
93
  #: dictionary, Axe's error message for the rule will be used as fallback.
69
94
  axe_messages = {
70
- "button-name": _(
71
- "Button text is empty. Use meaningful text for screen reader users."
72
- ),
73
- "empty-heading": _(
74
- "Empty heading found. Use meaningful text for screen reader users."
75
- ),
76
- "empty-table-header": _(
77
- "Table header text is empty. Use meaningful text for screen reader users."
78
- ),
79
- "frame-title": _(
80
- "Empty frame title found. Use a meaningful title for screen reader users."
81
- ),
82
- "heading-order": _("Incorrect heading hierarchy. Avoid skipping levels."),
83
- "input-button-name": _(
84
- "Input button text is empty. Use meaningful text for screen reader users."
85
- ),
86
- "link-name": _(
87
- "Link text is empty. Use meaningful text for screen reader users."
88
- ),
89
- "p-as-heading": _("Misusing paragraphs as headings. Use proper heading tags."),
95
+ "button-name": {
96
+ "error_name": _("Button text is empty"),
97
+ "help_text": _("Use meaningful text for screen reader users"),
98
+ },
99
+ "empty-heading": {
100
+ "error_name": _("Empty heading found"),
101
+ "help_text": _("Use meaningful text for screen reader users"),
102
+ },
103
+ "empty-table-header": {
104
+ "error_name": _("Table header text is empty"),
105
+ "help_text": _("Use meaningful text for screen reader users"),
106
+ },
107
+ "frame-title": {
108
+ "error_name": _("Empty frame title found"),
109
+ "help_text": _("Use a meaningful title for screen reader users"),
110
+ },
111
+ "heading-order": {
112
+ "error_name": _("Incorrect heading hierarchy"),
113
+ "help_text": _("Avoid skipping levels"),
114
+ },
115
+ "input-button-name": {
116
+ "error_name": _("Input button text is empty"),
117
+ "help_text": _("Use meaningful text for screen reader users"),
118
+ },
119
+ "link-name": {
120
+ "error_name": _("Link text is empty"),
121
+ "help_text": _("Use meaningful text for screen reader users"),
122
+ },
123
+ "p-as-heading": {
124
+ "error_name": _("Misusing paragraphs as headings"),
125
+ "help_text": _("Use proper heading tags"),
126
+ },
127
+ "alt-text-quality": {
128
+ "error_name": _("Image alt text has inappropriate pattern"),
129
+ "help_text": _("Use meaningful text"),
130
+ },
90
131
  }
91
132
 
92
133
  def get_axe_include(self, request):
@@ -105,6 +146,14 @@ class AccessibilityItem(BaseItem):
105
146
  """Returns a dictionary that maps axe-core rule IDs to a dictionary of rule options."""
106
147
  return self.axe_rules
107
148
 
149
+ def get_axe_custom_rules(self, request):
150
+ """List of rule objects per axe.run API."""
151
+ return self.axe_custom_rules
152
+
153
+ def get_axe_custom_checks(self, request):
154
+ """List of check objects per axe.run API, without evaluate function."""
155
+ return self.axe_custom_checks
156
+
108
157
  def get_axe_messages(self, request):
109
158
  """Returns a dictionary that maps axe-core rule IDs to custom translatable strings."""
110
159
  return self.axe_messages
@@ -139,11 +188,19 @@ class AccessibilityItem(BaseItem):
139
188
  options.pop("runOnly")
140
189
  return options
141
190
 
191
+ def get_axe_spec(self, request):
192
+ """Returns spec for Axe, including custom rules and custom checks"""
193
+ return {
194
+ "rules": self.get_axe_custom_rules(request),
195
+ "checks": self.get_axe_custom_checks(request),
196
+ }
197
+
142
198
  def get_axe_configuration(self, request):
143
199
  return {
144
200
  "context": self.get_axe_context(request),
145
201
  "options": self.get_axe_options(request),
146
202
  "messages": self.get_axe_messages(request),
203
+ "spec": self.get_axe_spec(request),
147
204
  }
148
205
 
149
206
  def get_context_data(self, request):
@@ -1,11 +1,13 @@
1
1
  import re
2
- import urllib.parse as urlparse
2
+ from collections import defaultdict
3
+ from urllib.parse import parse_qs, quote, urlencode, urlsplit
3
4
 
4
5
  from django.conf import settings
5
6
  from django.core.paginator import InvalidPage, Paginator
6
7
  from django.http import Http404
7
8
  from django.shortcuts import get_object_or_404
8
9
  from django.template.response import TemplateResponse
10
+ from django.urls import NoReverseMatch
9
11
  from django.urls.base import reverse
10
12
  from django.utils.translation import gettext_lazy as _
11
13
  from django.views.generic.base import View
@@ -629,29 +631,57 @@ class ExternalLinkView(BaseLinkFormView):
629
631
  if sites is None:
630
632
  sites = Site.get_site_root_paths()
631
633
 
634
+ try:
635
+ # The serve view might not be routed to the root path of the domain,
636
+ # e.g. /pages/, so we need to account for the path to the serve view
637
+ serve_path = reverse("wagtail_serve", args=("",))
638
+ except NoReverseMatch:
639
+ serve_path = None
640
+
632
641
  match_relative_paths = submitted_url.startswith("/") and len(sites) == 1
633
642
  # We should only match relative urls if there's only a single site
634
643
  # Otherwise this could get very annoying accidentally matching coincidentally
635
644
  # named pages on different sites
636
645
 
646
+ possible_sites = defaultdict(list)
647
+
637
648
  if match_relative_paths:
638
- possible_sites = [
639
- (pk, url_without_query) for pk, path, url, language_code in sites
640
- ]
649
+ for pk, path, url, language_code in sites:
650
+ possible_sites[pk].append(url_without_query)
651
+
652
+ # If the submitted URL is prefixed with the serve path,
653
+ # also consider it without the serve path so we can match
654
+ # the page using Page.route()
655
+ if serve_path and url_without_query.startswith(serve_path):
656
+ possible_sites[pk].append(
657
+ url_without_query[len(serve_path) - 1 :]
658
+ )
641
659
  else:
642
- possible_sites = [
643
- (pk, url_without_query[len(url) :])
644
- for pk, path, url, language_code in sites
645
- if submitted_url.startswith(url)
646
- ]
660
+ for pk, path, url, language_code in sites:
661
+ if not submitted_url.startswith(url):
662
+ continue
663
+ possible_sites[pk].append(url_without_query[len(url) :])
664
+
665
+ # If the submitted URL is prefixed with the serve path,
666
+ # also consider it without the serve path so we can match
667
+ # the page using Page.route()
668
+ if serve_path and url_without_query.startswith(url + serve_path):
669
+ possible_sites[pk].append(
670
+ url_without_query[len(url) + len(serve_path) - 1 :]
671
+ )
647
672
 
648
673
  # Loop over possible sites to identify a page match
649
- for pk, url in possible_sites:
650
- try:
651
- route = Site.objects.get(pk=pk).root_page.specific.route(
652
- request,
653
- [component for component in url.split("/") if component],
654
- )
674
+ for pk, possible_urls in possible_sites.items():
675
+ site = Site.objects.select_related("root_page").get(pk=pk)
676
+ root_page = site.root_page.specific
677
+ for url in possible_urls:
678
+ try:
679
+ route = root_page.route(
680
+ request,
681
+ [component for component in url.split("/") if component],
682
+ )
683
+ except Http404:
684
+ continue
655
685
 
656
686
  matched_page = route.page.specific
657
687
 
@@ -704,9 +734,6 @@ class ExternalLinkView(BaseLinkFormView):
704
734
  },
705
735
  )
706
736
 
707
- except Http404:
708
- continue
709
-
710
737
  # Otherwise, with no internal matches, fall back to an external url
711
738
  return self.render_chosen_response(result)
712
739
  else: # form invalid
@@ -748,9 +775,9 @@ class EmailLinkView(BaseLinkFormView):
748
775
  "subject": self.form.cleaned_data["subject"],
749
776
  "body": self.form.cleaned_data["body"],
750
777
  }
751
- encoded_params = urlparse.urlencode(
778
+ encoded_params = urlencode(
752
779
  {k: v for k, v in params.items() if v is not None and v != ""},
753
- quote_via=urlparse.quote,
780
+ quote_via=quote,
754
781
  )
755
782
 
756
783
  url = "mailto:" + self.form.cleaned_data["email_address"]
@@ -781,11 +808,11 @@ class EmailLinkView(BaseLinkFormView):
781
808
  def parse_email_link(self, mailto):
782
809
  result = {}
783
810
 
784
- mail_result = urlparse.urlparse(mailto)
811
+ mail_result = urlsplit(mailto)
785
812
 
786
813
  result["email"] = mail_result.path
787
814
 
788
- query = urlparse.parse_qs(mail_result.query)
815
+ query = parse_qs(mail_result.query)
789
816
  result["subject"] = query["subject"][0] if "subject" in query else ""
790
817
  result["body"] = query["body"][0] if "body" in query else ""
791
818
 
@@ -138,17 +138,6 @@ class Edit(EditView):
138
138
  instance.move(self.form.cleaned_data["parent"], "sorted-child")
139
139
  return instance
140
140
 
141
- def get_context_data(self, **kwargs):
142
- context = super().get_context_data(**kwargs)
143
- context["can_delete"] = (
144
- self.permission_policy.instances_user_has_permission_for(
145
- self.request.user, "delete"
146
- )
147
- .filter(pk=self.object.pk)
148
- .first()
149
- )
150
- return context
151
-
152
141
 
153
142
  class Delete(DeleteView):
154
143
  permission_policy = collection_permission_policy