wagtail 6.3.2__py3-none-any.whl → 6.4rc1__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 (296) hide show
  1. wagtail/__init__.py +1 -1
  2. wagtail/actions/publish_revision.py +4 -5
  3. wagtail/admin/auth.py +0 -2
  4. wagtail/admin/checks.py +1 -1
  5. wagtail/admin/filters.py +3 -1
  6. wagtail/admin/forms/account.py +21 -11
  7. wagtail/admin/forms/collections.py +2 -9
  8. wagtail/admin/forms/formsets.py +32 -0
  9. wagtail/admin/forms/pages.py +5 -1
  10. wagtail/admin/forms/workflows.py +2 -13
  11. wagtail/admin/locale/ar/LC_MESSAGES/django.mo +0 -0
  12. wagtail/admin/locale/ar/LC_MESSAGES/django.po +68 -1
  13. wagtail/admin/locale/ar/LC_MESSAGES/djangojs.mo +0 -0
  14. wagtail/admin/locale/ar/LC_MESSAGES/djangojs.po +5 -1
  15. wagtail/admin/locale/en/LC_MESSAGES/django.po +312 -356
  16. wagtail/admin/locale/en/LC_MESSAGES/djangojs.po +21 -16
  17. wagtail/admin/menu.py +0 -13
  18. wagtail/admin/panels/base.py +2 -2
  19. wagtail/admin/panels/group.py +4 -1
  20. wagtail/admin/panels/inline_panel.py +5 -2
  21. wagtail/admin/panels/model_utils.py +36 -0
  22. wagtail/admin/panels/page_utils.py +2 -40
  23. wagtail/admin/panels/signal_handlers.py +0 -2
  24. wagtail/admin/static/wagtailadmin/css/core.css +1 -1
  25. wagtail/admin/static/wagtailadmin/css/panels/draftail.css +1 -1
  26. wagtail/admin/static/wagtailadmin/css/panels/streamfield.css +1 -1
  27. wagtail/admin/static/wagtailadmin/js/comments.js +1 -1
  28. wagtail/admin/static/wagtailadmin/js/core.js +1 -1
  29. wagtail/admin/static/wagtailadmin/js/core.js.LICENSE.txt +1 -8
  30. wagtail/admin/static/wagtailadmin/js/draftail.js +1 -1
  31. wagtail/admin/static/wagtailadmin/js/modal-workflow.js +1 -1
  32. wagtail/admin/static/wagtailadmin/js/page-chooser-modal.js +1 -1
  33. wagtail/admin/static/wagtailadmin/js/privacy-switch.js +1 -1
  34. wagtail/admin/static/wagtailadmin/js/sidebar.js +1 -1
  35. wagtail/admin/static/wagtailadmin/js/telepath/blocks.js +1 -1
  36. wagtail/admin/static/wagtailadmin/js/userbar.js +1 -1
  37. wagtail/admin/static/wagtailadmin/js/userbar.js.LICENSE.txt +1 -1
  38. wagtail/admin/static/wagtailadmin/js/vendor.js +1 -1
  39. wagtail/admin/static/wagtailadmin/js/vendor.js.LICENSE.txt +7 -0
  40. wagtail/admin/templates/wagtailadmin/404.html +4 -0
  41. wagtail/admin/templates/wagtailadmin/chooser/browse.html +2 -1
  42. wagtail/admin/templates/wagtailadmin/chooser/tables/parent_page_cell.html +1 -1
  43. wagtail/admin/templates/wagtailadmin/collections/_privacy_switch.html +8 -1
  44. wagtail/admin/templates/wagtailadmin/generic/confirm_delete.html +15 -9
  45. wagtail/admin/templates/wagtailadmin/generic/confirm_unpublish.html +21 -25
  46. wagtail/admin/templates/wagtailadmin/generic/form.html +1 -1
  47. wagtail/admin/templates/wagtailadmin/generic/preview_error.html +3 -0
  48. wagtail/admin/templates/wagtailadmin/generic/revisions/compare.html +63 -76
  49. wagtail/admin/templates/wagtailadmin/pages/_editor_js.html +0 -2
  50. wagtail/admin/templates/wagtailadmin/pages/edit.html +1 -5
  51. wagtail/admin/templates/wagtailadmin/panels/inline_panel_child.html +1 -0
  52. wagtail/admin/templates/wagtailadmin/permissions/includes/collection_member_permissions_form.html +1 -1
  53. wagtail/admin/templates/wagtailadmin/permissions/includes/collection_member_permissions_formset.html +6 -22
  54. wagtail/admin/templates/wagtailadmin/shared/formatted_field.html +2 -2
  55. wagtail/admin/templates/wagtailadmin/shared/header.html +2 -2
  56. wagtail/admin/templates/wagtailadmin/shared/page_status_tag_new.html +32 -39
  57. wagtail/admin/templates/wagtailadmin/shared/revisions/confirm_unschedule.html +13 -17
  58. wagtail/admin/templates/wagtailadmin/shared/side_panels/includes/status/privacy.html +15 -3
  59. wagtail/admin/templates/wagtailadmin/shared/side_panels/preview.html +1 -1
  60. wagtail/admin/templates/wagtailadmin/skeleton.html +4 -2
  61. wagtail/admin/templates/wagtailadmin/workflows/create.html +1 -1
  62. wagtail/admin/templates/wagtailadmin/workflows/edit.html +1 -1
  63. wagtail/admin/templates/wagtailadmin/workflows/includes/workflow_pages_form.html +1 -1
  64. wagtail/admin/templates/wagtailadmin/workflows/includes/workflow_pages_formset.html +6 -23
  65. wagtail/admin/templatetags/wagtailadmin_tags.py +12 -0
  66. wagtail/admin/templatetags/wagtailuserbar.py +2 -3
  67. wagtail/admin/tests/pages/test_create_page.py +110 -1
  68. wagtail/admin/tests/pages/test_edit_page.py +3 -2
  69. wagtail/admin/tests/pages/test_explorer_view.py +18 -0
  70. wagtail/admin/tests/pages/test_page_usage.py +24 -20
  71. wagtail/admin/tests/pages/test_preview.py +69 -1
  72. wagtail/admin/tests/pages/test_revisions.py +40 -6
  73. wagtail/admin/tests/test_account_management.py +39 -1
  74. wagtail/admin/tests/test_audit_log.py +4 -2
  75. wagtail/admin/tests/test_block_preview.py +224 -0
  76. wagtail/admin/tests/test_edit_handlers.py +23 -6
  77. wagtail/admin/tests/test_page_chooser.py +50 -3
  78. wagtail/admin/tests/test_privacy.py +49 -26
  79. wagtail/admin/tests/test_site_summary.py +15 -10
  80. wagtail/admin/tests/test_templatetags.py +19 -0
  81. wagtail/admin/tests/test_userbar.py +82 -1
  82. wagtail/admin/tests/test_views_generic.py +27 -12
  83. wagtail/admin/tests/test_workflows.py +69 -0
  84. wagtail/admin/tests/tests.py +23 -4
  85. wagtail/admin/tests/ui/test_sidebar.py +1 -1
  86. wagtail/admin/tests/viewsets/test_model_viewset.py +15 -13
  87. wagtail/admin/ui/side_panels.py +7 -4
  88. wagtail/admin/urls/__init__.py +6 -0
  89. wagtail/admin/urls/pages.py +1 -1
  90. wagtail/admin/userbar.py +21 -1
  91. wagtail/admin/views/account.py +5 -0
  92. wagtail/admin/views/chooser.py +5 -1
  93. wagtail/admin/views/collections.py +0 -2
  94. wagtail/admin/views/generic/base.py +20 -10
  95. wagtail/admin/views/generic/history.py +0 -1
  96. wagtail/admin/views/generic/models.py +79 -21
  97. wagtail/admin/views/generic/preview.py +50 -1
  98. wagtail/admin/views/mixins.py +4 -2
  99. wagtail/admin/views/pages/bulk_actions/delete.py +11 -23
  100. wagtail/admin/views/pages/bulk_actions/page_bulk_action.py +17 -0
  101. wagtail/admin/views/pages/bulk_actions/publish.py +11 -31
  102. wagtail/admin/views/pages/bulk_actions/unpublish.py +11 -31
  103. wagtail/admin/views/pages/create.py +1 -0
  104. wagtail/admin/views/pages/edit.py +38 -30
  105. wagtail/admin/views/pages/revisions.py +43 -114
  106. wagtail/admin/views/pages/utils.py +0 -1
  107. wagtail/admin/views/tags.py +6 -2
  108. wagtail/admin/views/workflows.py +8 -6
  109. wagtail/admin/viewsets/model.py +0 -4
  110. wagtail/admin/viewsets/pages.py +0 -1
  111. wagtail/admin/widgets/tags.py +1 -0
  112. wagtail/api/v2/tests/test_documents.py +4 -2
  113. wagtail/api/v2/tests/test_images.py +4 -2
  114. wagtail/api/v2/tests/test_pages.py +8 -4
  115. wagtail/blocks/base.py +59 -1
  116. wagtail/blocks/field_block.py +6 -0
  117. wagtail/blocks/list_block.py +4 -0
  118. wagtail/blocks/static_block.py +3 -0
  119. wagtail/blocks/stream_block.py +5 -1
  120. wagtail/blocks/struct_block.py +6 -0
  121. wagtail/compat.py +16 -0
  122. wagtail/contrib/forms/forms.py +27 -7
  123. wagtail/contrib/forms/locale/en/LC_MESSAGES/django.po +2 -2
  124. wagtail/contrib/forms/tests/test_models.py +7 -5
  125. wagtail/contrib/forms/tests/test_views.py +75 -0
  126. wagtail/contrib/frontend_cache/tasks.py +83 -0
  127. wagtail/contrib/frontend_cache/tests.py +47 -32
  128. wagtail/contrib/frontend_cache/utils.py +2 -70
  129. wagtail/contrib/redirects/base_formats.py +2 -2
  130. wagtail/contrib/redirects/locale/ar/LC_MESSAGES/django.mo +0 -0
  131. wagtail/contrib/redirects/locale/ar/LC_MESSAGES/django.po +3 -0
  132. wagtail/contrib/redirects/locale/en/LC_MESSAGES/django.po +24 -37
  133. wagtail/contrib/redirects/templates/wagtailredirects/add.html +1 -24
  134. wagtail/contrib/redirects/templates/wagtailredirects/confirm_delete.html +3 -13
  135. wagtail/contrib/redirects/tests/test_redirects.py +122 -110
  136. wagtail/contrib/redirects/tests/test_signal_handlers.py +75 -69
  137. wagtail/contrib/redirects/urls.py +2 -2
  138. wagtail/contrib/redirects/views.py +35 -73
  139. wagtail/contrib/search_promotions/admin_urls.py +10 -3
  140. wagtail/contrib/search_promotions/forms.py +55 -26
  141. wagtail/contrib/search_promotions/locale/en/LC_MESSAGES/django.po +44 -54
  142. wagtail/contrib/search_promotions/templates/wagtailsearchpromotions/add.html +21 -31
  143. wagtail/contrib/search_promotions/templates/wagtailsearchpromotions/confirm_delete.html +3 -12
  144. wagtail/contrib/search_promotions/templates/wagtailsearchpromotions/edit.html +11 -34
  145. wagtail/contrib/search_promotions/templates/wagtailsearchpromotions/includes/searchpromotion_form.html +1 -0
  146. wagtail/contrib/search_promotions/templates/wagtailsearchpromotions/includes/searchpromotions_formset.js +2 -1
  147. wagtail/contrib/search_promotions/templates/wagtailsearchpromotions/index.html +0 -1
  148. wagtail/contrib/search_promotions/tests.py +814 -13
  149. wagtail/contrib/search_promotions/views/__init__.py +1 -0
  150. wagtail/contrib/search_promotions/views/reports.py +56 -0
  151. wagtail/contrib/search_promotions/views/settings.py +258 -0
  152. wagtail/contrib/search_promotions/wagtail_hooks.py +12 -1
  153. wagtail/contrib/settings/locale/ar/LC_MESSAGES/django.mo +0 -0
  154. wagtail/contrib/settings/locale/ar/LC_MESSAGES/django.po +6 -1
  155. wagtail/contrib/settings/locale/en/LC_MESSAGES/django.po +3 -3
  156. wagtail/contrib/settings/templates/wagtailsettings/edit.html +1 -5
  157. wagtail/contrib/settings/tests/generic/test_admin.py +2 -5
  158. wagtail/contrib/settings/tests/generic/test_register.py +1 -1
  159. wagtail/contrib/settings/tests/site_specific/test_admin.py +2 -5
  160. wagtail/contrib/settings/tests/site_specific/test_register.py +1 -1
  161. wagtail/contrib/settings/views.py +9 -23
  162. wagtail/contrib/simple_translation/locale/en/LC_MESSAGES/django.po +1 -1
  163. wagtail/contrib/styleguide/locale/en/LC_MESSAGES/django.po +1 -1
  164. wagtail/contrib/table_block/locale/en/LC_MESSAGES/django.po +1 -1
  165. wagtail/contrib/table_block/tests.py +4 -1
  166. wagtail/contrib/typed_table_block/blocks.py +3 -0
  167. wagtail/contrib/typed_table_block/locale/en/LC_MESSAGES/django.po +10 -10
  168. wagtail/contrib/typed_table_block/static/typed_table_block/js/typed_table_block.js +1 -1
  169. wagtail/contrib/typed_table_block/tests.py +33 -0
  170. wagtail/documents/locale/en/LC_MESSAGES/django.po +26 -26
  171. wagtail/documents/migrations/0011_add_choose_permissions.py +1 -0
  172. wagtail/documents/models.py +1 -0
  173. wagtail/documents/signal_handlers.py +6 -2
  174. wagtail/documents/static/wagtaildocs/js/add-multiple.js +1 -1
  175. wagtail/documents/templates/wagtaildocs/documents/edit.html +1 -3
  176. wagtail/documents/templates/wagtaildocs/multiple/add.html +7 -1
  177. wagtail/documents/tests/test_admin_views.py +74 -33
  178. wagtail/documents/tests/test_views.py +21 -12
  179. wagtail/documents/views/chooser.py +1 -0
  180. wagtail/documents/views/documents.py +1 -2
  181. wagtail/documents/views/multiple.py +0 -1
  182. wagtail/documents/views/serve.py +9 -2
  183. wagtail/documents/wagtail_hooks.py +6 -1
  184. wagtail/embeds/locale/en/LC_MESSAGES/django.po +1 -1
  185. wagtail/embeds/oembed_providers.py +0 -64
  186. wagtail/fields.py +3 -0
  187. wagtail/images/apps.py +2 -1
  188. wagtail/images/blocks.py +6 -2
  189. wagtail/images/forms.py +40 -3
  190. wagtail/images/locale/ar/LC_MESSAGES/django.mo +0 -0
  191. wagtail/images/locale/ar/LC_MESSAGES/django.po +4 -0
  192. wagtail/images/locale/en/LC_MESSAGES/django.po +49 -49
  193. wagtail/images/migrations/0023_add_choose_permissions.py +1 -0
  194. wagtail/images/rich_text/contentstate.py +1 -0
  195. wagtail/images/rich_text/editor_html.py +1 -0
  196. wagtail/images/signal_handlers.py +17 -10
  197. wagtail/images/static/wagtailimages/js/add-multiple.js +1 -1
  198. wagtail/images/static/wagtailimages/js/image-block.js +1 -1
  199. wagtail/images/static/wagtailimages/js/image-chooser-telepath.js +1 -1
  200. wagtail/images/static/wagtailimages/js/image-chooser.js +1 -1
  201. wagtail/images/static/wagtailimages/js/image-url-generator.js +1 -1
  202. wagtail/images/static/wagtailimages/js/vendor/jquery.fileupload-image.js +1 -1
  203. wagtail/images/tasks.py +18 -0
  204. wagtail/images/templates/wagtailimages/images/edit.html +1 -3
  205. wagtail/images/templates/wagtailimages/images/url_generator.html +1 -1
  206. wagtail/images/templates/wagtailimages/multiple/add.html +7 -2
  207. wagtail/images/templates/wagtailimages/widgets/image_chooser.html +1 -1
  208. wagtail/images/tests/test_admin_views.py +53 -29
  209. wagtail/images/tests/test_blocks.py +3 -2
  210. wagtail/images/tests/test_models.py +12 -10
  211. wagtail/images/tests/tests.py +10 -0
  212. wagtail/images/views/chooser.py +1 -0
  213. wagtail/images/views/images.py +1 -3
  214. wagtail/images/views/multiple.py +0 -1
  215. wagtail/images/views/serve.py +18 -2
  216. wagtail/images/widgets.py +3 -0
  217. wagtail/locale/en/LC_MESSAGES/django.po +228 -216
  218. wagtail/locales/locale/en/LC_MESSAGES/django.po +1 -1
  219. wagtail/management/commands/publish_scheduled.py +1 -1
  220. wagtail/migrations/0087_alter_grouppagepermission_unique_together_and_more.py +16 -8
  221. wagtail/models/__init__.py +300 -119
  222. wagtail/models/i18n.py +2 -2
  223. wagtail/models/panels.py +37 -0
  224. wagtail/models/sites.py +7 -6
  225. wagtail/permission_policies/pages.py +2 -2
  226. wagtail/project_template/project_name/settings/base.py +4 -0
  227. wagtail/project_template/requirements.txt +1 -1
  228. wagtail/query.py +145 -0
  229. wagtail/search/backends/database/mysql/mysql.py +25 -17
  230. wagtail/search/backends/database/postgres/postgres.py +44 -83
  231. wagtail/search/backends/database/sqlite/sqlite.py +25 -17
  232. wagtail/search/backends/elasticsearch7.py +4 -0
  233. wagtail/search/locale/en/LC_MESSAGES/django.po +1 -1
  234. wagtail/search/query.py +8 -2
  235. wagtail/search/signal_handlers.py +6 -9
  236. wagtail/search/tasks.py +10 -0
  237. wagtail/search/tests/test_elasticsearch7_backend.py +21 -0
  238. wagtail/search/tests/test_index_functions.py +10 -6
  239. wagtail/search/tests/test_postgres_backend.py +0 -14
  240. wagtail/signal_handlers.py +5 -20
  241. wagtail/sites/locale/en/LC_MESSAGES/django.po +1 -1
  242. wagtail/snippets/locale/en/LC_MESSAGES/django.po +3 -13
  243. wagtail/snippets/tests/test_preview.py +5 -0
  244. wagtail/snippets/tests/test_snippets.py +100 -45
  245. wagtail/snippets/tests/test_usage.py +29 -24
  246. wagtail/snippets/tests/test_viewset.py +1 -1
  247. wagtail/snippets/views/snippets.py +0 -12
  248. wagtail/tasks.py +41 -0
  249. wagtail/templates/wagtailcore/shared/block_preview.html +29 -0
  250. wagtail/test/earlypage/__init__.py +0 -0
  251. wagtail/test/earlypage/migrations/0001_initial.py +37 -0
  252. wagtail/test/earlypage/migrations/__init__.py +0 -0
  253. wagtail/test/earlypage/models.py +14 -0
  254. wagtail/test/settings.py +3 -0
  255. wagtail/test/testapp/fixtures/test.json +7 -0
  256. wagtail/test/testapp/fixtures/test_specific.json +6 -3
  257. wagtail/test/testapp/models.py +58 -44
  258. wagtail/test/testapp/templates/tests/custom_block_preview.html +16 -0
  259. wagtail/test/testapp/templates/tests/static_block_preview.html +5 -0
  260. wagtail/test/testapp/wagtail_hooks.py +9 -0
  261. wagtail/tests/test_blocks.py +189 -2
  262. wagtail/tests/test_hooks.py +166 -1
  263. wagtail/tests/test_management_commands.py +54 -13
  264. wagtail/tests/test_page_allowed_http_methods.py +32 -0
  265. wagtail/tests/test_page_model.py +68 -0
  266. wagtail/tests/test_page_privacy.py +10 -0
  267. wagtail/tests/test_page_queryset.py +79 -0
  268. wagtail/tests/test_reference_index.py +84 -75
  269. wagtail/tests/test_streamfield.py +30 -0
  270. wagtail/tests/test_utils.py +61 -0
  271. wagtail/users/forms.py +2 -9
  272. wagtail/users/locale/en/LC_MESSAGES/django.po +17 -17
  273. wagtail/users/templates/wagtailusers/groups/create.html +0 -5
  274. wagtail/users/templates/wagtailusers/groups/includes/page_permissions_form.html +1 -1
  275. wagtail/users/templates/wagtailusers/groups/includes/page_permissions_formset.html +6 -6
  276. wagtail/users/tests/test_admin_views.py +96 -4
  277. wagtail/users/tests/test_utils.py +76 -0
  278. wagtail/users/utils.py +43 -11
  279. wagtail/utils/setup.py +2 -2
  280. wagtail/utils/templates.py +26 -0
  281. wagtail/utils/widgets.py +1 -0
  282. wagtail/views.py +9 -1
  283. wagtail/wagtail_hooks.py +67 -29
  284. {wagtail-6.3.2.dist-info → wagtail-6.4rc1.dist-info}/METADATA +2 -2
  285. {wagtail-6.3.2.dist-info → wagtail-6.4rc1.dist-info}/RECORD +289 -276
  286. wagtail/admin/static/wagtailadmin/js/expanding-formset.js +0 -1
  287. wagtail/admin/static/wagtailadmin/js/vendor/rangy-core.js +0 -1
  288. wagtail/admin/static/wagtailadmin/js/vendor/uuidv4.min.js +0 -1
  289. wagtail/contrib/search_promotions/views.py +0 -323
  290. wagtail/images/static/wagtailimages/js/vendor/canvas-to-blob.min.js +0 -1
  291. wagtail/users/static/wagtailusers/js/group-form.js +0 -1
  292. wagtail/users/templates/wagtailusers/groups/includes/group_form_js.html +0 -3
  293. {wagtail-6.3.2.dist-info → wagtail-6.4rc1.dist-info}/LICENSE +0 -0
  294. {wagtail-6.3.2.dist-info → wagtail-6.4rc1.dist-info}/WHEEL +0 -0
  295. {wagtail-6.3.2.dist-info → wagtail-6.4rc1.dist-info}/entry_points.txt +0 -0
  296. {wagtail-6.3.2.dist-info → wagtail-6.4rc1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1 @@
1
+ from .settings import * # NOQA: F403
@@ -0,0 +1,56 @@
1
+ from django.utils.translation import gettext_lazy as _
2
+ from django_filters import DateFromToRangeFilter
3
+
4
+ from wagtail.admin.auth import permission_denied
5
+ from wagtail.admin.filters import DateRangePickerWidget, WagtailFilterSet
6
+ from wagtail.admin.ui.tables import Column
7
+ from wagtail.admin.views.reports import ReportView
8
+ from wagtail.contrib.search_promotions.models import Query
9
+
10
+
11
+ class SearchTermsReportFilterSet(WagtailFilterSet):
12
+ hit_date = DateFromToRangeFilter(
13
+ label=_("Date"),
14
+ field_name="daily_hits__date",
15
+ widget=DateRangePickerWidget,
16
+ )
17
+
18
+ class Meta:
19
+ model = Query
20
+ fields = []
21
+
22
+
23
+ class SearchTermsReportView(ReportView):
24
+ page_title = _("Search terms")
25
+ header_icon = "search"
26
+ is_searchable = True
27
+ search_fields = ["query_string"]
28
+ filterset_class = SearchTermsReportFilterSet
29
+ default_ordering = "-_hits"
30
+ index_url_name = "wagtailsearchpromotions:search_terms"
31
+ index_results_url_name = "wagtailsearchpromotions:search_terms_results"
32
+ columns = [
33
+ Column("query_string", label=_("Search term(s)"), sort_key="query_string"),
34
+ Column("_hits", label=_("Views"), sort_key="_hits"),
35
+ ]
36
+ export_headings = {
37
+ "query_string": _("Search term(s)"),
38
+ "_hits": _("Views"),
39
+ }
40
+ list_export = [
41
+ "query_string",
42
+ "_hits",
43
+ ]
44
+
45
+ def get_filterset_kwargs(self):
46
+ kwargs = super().get_filterset_kwargs()
47
+ kwargs["queryset"] = self.get_base_queryset()
48
+ return kwargs
49
+
50
+ def get_base_queryset(self):
51
+ return Query.get_most_popular()
52
+
53
+ def dispatch(self, request, *args, **kwargs):
54
+ if not self.request.user.is_superuser:
55
+ return permission_denied(request)
56
+ return super().dispatch(request, *args, **kwargs)
@@ -0,0 +1,258 @@
1
+ from django.core.paginator import InvalidPage, Paginator
2
+ from django.db import transaction
3
+ from django.db.models import Sum, functions
4
+ from django.http import Http404
5
+ from django.shortcuts import redirect
6
+ from django.template.response import TemplateResponse
7
+ from django.utils.functional import cached_property
8
+ from django.utils.translation import gettext_lazy
9
+
10
+ from wagtail.admin import messages
11
+ from wagtail.admin.forms.search import SearchForm
12
+ from wagtail.admin.modal_workflow import render_modal_workflow
13
+ from wagtail.admin.ui.tables import Column, RelatedObjectsColumn, TitleColumn
14
+ from wagtail.admin.views import generic
15
+ from wagtail.contrib.search_promotions import forms, models
16
+ from wagtail.contrib.search_promotions.models import Query, SearchPromotion
17
+ from wagtail.log_actions import log
18
+ from wagtail.permission_policies.base import ModelPermissionPolicy
19
+ from wagtail.search.utils import normalise_query_string
20
+
21
+
22
+ class SearchPromotionColumn(RelatedObjectsColumn):
23
+ cell_template_name = "wagtailsearchpromotions/search_promotion_column.html"
24
+
25
+
26
+ class IndexView(generic.IndexView):
27
+ model = Query
28
+ template_name = "wagtailsearchpromotions/index.html"
29
+ results_template_name = "wagtailsearchpromotions/index_results.html"
30
+ context_object_name = "queries"
31
+ page_title = gettext_lazy("Promoted search results")
32
+ header_icon = "pick"
33
+ paginate_by = 20
34
+ permission_policy = ModelPermissionPolicy(SearchPromotion)
35
+ index_url_name = "wagtailsearchpromotions:index"
36
+ index_results_url_name = "wagtailsearchpromotions:index_results"
37
+ search_fields = ["query_string"]
38
+ default_ordering = "query_string"
39
+ add_url_name = "wagtailsearchpromotions:add"
40
+ add_item_label = gettext_lazy("Add new promoted result")
41
+ columns = [
42
+ TitleColumn(
43
+ "query_string",
44
+ label=gettext_lazy("Search term(s)"),
45
+ width="40%",
46
+ url_name="wagtailsearchpromotions:edit",
47
+ sort_key="query_string",
48
+ ),
49
+ SearchPromotionColumn(
50
+ "editors_picks",
51
+ label=gettext_lazy("Promoted results"),
52
+ width="40%",
53
+ ),
54
+ Column(
55
+ "views",
56
+ label=gettext_lazy("Views (past week)"),
57
+ width="20%",
58
+ sort_key="views",
59
+ ),
60
+ ]
61
+
62
+ def get_base_queryset(self):
63
+ # Use a subquery to filter out the Query objects that do not have a
64
+ # SearchPromotion instead of using .filter(editors_picks__isnull=False).
65
+ # The latter would use a JOIN which would result in duplicate rows before
66
+ # the sum is calculated, causing the sum to be incorrect.
67
+ has_promotions = SearchPromotion.objects.values_list("query_id", flat=True)
68
+ queryset = self.model.objects.filter(pk__in=has_promotions)
69
+
70
+ # Prevent N+1 queries by annotating the sum instead of using the
71
+ # Query.hits property and prefetching the related editors_picks.
72
+ queryset = queryset.annotate(
73
+ views=functions.Coalesce(Sum("daily_hits__hits"), 0)
74
+ ).prefetch_related("editors_picks", "editors_picks__page")
75
+ return queryset
76
+
77
+ def get_breadcrumbs_items(self):
78
+ breadcrumbs = super().get_breadcrumbs_items()
79
+ breadcrumbs[-1]["label"] = self.get_page_title()
80
+ return breadcrumbs
81
+
82
+
83
+ class SearchPromotionCreateEditMixin:
84
+ model = Query
85
+ permission_policy = ModelPermissionPolicy(SearchPromotion)
86
+ index_url_name = "wagtailsearchpromotions:index"
87
+ edit_url_name = "wagtailsearchpromotions:edit"
88
+ form_class = forms.QueryForm
89
+ header_icon = "pick"
90
+ _show_breadcrumbs = True
91
+ page_subtitle = gettext_lazy("Promoted search result")
92
+
93
+ def get_success_message(self, instance=None):
94
+ return self.success_message % {"query": instance}
95
+
96
+ def get_error_message(self):
97
+ if formset_errors := self.searchpicks_formset.non_form_errors():
98
+ # formset level error (e.g. no forms submitted)
99
+ return " ".join(error for error in formset_errors)
100
+ return super().get_error_message()
101
+
102
+ def get_breadcrumbs_items(self):
103
+ breadcrumbs = super().get_breadcrumbs_items()
104
+ breadcrumbs[-2]["label"] = IndexView.page_title
105
+ return breadcrumbs
106
+
107
+ def get_context_data(self, **kwargs):
108
+ context = super().get_context_data(**kwargs)
109
+ context["searchpicks_formset"] = self.searchpicks_formset
110
+ context["media"] += self.searchpicks_formset.media
111
+ return context
112
+
113
+ def save_searchpicks(self, query, new_query):
114
+ searchpicks_formset = self.searchpicks_formset
115
+ if not searchpicks_formset.is_valid():
116
+ return False
117
+
118
+ # Set sort_order
119
+ for i, form in enumerate(searchpicks_formset.ordered_forms):
120
+ form.instance.sort_order = i
121
+
122
+ # Make sure the form is marked as changed so it gets saved with the new order
123
+ form.has_changed = lambda: True
124
+
125
+ # log deleted items before saving, otherwise we lose their IDs
126
+ items_for_deletion = [
127
+ form.instance
128
+ for form in searchpicks_formset.deleted_forms
129
+ if form.instance.pk
130
+ ]
131
+ with transaction.atomic():
132
+ for search_pick in items_for_deletion:
133
+ log(search_pick, "wagtail.delete")
134
+
135
+ searchpicks_formset.save()
136
+
137
+ for search_pick in searchpicks_formset.new_objects:
138
+ log(search_pick, "wagtail.create")
139
+
140
+ # If query was changed, move all search picks to the new query
141
+ if query != new_query:
142
+ searchpicks_formset.get_queryset().update(query=new_query)
143
+ # log all items in the formset as having changed
144
+ for search_pick, changed_fields in searchpicks_formset.changed_objects:
145
+ log(search_pick, "wagtail.edit")
146
+ else:
147
+ # only log objects with actual changes
148
+ for search_pick, changed_fields in searchpicks_formset.changed_objects:
149
+ if changed_fields:
150
+ log(search_pick, "wagtail.edit")
151
+
152
+ return True
153
+
154
+ @cached_property
155
+ def searchpicks_formset(self):
156
+ if self.request.method == "POST":
157
+ return forms.SearchPromotionsFormSet(
158
+ self.request.POST, instance=self.object
159
+ )
160
+ return forms.SearchPromotionsFormSet(instance=self.object)
161
+
162
+ def form_valid(self, form):
163
+ self.form = form
164
+ new_query = Query.get(form.cleaned_data["query_string"])
165
+ if not self.object:
166
+ self.object = new_query
167
+
168
+ if self.save_searchpicks(self.object, new_query):
169
+ messages.success(
170
+ self.request,
171
+ self.get_success_message(self.object),
172
+ buttons=self.get_success_buttons(),
173
+ )
174
+ return redirect(self.index_url_name)
175
+
176
+ return super().form_invalid(form)
177
+
178
+
179
+ class CreateView(SearchPromotionCreateEditMixin, generic.CreateView):
180
+ success_message = gettext_lazy("Editor's picks for '%(query)s' created.")
181
+ error_message = gettext_lazy("Recommendations have not been created due to errors")
182
+ template_name = "wagtailsearchpromotions/add.html"
183
+ add_url_name = "wagtailsearchpromotions:add"
184
+
185
+
186
+ class EditView(SearchPromotionCreateEditMixin, generic.EditView):
187
+ pk_url_kwarg = "query_id"
188
+ context_object_name = "query"
189
+ delete_url_name = "wagtailsearchpromotions:delete"
190
+ success_message = gettext_lazy("Editor's picks for '%(query)s' updated.")
191
+ error_message = gettext_lazy("Recommendations have not been saved due to errors")
192
+ template_name = "wagtailsearchpromotions/edit.html"
193
+
194
+
195
+ class DeleteView(generic.DeleteView):
196
+ model = Query
197
+ permission_policy = ModelPermissionPolicy(SearchPromotion)
198
+ pk_url_kwarg = "query_id"
199
+ context_object_name = "query"
200
+ success_message = gettext_lazy("Editor's picks deleted.")
201
+ index_url_name = "wagtailsearchpromotions:index"
202
+ delete_url_name = "wagtailsearchpromotions:delete"
203
+ header_icon = "pick"
204
+ template_name = "wagtailsearchpromotions/confirm_delete.html"
205
+
206
+ def delete_action(self):
207
+ editors_picks = self.object.editors_picks.all()
208
+ with transaction.atomic():
209
+ for search_pick in editors_picks:
210
+ log(search_pick, "wagtail.delete")
211
+ editors_picks.delete()
212
+
213
+
214
+ def chooser(request, get_results=False):
215
+ # Get most popular queries
216
+ queries = models.Query.get_most_popular()
217
+
218
+ # If searching, filter results by query string
219
+ if "q" in request.GET:
220
+ searchform = SearchForm(request.GET)
221
+ if searchform.is_valid():
222
+ query_string = searchform.cleaned_data["q"]
223
+ queries = queries.filter(
224
+ query_string__icontains=normalise_query_string(query_string)
225
+ )
226
+ else:
227
+ searchform = SearchForm()
228
+
229
+ paginator = Paginator(queries, per_page=10)
230
+ try:
231
+ queries = paginator.page(request.GET.get("p", 1))
232
+ except InvalidPage:
233
+ raise Http404
234
+
235
+ # Render
236
+ if get_results:
237
+ return TemplateResponse(
238
+ request,
239
+ "wagtailsearchpromotions/queries/chooser/results.html",
240
+ {
241
+ "queries": queries,
242
+ },
243
+ )
244
+ else:
245
+ return render_modal_workflow(
246
+ request,
247
+ "wagtailsearchpromotions/queries/chooser/chooser.html",
248
+ None,
249
+ {
250
+ "queries": queries,
251
+ "searchform": searchform,
252
+ },
253
+ json_data={"step": "chooser"},
254
+ )
255
+
256
+
257
+ def chooserresults(request):
258
+ return chooser(request, get_results=True)
@@ -7,7 +7,7 @@ from wagtail.admin.admin_url_finder import (
7
7
  ModelAdminURLFinder,
8
8
  register_admin_url_finder,
9
9
  )
10
- from wagtail.admin.menu import MenuItem
10
+ from wagtail.admin.menu import AdminOnlyMenuItem, MenuItem
11
11
  from wagtail.contrib.search_promotions import admin_urls
12
12
  from wagtail.permission_policies import ModelPermissionPolicy
13
13
 
@@ -41,6 +41,17 @@ def register_search_picks_menu_item():
41
41
  )
42
42
 
43
43
 
44
+ @hooks.register("register_reports_menu_item")
45
+ def register_query_search_report_menu_item():
46
+ return AdminOnlyMenuItem(
47
+ _("Search terms"),
48
+ reverse("wagtailsearchpromotions:search_terms"),
49
+ name="search-terms",
50
+ icon_name="search",
51
+ order=1300,
52
+ )
53
+
54
+
44
55
  @hooks.register("register_permissions")
45
56
  def register_permissions():
46
57
  return Permission.objects.filter(
@@ -8,6 +8,7 @@
8
8
  # Bashar Al-Abdulhadi, 2020
9
9
  # Bashar Al-Abdulhadi, 2020
10
10
  # Khaled Arnaout <khaledarnaout@live.com>, 2018
11
+ # Tarek Berkane, 2025
11
12
  # Younes Oumakhou, 2022
12
13
  # Younes Oumakhou, 2022
13
14
  # abdulaziz alfuhigi <abajall@gmail.com>, 2016
@@ -17,7 +18,7 @@ msgstr ""
17
18
  "Report-Msgid-Bugs-To: \n"
18
19
  "POT-Creation-Date: 2024-10-21 17:53+0100\n"
19
20
  "PO-Revision-Date: 2016-03-01 19:20+0000\n"
20
- "Last-Translator: abdulaziz alfuhigi <abajall@gmail.com>, 2016\n"
21
+ "Last-Translator: Tarek Berkane, 2025\n"
21
22
  "Language-Team: Arabic (http://app.transifex.com/torchbox/wagtail/language/"
22
23
  "ar/)\n"
23
24
  "MIME-Version: 1.0\n"
@@ -30,6 +31,10 @@ msgstr ""
30
31
  msgid "default"
31
32
  msgstr "الإفتراضي"
32
33
 
34
+ #, python-format
35
+ msgid "%(site_setting)s for %(site)s"
36
+ msgstr " %(site_setting)s الخاص ب%(site)s"
37
+
33
38
  msgid "Site"
34
39
  msgstr "موقع"
35
40
 
@@ -8,7 +8,7 @@ msgid ""
8
8
  msgstr ""
9
9
  "Project-Id-Version: PACKAGE VERSION\n"
10
10
  "Report-Msgid-Bugs-To: \n"
11
- "POT-Creation-Date: 2024-10-21 17:53+0100\n"
11
+ "POT-Creation-Date: 2025-01-20 17:59+0000\n"
12
12
  "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13
13
  "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
14
14
  "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -40,11 +40,11 @@ msgstr ""
40
40
  msgid "This setting could not be opened because there is no site defined."
41
41
  msgstr ""
42
42
 
43
- #: views.py:84
43
+ #: views.py:85
44
44
  msgid "The setting could not be saved due to errors."
45
45
  msgstr ""
46
46
 
47
- #: views.py:152
47
+ #: views.py:138
48
48
  #, python-format
49
49
  msgid "%(setting_type)s updated."
50
50
  msgstr ""
@@ -1,5 +1,5 @@
1
1
  {% extends "wagtailadmin/generic/edit.html" %}
2
- {% load i18n wagtailadmin_tags %}
2
+ {% load i18n %}
3
3
 
4
4
  {% block before_form %}
5
5
  {% if site_switcher %}
@@ -11,7 +11,3 @@
11
11
  </form>
12
12
  {% endif %}
13
13
  {% endblock %}
14
-
15
- {% block form_content %}
16
- {{ edit_handler.render_form_content }}
17
- {% endblock %}
@@ -233,11 +233,8 @@ class TestGenericSettingEditView(BaseTestGenericSettingView):
233
233
  def test_register_with_icon(self):
234
234
  edit_url = reverse("wagtailsettings:edit", args=("tests", "IconGenericSetting"))
235
235
  edit_response = self.client.get(edit_url, follow=True)
236
-
237
- self.assertContains(
238
- edit_response,
239
- """<svg class="icon icon-icon-setting-tag w-header__glyph" aria-hidden="true"><use href="#icon-icon-setting-tag"></use></svg>""",
240
- )
236
+ soup = self.get_soup(edit_response.content)
237
+ self.assertIsNotNone(soup.select_one("h2 svg use[href='#icon-tag']"))
241
238
 
242
239
  def test_edit_invalid(self):
243
240
  response = self.post(post_data={"foo": "bar"})
@@ -21,4 +21,4 @@ class GenericSettingRegisterTestCase(WagtailTestUtils, TestCase):
21
21
 
22
22
  def test_icon(self):
23
23
  admin = self.client.get(reverse("wagtailadmin_home"))
24
- self.assertContains(admin, "icon-setting-tag")
24
+ self.assertContains(admin, '"tag"')
@@ -230,11 +230,8 @@ class TestSiteSettingEditView(BaseTestSiteSettingView):
230
230
  def test_register_with_icon(self):
231
231
  edit_url = reverse("wagtailsettings:edit", args=("tests", "IconGenericSetting"))
232
232
  edit_response = self.client.get(edit_url, follow=True)
233
-
234
- self.assertContains(
235
- edit_response,
236
- """<svg class="icon icon-icon-setting-tag w-header__glyph" aria-hidden="true"><use href="#icon-icon-setting-tag"></use></svg>""",
237
- )
233
+ soup = self.get_soup(edit_response.content)
234
+ self.assertIsNotNone(soup.select_one("h2 svg use[href='#icon-tag']"))
238
235
 
239
236
  def test_edit_invalid(self):
240
237
  response = self.post(post_data={"foo": "bar"})
@@ -21,4 +21,4 @@ class TestRegister(WagtailTestUtils, TestCase):
21
21
 
22
22
  def test_icon(self):
23
23
  admin = self.client.get(reverse("wagtailadmin_home"))
24
- self.assertContains(admin, "icon-setting-tag")
24
+ self.assertContains(admin, '"tag"')
@@ -81,6 +81,7 @@ def redirect_to_relevant_instance(request, app_name, model_name):
81
81
 
82
82
  class EditView(generic.EditView):
83
83
  template_name = "wagtailsettings/edit.html"
84
+ edit_url_name = "wagtailsettings:edit"
84
85
  error_message = gettext_lazy("The setting could not be saved due to errors.")
85
86
  permission_required = "change"
86
87
 
@@ -92,6 +93,9 @@ class EditView(generic.EditView):
92
93
  self.pk = kwargs.get(self.pk_url_kwarg)
93
94
  super().setup(request, app_name, model_name, *args, **kwargs)
94
95
 
96
+ def get_header_icon(self):
97
+ return registry._model_icons.get(self.model)
98
+
95
99
  def get_object(self, queryset=None):
96
100
  self.site = None
97
101
  if issubclass(self.model, BaseSiteSetting):
@@ -100,12 +104,12 @@ class EditView(generic.EditView):
100
104
  else:
101
105
  return get_object_or_404(self.model, pk=self.pk)
102
106
 
103
- def get_form_class(self):
104
- return get_setting_edit_handler(self.model).get_form_class()
107
+ def get_panel(self):
108
+ return get_setting_edit_handler(self.model)
105
109
 
106
110
  def get_edit_url(self):
107
111
  return reverse(
108
- "wagtailsettings:edit",
112
+ self.edit_url_name,
109
113
  args=(self.app_name, self.model_name, self.pk),
110
114
  )
111
115
 
@@ -121,27 +125,9 @@ class EditView(generic.EditView):
121
125
  site_switcher = None
122
126
  if self.site and Site.objects.count() > 1:
123
127
  site_switcher = SiteSwitchForm(self.site, self.model)
124
- media = context.get("media") + site_switcher.media
125
-
126
- form = self.get_form()
127
-
128
- edit_handler = get_setting_edit_handler(self.model).get_bound_panel(
129
- instance=self.object, request=self.request, form=form
130
- )
131
-
132
- media = form.media + edit_handler.media
133
-
134
- header_icon = registry._model_icons.get(self.model)
135
-
136
- context.update(
137
- {
138
- "edit_handler": edit_handler,
139
- "site_switcher": site_switcher,
140
- "media": media,
141
- "header_icon": header_icon,
142
- }
143
- )
128
+ context["media"] += site_switcher.media
144
129
 
130
+ context["site_switcher"] = site_switcher
145
131
  return context
146
132
 
147
133
  def get_success_url(self):
@@ -8,7 +8,7 @@ msgid ""
8
8
  msgstr ""
9
9
  "Project-Id-Version: PACKAGE VERSION\n"
10
10
  "Report-Msgid-Bugs-To: \n"
11
- "POT-Creation-Date: 2024-10-21 17:53+0100\n"
11
+ "POT-Creation-Date: 2025-01-20 17:59+0000\n"
12
12
  "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13
13
  "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
14
14
  "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -8,7 +8,7 @@ msgid ""
8
8
  msgstr ""
9
9
  "Project-Id-Version: PACKAGE VERSION\n"
10
10
  "Report-Msgid-Bugs-To: \n"
11
- "POT-Creation-Date: 2024-10-21 17:53+0100\n"
11
+ "POT-Creation-Date: 2025-01-20 17:59+0000\n"
12
12
  "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13
13
  "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
14
14
  "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -8,7 +8,7 @@ msgid ""
8
8
  msgstr ""
9
9
  "Project-Id-Version: PACKAGE VERSION\n"
10
10
  "Report-Msgid-Bugs-To: \n"
11
- "POT-Creation-Date: 2024-10-21 17:53+0100\n"
11
+ "POT-Creation-Date: 2025-01-20 17:59+0000\n"
12
12
  "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13
13
  "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
14
14
  "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -558,7 +558,7 @@ class TestTableBlockForm(WagtailTestUtils, SimpleTestCase):
558
558
  self.assertIs(block_3_opts["allowEmpty"], False)
559
559
 
560
560
  def test_adapt(self):
561
- block = TableBlock()
561
+ block = TableBlock(description="A table to display data.")
562
562
 
563
563
  block.set_name("test_tableblock")
564
564
  js_args = FieldBlockAdapter().js_args(block)
@@ -569,8 +569,11 @@ class TestTableBlockForm(WagtailTestUtils, SimpleTestCase):
569
569
  js_args[2],
570
570
  {
571
571
  "label": "Test tableblock",
572
+ "description": "A table to display data.",
572
573
  "required": True,
573
574
  "icon": "table",
575
+ "blockDefId": block.definition_prefix,
576
+ "isPreviewable": block.is_previewable,
574
577
  "classname": "w-field w-field--char_field w-field--table_input",
575
578
  "showAddCommentButton": True,
576
579
  "strings": {"ADD_COMMENT": "Add Comment"},
@@ -332,8 +332,11 @@ class TypedTableBlockAdapter(Adapter):
332
332
  def js_args(self, block):
333
333
  meta = {
334
334
  "label": block.label,
335
+ "description": block.get_description(),
335
336
  "required": block.required,
336
337
  "icon": block.meta.icon,
338
+ "blockDefId": block.definition_prefix,
339
+ "isPreviewable": block.is_previewable,
337
340
  "strings": {
338
341
  "CAPTION": _("Caption"),
339
342
  "CAPTION_HELP_TEXT": _(
@@ -8,7 +8,7 @@ msgid ""
8
8
  msgstr ""
9
9
  "Project-Id-Version: PACKAGE VERSION\n"
10
10
  "Report-Msgid-Bugs-To: \n"
11
- "POT-Creation-Date: 2024-10-21 17:53+0100\n"
11
+ "POT-Creation-Date: 2025-01-20 17:59+0000\n"
12
12
  "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13
13
  "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
14
14
  "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -18,40 +18,40 @@ msgstr ""
18
18
  "Content-Transfer-Encoding: 8bit\n"
19
19
  "Plural-Forms: nplurals=2; plural=(n != 1);\n"
20
20
 
21
- #: blocks.py:338
21
+ #: blocks.py:341
22
22
  msgid "Caption"
23
23
  msgstr ""
24
24
 
25
- #: blocks.py:340
25
+ #: blocks.py:343
26
26
  msgid ""
27
27
  "A heading that identifies the overall topic of the table, and is useful for "
28
28
  "screen reader users."
29
29
  msgstr ""
30
30
 
31
- #: blocks.py:342
31
+ #: blocks.py:345
32
32
  msgid "Add column"
33
33
  msgstr ""
34
34
 
35
- #: blocks.py:343
35
+ #: blocks.py:346
36
36
  msgid "Add row"
37
37
  msgstr ""
38
38
 
39
- #: blocks.py:344
39
+ #: blocks.py:347
40
40
  msgid "Column heading"
41
41
  msgstr ""
42
42
 
43
- #: blocks.py:345
43
+ #: blocks.py:348
44
44
  msgid "Insert column"
45
45
  msgstr ""
46
46
 
47
- #: blocks.py:346
47
+ #: blocks.py:349
48
48
  msgid "Delete column"
49
49
  msgstr ""
50
50
 
51
- #: blocks.py:347
51
+ #: blocks.py:350
52
52
  msgid "Insert row"
53
53
  msgstr ""
54
54
 
55
- #: blocks.py:348
55
+ #: blocks.py:351
56
56
  msgid "Delete row"
57
57
  msgstr ""