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
@@ -0,0 +1,114 @@
1
+ import logging
2
+
3
+ import requests
4
+ from django.core.exceptions import ImproperlyConfigured
5
+
6
+ from .base import BaseBackend
7
+
8
+ logger = logging.getLogger("wagtail.frontendcache")
9
+
10
+
11
+ __all__ = ["CloudflareBackend"]
12
+
13
+
14
+ class CloudflareBackend(BaseBackend):
15
+ CHUNK_SIZE = 30
16
+
17
+ def __init__(self, params):
18
+ super().__init__(params)
19
+
20
+ self.cloudflare_email = params.pop("EMAIL", None)
21
+ self.cloudflare_api_key = params.pop("TOKEN", None) or params.pop(
22
+ "API_KEY", None
23
+ )
24
+ self.cloudflare_token = params.pop("BEARER_TOKEN", None)
25
+ self.cloudflare_zoneid = params.pop("ZONEID")
26
+ self.cloudflare_purge_endpoint_url = (
27
+ "https://api.cloudflare.com/client/v4/zones/{}/purge_cache".format(
28
+ self.cloudflare_zoneid
29
+ )
30
+ )
31
+
32
+ if (
33
+ (not self.cloudflare_email and self.cloudflare_api_key)
34
+ or (self.cloudflare_email and not self.cloudflare_api_key)
35
+ or (
36
+ not any(
37
+ [
38
+ self.cloudflare_email,
39
+ self.cloudflare_api_key,
40
+ self.cloudflare_token,
41
+ ]
42
+ )
43
+ )
44
+ ):
45
+ raise ImproperlyConfigured(
46
+ "The setting 'WAGTAILFRONTENDCACHE' requires both 'EMAIL' and 'API_KEY', or 'BEARER_TOKEN' to be specified."
47
+ )
48
+
49
+ def _purge_urls(self, urls):
50
+ try:
51
+ purge_url = (
52
+ "https://api.cloudflare.com/client/v4/zones/{}/purge_cache".format(
53
+ self.cloudflare_zoneid
54
+ )
55
+ )
56
+
57
+ headers = {"Content-Type": "application/json"}
58
+
59
+ if self.cloudflare_token:
60
+ headers["Authorization"] = f"Bearer {self.cloudflare_token}"
61
+ else:
62
+ headers["X-Auth-Email"] = self.cloudflare_email
63
+ headers["X-Auth-Key"] = self.cloudflare_api_key
64
+
65
+ data = {"files": urls}
66
+
67
+ response = requests.delete(
68
+ purge_url,
69
+ json=data,
70
+ headers=headers,
71
+ )
72
+
73
+ try:
74
+ response_json = response.json()
75
+ except ValueError:
76
+ if response.status_code != 200:
77
+ response.raise_for_status()
78
+ else:
79
+ for url in urls:
80
+ logger.error(
81
+ "Couldn't purge '%s' from Cloudflare. Unexpected JSON parse error.",
82
+ url,
83
+ )
84
+
85
+ except requests.exceptions.HTTPError as e:
86
+ for url in urls:
87
+ logging.exception(
88
+ "Couldn't purge '%s' from Cloudflare. HTTPError: %d",
89
+ url,
90
+ e.response.status_code,
91
+ )
92
+ return
93
+
94
+ if response_json["success"] is False:
95
+ error_messages = ", ".join(
96
+ [str(err["message"]) for err in response_json["errors"]]
97
+ )
98
+ for url in urls:
99
+ logger.error(
100
+ "Couldn't purge '%s' from Cloudflare. Cloudflare errors '%s'",
101
+ url,
102
+ error_messages,
103
+ )
104
+ return
105
+
106
+ def purge_batch(self, urls):
107
+ # Break the batched URLs in to chunks to fit within Cloudflare's maximum size for
108
+ # the purge_cache call (https://api.cloudflare.com/#zone-purge-files-by-url)
109
+ for i in range(0, len(urls), self.CHUNK_SIZE):
110
+ chunk = urls[i : i + self.CHUNK_SIZE]
111
+ self._purge_urls(chunk)
112
+
113
+ def purge(self, url):
114
+ self._purge_urls([url])
@@ -0,0 +1,99 @@
1
+ import logging
2
+ import uuid
3
+ from collections import defaultdict
4
+ from urllib.parse import urlparse
5
+ from warnings import warn
6
+
7
+ from django.core.exceptions import ImproperlyConfigured
8
+
9
+ from wagtail.utils.deprecation import RemovedInWagtail70Warning
10
+
11
+ from .base import BaseBackend
12
+
13
+ logger = logging.getLogger("wagtail.frontendcache")
14
+
15
+
16
+ __all__ = ["CloudfrontBackend"]
17
+
18
+
19
+ class CloudfrontBackend(BaseBackend):
20
+ def __init__(self, params):
21
+ import boto3
22
+
23
+ super().__init__(params)
24
+
25
+ self.client = boto3.client(
26
+ "cloudfront",
27
+ aws_access_key_id=params.get("AWS_ACCESS_KEY_ID"),
28
+ aws_secret_access_key=params.get("AWS_SECRET_ACCESS_KEY"),
29
+ aws_session_token=params.get("AWS_SESSION_TOKEN"),
30
+ )
31
+
32
+ try:
33
+ self.cloudfront_distribution_id = params.pop("DISTRIBUTION_ID")
34
+ except KeyError:
35
+ raise ImproperlyConfigured(
36
+ "The setting 'WAGTAILFRONTENDCACHE' requires the object 'DISTRIBUTION_ID'."
37
+ )
38
+
39
+ # Add known hostnames for hostname validation (if not already defined)
40
+ # RemovedInWagtail70Warning
41
+ if isinstance(self.cloudfront_distribution_id, dict):
42
+ if "HOSTNAMES" in params:
43
+ self.hostnames.extend(self.cloudfront_distribution_id.keys())
44
+ else:
45
+ self.hostnames = list(self.cloudfront_distribution_id.keys())
46
+
47
+ def purge_batch(self, urls):
48
+ paths_by_distribution_id = defaultdict(list)
49
+
50
+ for url in urls:
51
+ url_parsed = urlparse(url)
52
+ distribution_id = None
53
+
54
+ if isinstance(self.cloudfront_distribution_id, dict):
55
+ warn(
56
+ "Using a `DISTRIBUTION_ID` mapping is deprecated - use `HOSTNAMES` in combination with multiple backends instead.",
57
+ category=RemovedInWagtail70Warning,
58
+ )
59
+ host = url_parsed.hostname
60
+ if host in self.cloudfront_distribution_id:
61
+ distribution_id = self.cloudfront_distribution_id.get(host)
62
+ else:
63
+ logger.warning(
64
+ "Couldn't purge '%s' from CloudFront. Hostname '%s' not found in the DISTRIBUTION_ID mapping",
65
+ url,
66
+ host,
67
+ )
68
+ else:
69
+ distribution_id = self.cloudfront_distribution_id
70
+
71
+ if distribution_id:
72
+ paths_by_distribution_id[distribution_id].append(url_parsed.path)
73
+
74
+ for distribution_id, paths in paths_by_distribution_id.items():
75
+ self._create_invalidation(distribution_id, paths)
76
+
77
+ def purge(self, url):
78
+ self.purge_batch([url])
79
+
80
+ def _create_invalidation(self, distribution_id, paths):
81
+ import botocore
82
+
83
+ try:
84
+ self.client.create_invalidation(
85
+ DistributionId=distribution_id,
86
+ InvalidationBatch={
87
+ "Paths": {"Quantity": len(paths), "Items": paths},
88
+ "CallerReference": str(uuid.uuid4()),
89
+ },
90
+ )
91
+ except botocore.exceptions.ClientError as e:
92
+ for path in paths:
93
+ logger.error(
94
+ "Couldn't purge path '%s' from CloudFront (DistributionId=%s). ClientError: %s %s",
95
+ path,
96
+ distribution_id,
97
+ e.response["Error"]["Code"],
98
+ e.response["Error"]["Message"],
99
+ )
@@ -0,0 +1,64 @@
1
+ import logging
2
+ from urllib.error import HTTPError, URLError
3
+ from urllib.parse import urlsplit, urlunsplit
4
+ from urllib.request import Request, urlopen
5
+
6
+ from wagtail import __version__
7
+
8
+ from .base import BaseBackend
9
+
10
+ logger = logging.getLogger("wagtail.frontendcache")
11
+
12
+
13
+ __all__ = ["PurgeRequest", "HTTPBackend"]
14
+
15
+
16
+ class PurgeRequest(Request):
17
+ def get_method(self):
18
+ return "PURGE"
19
+
20
+
21
+ class HTTPBackend(BaseBackend):
22
+ def __init__(self, params):
23
+ super().__init__(params)
24
+ location_url_parsed = urlsplit(params.pop("LOCATION"))
25
+ self.cache_scheme = location_url_parsed.scheme
26
+ self.cache_netloc = location_url_parsed.netloc
27
+
28
+ def purge(self, url):
29
+ url_parsed = urlsplit(url)
30
+ host = url_parsed.hostname
31
+
32
+ # Append port to host if it is set in the original URL
33
+ if url_parsed.port:
34
+ host += ":" + str(url_parsed.port)
35
+
36
+ request = PurgeRequest(
37
+ url=urlunsplit(
38
+ [
39
+ self.cache_scheme,
40
+ self.cache_netloc,
41
+ url_parsed.path,
42
+ url_parsed.query,
43
+ url_parsed.fragment,
44
+ ]
45
+ ),
46
+ headers={
47
+ "Host": host,
48
+ "User-Agent": "Wagtail-frontendcache/" + __version__,
49
+ },
50
+ )
51
+
52
+ try:
53
+ urlopen(request)
54
+ except HTTPError as e:
55
+ logger.error(
56
+ "Couldn't purge '%s' from HTTP cache. HTTPError: %d %s",
57
+ url,
58
+ e.code,
59
+ e.reason,
60
+ )
61
+ except URLError as e:
62
+ logger.error(
63
+ "Couldn't purge '%s' from HTTP cache. URLError: %s", url, e.reason
64
+ )
@@ -5,7 +5,7 @@ import requests
5
5
  from azure.mgmt.cdn import CdnManagementClient
6
6
  from azure.mgmt.frontdoor import FrontDoorManagementClient
7
7
  from django.core.exceptions import ImproperlyConfigured
8
- from django.test import TestCase
8
+ from django.test import SimpleTestCase, TestCase
9
9
  from django.test.utils import override_settings
10
10
 
11
11
  from wagtail.contrib.frontend_cache.backends import (
@@ -19,6 +19,7 @@ from wagtail.contrib.frontend_cache.backends import (
19
19
  from wagtail.contrib.frontend_cache.utils import get_backends
20
20
  from wagtail.models import Page
21
21
  from wagtail.test.testapp.models import EventIndex
22
+ from wagtail.utils.deprecation import RemovedInWagtail70Warning
22
23
 
23
24
  from .utils import (
24
25
  PurgeBatch,
@@ -29,7 +30,7 @@ from .utils import (
29
30
  )
30
31
 
31
32
 
32
- class TestBackendConfiguration(TestCase):
33
+ class TestBackendConfiguration(SimpleTestCase):
33
34
  def test_default(self):
34
35
  backends = get_backends()
35
36
 
@@ -81,6 +82,8 @@ class TestBackendConfiguration(TestCase):
81
82
  "cloudfront": {
82
83
  "BACKEND": "wagtail.contrib.frontend_cache.backends.CloudfrontBackend",
83
84
  "DISTRIBUTION_ID": "frontend",
85
+ "AWS_ACCESS_KEY_ID": "my-access-key-id",
86
+ "AWS_SECRET_ACCESS_KEY": "my-secret-access-key",
84
87
  },
85
88
  }
86
89
  )
@@ -90,6 +93,12 @@ class TestBackendConfiguration(TestCase):
90
93
 
91
94
  self.assertEqual(backends["cloudfront"].cloudfront_distribution_id, "frontend")
92
95
 
96
+ credentials = backends["cloudfront"].client._request_signer._credentials
97
+
98
+ self.assertEqual(credentials.method, "explicit")
99
+ self.assertEqual(credentials.access_key, "my-access-key-id")
100
+ self.assertEqual(credentials.secret_key, "my-secret-access-key")
101
+
93
102
  def test_azure_cdn(self):
94
103
  backends = get_backends(
95
104
  backend_settings={
@@ -172,7 +181,7 @@ class TestBackendConfiguration(TestCase):
172
181
  self.assertIs(client._config.credential, mock_credentials)
173
182
 
174
183
  @mock.patch(
175
- "wagtail.contrib.frontend_cache.backends.AzureCdnBackend._make_purge_call"
184
+ "wagtail.contrib.frontend_cache.backends.azure.AzureCdnBackend._make_purge_call"
176
185
  )
177
186
  def test_azure_cdn_purge(self, make_purge_call_mock):
178
187
  backends = get_backends(
@@ -214,7 +223,7 @@ class TestBackendConfiguration(TestCase):
214
223
  self.assertEqual(call_args[1], ["/home/events/christmas/?test=1", "/blog/"])
215
224
 
216
225
  @mock.patch(
217
- "wagtail.contrib.frontend_cache.backends.AzureFrontDoorBackend._make_purge_call"
226
+ "wagtail.contrib.frontend_cache.backends.azure.AzureFrontDoorBackend._make_purge_call"
218
227
  )
219
228
  def test_azure_front_door_purge(self, make_purge_call_mock):
220
229
  backends = get_backends(
@@ -285,7 +294,7 @@ class TestBackendConfiguration(TestCase):
285
294
  log_output.output[0],
286
295
  )
287
296
 
288
- @mock.patch("wagtail.contrib.frontend_cache.backends.urlopen")
297
+ @mock.patch("wagtail.contrib.frontend_cache.backends.http.urlopen")
289
298
  def _test_http_with_side_effect(self, urlopen_mock, urlopen_side_effect):
290
299
  # given a backends configuration with one HTTP backend
291
300
  backends = get_backends(
@@ -323,7 +332,7 @@ class TestBackendConfiguration(TestCase):
323
332
  )
324
333
 
325
334
  @mock.patch(
326
- "wagtail.contrib.frontend_cache.backends.CloudfrontBackend._create_invalidation"
335
+ "wagtail.contrib.frontend_cache.backends.cloudfront.CloudfrontBackend._create_invalidation"
327
336
  )
328
337
  def test_cloudfront_distribution_id_mapping(self, _create_invalidation):
329
338
  backends = get_backends(
@@ -336,15 +345,31 @@ class TestBackendConfiguration(TestCase):
336
345
  },
337
346
  }
338
347
  )
339
- backends.get("cloudfront").purge(
340
- "http://www.wagtail.org/home/events/christmas/"
341
- )
342
- backends.get("cloudfront").purge("http://torchbox.com/blog/")
348
+ with self.assertWarnsMessage(
349
+ RemovedInWagtail70Warning,
350
+ "Using a `DISTRIBUTION_ID` mapping is deprecated - use `HOSTNAMES` in combination with multiple backends instead.",
351
+ ):
352
+ backends.get("cloudfront").purge(
353
+ "http://www.wagtail.org/home/events/christmas/"
354
+ )
355
+
356
+ with self.assertWarnsMessage(
357
+ RemovedInWagtail70Warning,
358
+ "Using a `DISTRIBUTION_ID` mapping is deprecated - use `HOSTNAMES` in combination with multiple backends instead.",
359
+ ):
360
+ backends.get("cloudfront").purge("http://torchbox.com/blog/")
343
361
 
344
362
  _create_invalidation.assert_called_once_with(
345
363
  "frontend", ["/home/events/christmas/"]
346
364
  )
347
365
 
366
+ self.assertTrue(
367
+ backends.get("cloudfront").invalidates_hostname("www.wagtail.org")
368
+ )
369
+ self.assertFalse(
370
+ backends.get("cloudfront").invalidates_hostname("torchbox.com")
371
+ )
372
+
348
373
  def test_multiple(self):
349
374
  backends = get_backends(
350
375
  backend_settings={
@@ -396,17 +421,11 @@ PURGED_URLS = []
396
421
 
397
422
 
398
423
  class MockBackend(BaseBackend):
399
- def __init__(self, config):
400
- pass
401
-
402
424
  def purge(self, url):
403
425
  PURGED_URLS.append(url)
404
426
 
405
427
 
406
428
  class MockCloudflareBackend(CloudflareBackend):
407
- def __init__(self, config):
408
- pass
409
-
410
429
  def _purge_urls(self, urls):
411
430
  if len(urls) > self.CHUNK_SIZE:
412
431
  raise Exception("Cloudflare backend is not chunking requests as expected")
@@ -465,11 +484,34 @@ class TestCachePurgingFunctions(TestCase):
465
484
  ],
466
485
  )
467
486
 
487
+ @override_settings(
488
+ WAGTAILFRONTENDCACHE={
489
+ "varnish": {
490
+ "BACKEND": "wagtail.contrib.frontend_cache.tests.MockBackend",
491
+ "HOSTNAMES": ["example.com"],
492
+ },
493
+ }
494
+ )
495
+ def test_invalidate_specific_location(self):
496
+ with self.assertLogs(level="INFO") as log_output:
497
+ purge_url_from_cache("http://localhost/foo")
498
+
499
+ self.assertEqual(PURGED_URLS, [])
500
+ self.assertIn(
501
+ "Unable to find purge backend for localhost",
502
+ log_output.output[0],
503
+ )
504
+
505
+ purge_url_from_cache("http://example.com/foo")
506
+ self.assertEqual(PURGED_URLS, ["http://example.com/foo"])
507
+
468
508
 
469
509
  @override_settings(
470
510
  WAGTAILFRONTENDCACHE={
471
511
  "cloudflare": {
472
512
  "BACKEND": "wagtail.contrib.frontend_cache.tests.MockCloudflareBackend",
513
+ "ZONEID": "zone",
514
+ "BEARER_TOKEN": "token",
473
515
  },
474
516
  }
475
517
  )
@@ -634,7 +676,7 @@ class TestPurgeBatchClass(TestCase):
634
676
  ],
635
677
  )
636
678
 
637
- @mock.patch("wagtail.contrib.frontend_cache.backends.requests.delete")
679
+ @mock.patch("wagtail.contrib.frontend_cache.backends.cloudflare.requests.delete")
638
680
  def test_http_error_on_cloudflare_purge_batch(self, requests_delete_mock):
639
681
  backend_settings = {
640
682
  "cloudflare": {
@@ -1,6 +1,7 @@
1
1
  import logging
2
2
  import re
3
- from urllib.parse import urlparse, urlunparse
3
+ from collections import defaultdict
4
+ from urllib.parse import urlsplit, urlunsplit
4
5
 
5
6
  from django.conf import settings
6
7
  from django.core.exceptions import ImproperlyConfigured
@@ -79,13 +80,12 @@ def purge_urls_from_cache(urls, backend_settings=None, backends=None):
79
80
  # Purge the given url for each managed language
80
81
  for isocode in languages:
81
82
  for url in urls:
82
- up = urlparse(url)
83
- new_url = urlunparse(
83
+ up = urlsplit(url)
84
+ new_url = urlunsplit(
84
85
  (
85
86
  up.scheme,
86
87
  up.netloc,
87
88
  re.sub(langs_regex, "/%s/" % isocode, up.path),
88
- up.params,
89
89
  up.query,
90
90
  up.fragment,
91
91
  )
@@ -100,11 +100,29 @@ def purge_urls_from_cache(urls, backend_settings=None, backends=None):
100
100
 
101
101
  urls = new_urls
102
102
 
103
- for backend_name, backend in get_backends(backend_settings, backends).items():
104
- for url in urls:
105
- logger.info("[%s] Purging URL: %s", backend_name, url)
103
+ urls_by_hostname = defaultdict(list)
106
104
 
107
- backend.purge_batch(urls)
105
+ for url in urls:
106
+ urls_by_hostname[urlsplit(url).netloc].append(url)
107
+
108
+ backends = get_backends(backend_settings, backends)
109
+
110
+ for hostname, urls in urls_by_hostname.items():
111
+ backends_for_hostname = {
112
+ backend_name: backend
113
+ for backend_name, backend in backends.items()
114
+ if backend.invalidates_hostname(hostname)
115
+ }
116
+
117
+ if not backends_for_hostname:
118
+ logger.info("Unable to find purge backend for %s", hostname)
119
+ continue
120
+
121
+ for backend_name, backend in backends_for_hostname.items():
122
+ for url in urls:
123
+ logger.info("[%s] Purging URL: %s", backend_name, url)
124
+
125
+ backend.purge_batch(urls)
108
126
 
109
127
 
110
128
  def _get_page_cached_urls(page):
@@ -1,13 +1,27 @@
1
1
  import django_filters
2
2
  from django import forms
3
+ from django.db.models import QuerySet
3
4
  from django.utils.translation import gettext as _
4
5
 
5
6
  from wagtail.admin.filters import WagtailFilterSet
6
7
  from wagtail.contrib.redirects.models import Redirect
7
- from wagtail.models import Site
8
+ from wagtail.models import Page, Site
9
+
10
+
11
+ def get_redirect_pages_queryset(request) -> QuerySet[Page]:
12
+ redirect_page_pks = (
13
+ Redirect.objects.filter(redirect_page__isnull=False)
14
+ .order_by()
15
+ .values_list("redirect_page", flat=True)
16
+ .distinct()
17
+ )
18
+ return Page.objects.filter(pk__in=redirect_page_pks)
8
19
 
9
20
 
10
21
  class RedirectsReportFilterSet(WagtailFilterSet):
22
+ redirect_page = django_filters.ModelChoiceFilter(
23
+ field_name="redirect_page", queryset=get_redirect_pages_queryset
24
+ )
11
25
  is_permanent = django_filters.ChoiceFilter(
12
26
  label=_("Type"),
13
27
  method="filter_type",