Browse Source

Enforce the use of a single string formatting mechanism for translation source strings

Close #9377
Loic Teixeira 2 years ago
parent
commit
5c1c2c8f53
60 changed files with 550 additions and 337 deletions
  1. 1 0
      .circleci/config.yml
  2. 5 0
      .pre-commit-config.yaml
  3. 79 0
      .semgrep.yml
  4. 1 0
      Makefile
  5. 3 3
      client/src/components/Minimap/MinimapItem.tsx
  6. 3 3
      client/src/components/PageExplorer/PageExplorerItem.tsx
  7. 2 2
      client/src/components/Sidebar/menu/SubMenuItem.tsx
  8. 3 3
      client/src/entrypoints/admin/page-editor.js
  9. 2 2
      client/src/includes/bulk-actions.js
  10. 4 3
      client/src/includes/sidePanel.js
  11. 1 1
      docs/contributing/general_guidelines.md
  12. 32 0
      docs/contributing/translations.md
  13. 1 1
      docs/contributing/ui_guidelines.md
  14. 2 2
      docs/extending/custom_bulk_actions.md
  15. 1 0
      setup.py
  16. 6 4
      wagtail/admin/action_menu.py
  17. 3 3
      wagtail/admin/forms/auth.py
  18. 2 2
      wagtail/admin/forms/pages.py
  19. 5 7
      wagtail/admin/forms/tags.py
  20. 4 3
      wagtail/admin/forms/workflows.py
  21. 12 12
      wagtail/admin/localization.py
  22. 1 1
      wagtail/admin/templatetags/wagtailadmin_tags.py
  23. 3 3
      wagtail/admin/views/collections.py
  24. 8 8
      wagtail/admin/views/generic/mixins.py
  25. 24 18
      wagtail/admin/views/generic/models.py
  26. 2 3
      wagtail/admin/views/pages/convert_alias.py
  27. 7 7
      wagtail/admin/views/pages/copy.py
  28. 8 10
      wagtail/admin/views/pages/create.py
  29. 2 1
      wagtail/admin/views/pages/delete.py
  30. 44 34
      wagtail/admin/views/pages/edit.py
  31. 2 1
      wagtail/admin/views/pages/lock.py
  32. 19 15
      wagtail/admin/views/pages/moderation.py
  33. 5 3
      wagtail/admin/views/pages/move.py
  34. 4 2
      wagtail/admin/views/pages/unpublish.py
  35. 5 5
      wagtail/admin/views/pages/workflow.py
  36. 16 11
      wagtail/admin/views/workflows.py
  37. 1 1
      wagtail/admin/wagtail_hooks.py
  38. 4 2
      wagtail/blocks/list_block.py
  39. 8 4
      wagtail/blocks/stream_block.py
  40. 7 7
      wagtail/contrib/forms/forms.py
  41. 3 1
      wagtail/contrib/forms/panels.py
  42. 7 7
      wagtail/contrib/modeladmin/helpers/button.py
  43. 19 20
      wagtail/contrib/modeladmin/views.py
  44. 15 5
      wagtail/contrib/redirects/views.py
  45. 2 2
      wagtail/contrib/search_promotions/views.py
  46. 3 3
      wagtail/contrib/simple_translation/forms.py
  47. 14 12
      wagtail/contrib/simple_translation/views.py
  48. 12 5
      wagtail/documents/views/documents.py
  49. 23 18
      wagtail/images/fields.py
  50. 12 6
      wagtail/images/views/images.py
  51. 3 3
      wagtail/locales/views.py
  52. 31 17
      wagtail/locks.py
  53. 14 6
      wagtail/models/__init__.py
  54. 3 2
      wagtail/models/audit_log.py
  55. 3 3
      wagtail/sites/views.py
  56. 6 6
      wagtail/snippets/bulk_actions/delete.py
  57. 29 25
      wagtail/snippets/views/snippets.py
  58. 3 3
      wagtail/snippets/widgets.py
  59. 3 3
      wagtail/users/views/groups.py
  60. 3 3
      wagtail/users/views/users.py

+ 1 - 0
.circleci/config.yml

@@ -22,6 +22,7 @@ jobs:
       - run: pipenv run flake8
       - run: pipenv run isort --check-only --diff .
       - run: pipenv run black --target-version py37 --check --diff .
+      - run: pipenv run semgrep --config .semgrep.yml --error .
       - run: git ls-files '*.html' | xargs pipenv run djhtml --check
       - run: pipenv run curlylint --parse-only wagtail
       - run: pipenv run doc8 docs

+ 5 - 0
.pre-commit-config.yaml

@@ -50,3 +50,8 @@ repos:
     rev: v1.4.13
     hooks:
       - id: djhtml
+  - repo: https://github.com/returntocorp/semgrep
+    rev: v0.117.0
+    hooks:
+      - id: semgrep
+        args: ['--config', '.semgrep.yml', '--error']

+ 79 - 0
.semgrep.yml

@@ -0,0 +1,79 @@
+rules:
+  - id: translation-no-new-style-formatting
+    patterns:
+      - pattern: $FUNC("$STRING_ID", ...)
+      - metavariable-regex:
+          metavariable: $FUNC
+          regex: '_|gettext|gettext_lazy|ngettext|ngettext_lazy'
+      - metavariable-regex:
+          metavariable: $STRING_ID
+          regex: ".*({(\\d*|[\\w_]*)}).*"
+    message: |
+      Do not use str.format style formatting for translations.
+      Use printf style formatting with named placeholders instead.
+      For example, do `_("Hello %(name)s") % {"name": "Wagtail"}`
+      instead of `_("Hello {name}").format(name="Wagtail")`.
+      See https://docs.wagtail.org/en/latest/contributing/translations.html#marking-strings-for-translation for more information.
+    languages: [python, javascript, typescript]
+    severity: ERROR
+  - id: translation-no-f-strings
+    patterns:
+      - pattern: $FUNC(f"...", ...)
+      - metavariable-regex:
+          metavariable: $FUNC
+          regex: '_|gettext|gettext_lazy|ngettext|ngettext_lazy'
+    message: >
+      Do not use formatted string literals for translations.
+      Use printf style formatting with named placeholders instead.
+      For example, do `_("Hello %(name)s") % {"name": "Wagtail"}`
+      instead of `_(f"Hello {name}")`.
+      See https://docs.wagtail.org/en/latest/contributing/translations.html#marking-strings-for-translation for more information.
+    languages: [python]
+    severity: ERROR
+  - id: translation-no-anonymous-arguments
+    patterns:
+      - pattern: $FUNC("$STRING_ID", ...)
+      - metavariable-regex:
+          metavariable: $FUNC
+          regex: '_|gettext|gettext_lazy|ngettext|ngettext_lazy'
+      - metavariable-regex:
+          metavariable: $STRING_ID
+          regex: ".*%\\w.*"
+    message: >
+      Do not use anonymous placeholders for translations.
+      Use printf style formatting with named placeholders instead.
+      For example, do `_("Hello %(name)s") % {"name": "Wagtail"}`
+      instead of `_("Hello %s") % "Wagtail"`.
+      See https://docs.wagtail.org/en/latest/contributing/translations.html#marking-strings-for-translation for more information.
+    languages: [python, javascript, typescript]
+    severity: ERROR
+  - id: translation-no-format-within-gettext-python
+    patterns:
+      - pattern: $FUNC("..." % ..., ...)
+      - metavariable-regex:
+          metavariable: $FUNC
+          regex: '_|gettext|gettext_lazy|ngettext|ngettext_lazy'
+    message: >
+      Do not format string before translations
+      or the interpolated value will be part of the key.
+      Instead, interpolate after the call to gettext.
+      For example, do `_("Hello %(name)s") % {"name": "Wagtail"}`
+      instead of `_("Hello %(name)s" % {"name": "Wagtail"} )`.
+      See https://docs.wagtail.org/en/latest/contributing/translations.html#marking-strings-for-translation for more information.
+    languages: [python]
+    severity: ERROR
+  - id: translation-no-format-within-gettext-javascript
+    patterns:
+      - pattern: $FUNC("...".replace(...), ...)
+      - metavariable-regex:
+          metavariable: $FUNC
+          regex: '_|gettext|gettext_lazy|ngettext|ngettext_lazy'
+    message: >
+      Do not format string before translations
+      or the interpolated value will be part of the key.
+      Instead, interpolate after the call to gettext.
+      For example, do `_("Hello %(name)s") % {"name": "Wagtail"}`
+      instead of `_("Hello %(name)s" % {"name": "Wagtail"} )`.
+      See https://docs.wagtail.org/en/latest/contributing/translations.html#marking-strings-for-translation for more information.
+    languages: [javascript, typescript]
+    severity: ERROR

+ 1 - 0
Makefile

@@ -21,6 +21,7 @@ lint-server:
 	black --target-version py37 --check --diff .
 	flake8
 	isort --check-only --diff .
+	semgrep --config .semgrep.yml --error .
 	curlylint --parse-only wagtail
 	git ls-files '*.html' | xargs djhtml --check
 

+ 3 - 3
client/src/components/Minimap/MinimapItem.tsx

@@ -36,10 +36,10 @@ const MinimapItem: React.FunctionComponent<MinimapItemProps> = ({
   const { href, label, icon, required, errorCount, level } = item;
   const hasError = errorCount > 0;
   const errorsLabel = ngettext(
-    '{num} error',
-    '{num} errors',
+    '%(num)s error',
+    '%(num)s errors',
     errorCount,
-  ).replace('{num}', `${errorCount}`);
+  ).replace('%(num)s', `${errorCount}`);
   const text = label.length > 26 ? `${label.substring(0, 26)}…` : label;
   return (
     <a

+ 3 - 3
client/src/components/PageExplorer/PageExplorerItem.tsx

@@ -59,7 +59,7 @@ const PageExplorerItem: React.FunctionComponent<PageExplorerItemProps> = ({
       >
         <Icon
           name="edit"
-          title={gettext("Edit '{title}'").replace('{title}', title || '')}
+          title={gettext("Edit '%(title)s'").replace('%(title)s', title || '')}
           className="icon--item-action"
         />
       </Button>
@@ -72,8 +72,8 @@ const PageExplorerItem: React.FunctionComponent<PageExplorerItemProps> = ({
         >
           <Icon
             name="arrow-right"
-            title={gettext("View child pages of '{title}'").replace(
-              '{title}',
+            title={gettext("View child pages of '%(title)s'").replace(
+              '%(title)s',
               title || '',
             )}
             className="icon--item-action"

+ 2 - 2
client/src/components/Sidebar/menu/SubMenuItem.tsx

@@ -97,8 +97,8 @@ export const SubMenuItem: React.FunctionComponent<SubMenuItemProps> = ({
               <span className="w-sr-only">
                 {dismissibleCount === 1
                   ? gettext('(1 new item in this menu)')
-                  : gettext('({number} new items in this menu)').replace(
-                      '{number}',
+                  : gettext('(%(number)s new items in this menu)').replace(
+                      '%(number)s',
                       `${dismissibleCount}`,
                     )}
               </span>

+ 3 - 3
client/src/entrypoints/admin/page-editor.js

@@ -70,10 +70,10 @@ function initErrorDetection() {
       .find('[data-tabs-errors-statement]')
       .text(
         ngettext(
-          '({errorCount} error)',
-          '({errorCount} errors)',
+          '(%(errorCount)s error)',
+          '(%(errorCount)s errors)',
           errorCount,
-        ).replace('{errorCount}', errorCount),
+        ).replace('%(errorCount)s', errorCount),
       );
   });
 }

+ 2 - 2
client/src/includes/bulk-actions.js

@@ -166,12 +166,12 @@ function onSelectIndividualCheckbox(e) {
       numObjectsSelected = getStringForListing('SINGULAR');
     } else if (numCheckedObjects === checkedState.numObjects) {
       numObjectsSelected = getStringForListing('ALL').replace(
-        '{0}',
+        '%(objects)s',
         numCheckedObjects,
       );
     } else {
       numObjectsSelected = getStringForListing('PLURAL').replace(
-        '{0}',
+        '%(objects)s',
         numCheckedObjects,
       );
     }

+ 4 - 3
client/src/includes/sidePanel.js

@@ -133,10 +133,11 @@ export default function initSidePanel() {
       parseInt(Math.max(minWidth, Math.min(targetWidth, maxWidth)), 10) ||
       width;
 
-    const valueText = ngettext('{num} pixel', '{num} pixels', newWidth).replace(
-      '{num}',
+    const valueText = ngettext(
+      '%(num)s pixel',
+      '%(num)s pixels',
       newWidth,
-    );
+    ).replace('%(num)s', newWidth);
 
     sidePanelWrapper.style.width = `${newWidth}px`;
     widthInput.value = 100 - ((newWidth - minWidth) / range) * 100;

+ 1 - 1
docs/contributing/general_guidelines.md

@@ -2,7 +2,7 @@
 
 ## Language
 
-British English is preferred for user-facing text; this text should also be marked for translation (using the `django.utils.translation.gettext` function and `{% trans %}` template tag, for example). However, identifiers within code should use American English if the British or international spelling would conflict with built-in language keywords; for example, CSS code should consistently use the spelling `color` to avoid inconsistencies like `background-color: $colour-red`.
+British English is preferred for user-facing text; this text should also be marked for translation (using the `django.utils.translation.gettext` function and `{% translate %}` template tag, for example). However, identifiers within code should use American English if the British or international spelling would conflict with built-in language keywords; for example, CSS code should consistently use the spelling `color` to avoid inconsistencies like `background-color: $colour-red`.
 
 ### Latin phrases and abbreviations
 

+ 32 - 0
docs/contributing/translations.md

@@ -36,6 +36,38 @@ These new translations are imported into Wagtail for any subsequent RC and the f
 -   To translate a project, select it and enter your translation in the translation panel
 -   Save the translation using the translation button on the panel
 
+## Marking strings for translation
+
+In code, strings can be marked for translation with using Django's [translation system](django:topics/i18n/translation), using `gettext` or `gettext_lazy` in Python and `blocktranslate` and `translate` in templates.
+
+In both Python and templates, make sure to always use named placeholder. In addition, in Python, only use the printf style formatting. This is to ensure compatibility with Transifex and help translators in their work.
+
+For example:
+
+```python
+from django.utils.translation import gettext_lazy as _
+
+# Do this: printf style + named placeholders
+_("Page %(page_title)s with status %(status)s") % {"page_title": page.title, "status": page.status_string}
+
+# Do not use anonymous placeholders
+_("Page %s with status %s") % (page.title, page.status_string)
+_("Page {} with status {}").format(page.title, page.status_string)
+
+# Do not use positional placeholders
+_("Page {0} with status {1}").format(page.title, page.status_string)
+
+# Do not use new style
+_("Page {page_title} with status {status}").format(page_title=page.title, status=page.status_string)
+
+# Do not interpolate within the gettext call
+_("Page %(page_title)s with status %(status)s" % {"page_title": page.title, "status": page.status_string})
+_("Page {page_title} with status {status}".format(page_title=page.title, status=page.status_string))
+
+# Do not use f-string
+_(f"Page {page.title} with status {page.status_string}")
+```
+
 ## Additional resources
 
 -   [](django:topics/i18n/translation)

+ 1 - 1
docs/contributing/ui_guidelines.md

@@ -46,4 +46,4 @@ We use [Prettier](https://prettier.io/) for formatting and [ESLint](https://esli
 
 This is an area of active improvement for Wagtail, with [ongoing discussions](https://github.com/wagtail/wagtail/discussions/8017).
 
--   Always use the `trimmed` attribute on `blocktrans` tags to prevent unnecessary whitespace from being added to the translation strings.
+-   Always use the `trimmed` attribute on `blocktranslate` tags to prevent unnecessary whitespace from being added to the translation strings.

+ 2 - 2
docs/extending/custom_bulk_actions.md

@@ -44,7 +44,7 @@ An example of a confirmation template is as follows:
 {% extends 'wagtailadmin/bulk_actions/confirmation/base.html' %}
 {% load i18n wagtailadmin_tags %}
 
-{% block titletag %}{% blocktrans trimmed count counter=items|length %}Delete 1 item{% plural %}Delete {{ counter }} items{% endblocktrans %}{% endblock %}
+{% block titletag %}{% blocktranslate trimmed count counter=items|length %}Delete 1 item{% plural %}Delete {{ counter }} items{% endblocktranslate %}{% endblock %}
 
 {% block header %}
     {% trans "Delete" as del_str %}
@@ -66,7 +66,7 @@ An example of a confirmation template is as follows:
 
 {% block items_with_no_access %}
 
-{% blocktrans trimmed asvar no_access_msg count counter=items_with_no_access|length %}You don't have permission to delete this item{% plural %}You don't have permission to delete these items{% endblocktrans %}
+{% blocktranslate trimmed asvar no_access_msg count counter=items_with_no_access|length %}You don't have permission to delete this item{% plural %}You don't have permission to delete these items{% endblocktranslate %}
 {% include './list_items_with_no_access.html' with items=items_with_no_access no_access_msg=no_access_msg %}
 
 {% endblock items_with_no_access %}

+ 1 - 0
setup.py

@@ -60,6 +60,7 @@ testing_extras = [
     "flake8-print==5.0.0",
     "doc8==0.8.1",
     "flake8-assertive==2.0.0",
+    "semgrep",
     # For templates linting
     "curlylint==0.13.1",
     # For template indenting

+ 6 - 4
wagtail/admin/action_menu.py

@@ -122,13 +122,15 @@ class SubmitForModerationMenuItem(ActionMenuItem):
             workflow_state
             and workflow_state.status == workflow_state.STATUS_NEEDS_CHANGES
         ):
-            context["label"] = _("Resubmit to {}").format(
-                workflow_state.current_task_state.task.name
-            )
+            context["label"] = _("Resubmit to %(task_name)s") % {
+                "task_name": workflow_state.current_task_state.task.name
+            }
         elif page:
             workflow = page.get_workflow()
             if workflow:
-                context["label"] = _("Submit to {}").format(workflow.name)
+                context["label"] = _("Submit to %(workflow_name)s") % {
+                    "workflow_name": workflow.name
+                }
         return context
 
 

+ 3 - 3
wagtail/admin/forms/auth.py

@@ -20,9 +20,9 @@ class LoginForm(AuthenticationForm):
 
     def __init__(self, request=None, *args, **kwargs):
         super().__init__(request=request, *args, **kwargs)
-        self.fields["username"].widget.attrs["placeholder"] = (
-            gettext_lazy("Enter your %s") % self.username_field.verbose_name
-        )
+        self.fields["username"].widget.attrs["placeholder"] = gettext_lazy(
+            "Enter your %(username_field_name)s"
+        ) % {"username_field_name": self.username_field.verbose_name}
         self.fields["username"].widget.attrs["autofocus"] = ""
 
     @property

+ 2 - 2
wagtail/admin/forms/pages.py

@@ -101,9 +101,9 @@ class CopyForm(forms.Form):
             self._errors["new_slug"] = self.error_class(
                 [
                     _(
-                        'This slug is already in use within the context of its parent page "%s"'
+                        'This slug is already in use within the context of its parent page "%(parent_page_title)s"'
                     )
-                    % parent_page
+                    % {"parent_page_title": parent_page}
                 ]
             )
             # The slug is no longer valid, hence remove it from cleaned_data

+ 5 - 7
wagtail/admin/forms/tags.py

@@ -45,13 +45,11 @@ class TagField(TaggitTagField):
                 value_too_long += val
         if value_too_long:
             raise ValidationError(
-                _(
-                    "Tag(s) %(value_too_long)s are over %(max_tag_length)d characters"
-                    % {
-                        "value_too_long": value_too_long,
-                        "max_tag_length": max_tag_length,
-                    }
-                )
+                _("Tag(s) %(value_too_long)s are over %(max_tag_length)d characters")
+                % {
+                    "value_too_long": value_too_long,
+                    "max_tag_length": max_tag_length,
+                }
             )
 
         if not self.free_tagging:

+ 4 - 3
wagtail/admin/forms/workflows.py

@@ -96,9 +96,10 @@ class WorkflowPageForm(forms.ModelForm):
                 self.add_error(
                     "page",
                     ValidationError(
-                        _("This page already has workflow '{0}' assigned.").format(
-                            existing_workflow
-                        ),
+                        _(
+                            "This page already has workflow '%(workflow_name)s' assigned."
+                        )
+                        % {"workflow_name": existing_workflow},
                         code="existing_workflow",
                     ),
                 )

+ 12 - 12
wagtail/admin/localization.py

@@ -57,38 +57,38 @@ def get_js_translation_strings():
         "BULK_ACTIONS": {
             "PAGE": {
                 "SINGULAR": _("1 page selected"),
-                "PLURAL": _("{0} pages selected"),
-                "ALL": _("All {0} pages on this screen selected"),
+                "PLURAL": _("%(objects)s pages selected"),
+                "ALL": _("All %(objects)s pages on this screen selected"),
                 "ALL_IN_LISTING": _("All pages in listing selected"),
             },
             "DOCUMENT": {
                 "SINGULAR": _("1 document selected"),
-                "PLURAL": _("{0} documents selected"),
-                "ALL": _("All {0} documents on this screen selected"),
+                "PLURAL": _("%(objects)s documents selected"),
+                "ALL": _("All %(objects)s documents on this screen selected"),
                 "ALL_IN_LISTING": _("All documents in listing selected"),
             },
             "IMAGE": {
                 "SINGULAR": _("1 image selected"),
-                "PLURAL": _("{0} images selected"),
-                "ALL": _("All {0} images on this screen selected"),
+                "PLURAL": _("%(objects)s images selected"),
+                "ALL": _("All %(objects)s images on this screen selected"),
                 "ALL_IN_LISTING": _("All images in listing selected"),
             },
             "USER": {
                 "SINGULAR": _("1 user selected"),
-                "PLURAL": _("{0} users selected"),
-                "ALL": _("All {0} users on this screen selected"),
+                "PLURAL": _("%(objects)s users selected"),
+                "ALL": _("All %(objects)s users on this screen selected"),
                 "ALL_IN_LISTING": _("All users in listing selected"),
             },
             "SNIPPET": {
                 "SINGULAR": _("1 snippet selected"),
-                "PLURAL": _("{0} snippets selected"),
-                "ALL": _("All {0} snippets on this screen selected"),
+                "PLURAL": _("%(objects)s snippets selected"),
+                "ALL": _("All %(objects)s snippets on this screen selected"),
                 "ALL_IN_LISTING": _("All snippets in listing selected"),
             },
             "ITEM": {
                 "SINGULAR": _("1 item selected"),
-                "PLURAL": _("{0} items selected"),
-                "ALL": _("All {0} items on this screen selected"),
+                "PLURAL": _("%(objects)s items selected"),
+                "ALL": _("All %(objects)s items on this screen selected"),
                 "ALL_IN_LISTING": _("All items in listing selected"),
             },
         },

+ 1 - 1
wagtail/admin/templatetags/wagtailadmin_tags.py

@@ -737,7 +737,7 @@ def timesince_last_update(
                     "user_display_name": user_display_name,
                 }
             else:
-                return _("%(time)s") % {"time": time_str}
+                return time_str
     else:
         if use_shorthand:
             # Note: Duplicate code in timesince_simple()

+ 3 - 3
wagtail/admin/views/collections.py

@@ -31,7 +31,7 @@ class Create(CreateView):
     permission_policy = collection_permission_policy
     form_class = CollectionForm
     page_title = gettext_lazy("Add collection")
-    success_message = gettext_lazy("Collection '{0}' created.")
+    success_message = gettext_lazy("Collection '%(object)s' created.")
     add_url_name = "wagtailadmin_collections:add"
     edit_url_name = "wagtailadmin_collections:edit"
     index_url_name = "wagtailadmin_collections:index"
@@ -58,7 +58,7 @@ class Edit(EditView):
     model = Collection
     form_class = CollectionForm
     template_name = "wagtailadmin/collections/edit.html"
-    success_message = gettext_lazy("Collection '{0}' updated.")
+    success_message = gettext_lazy("Collection '%(object)s' updated.")
     error_message = gettext_lazy("The collection could not be saved due to errors.")
     delete_item_label = gettext_lazy("Delete collection")
     edit_url_name = "wagtailadmin_collections:edit"
@@ -134,7 +134,7 @@ class Edit(EditView):
 class Delete(DeleteView):
     permission_policy = collection_permission_policy
     model = Collection
-    success_message = gettext_lazy("Collection '{0}' deleted.")
+    success_message = gettext_lazy("Collection '%(object)s' deleted.")
     index_url_name = "wagtailadmin_collections:index"
     delete_url_name = "wagtailadmin_collections:delete"
     page_title = gettext_lazy("Delete collection")

+ 8 - 8
wagtail/admin/views/generic/mixins.py

@@ -208,23 +208,23 @@ class RevisionsRevertMixin:
 
     def get_success_message(self):
         message = _(
-            "{model_name} '{instance}' has been replaced with version from {timestamp}."
+            "%(model_name)s '%(object)s' has been replaced with version from %(timestamp)s."
         )
         if self.draftstate_enabled and self.action == "publish":
             message = _(
-                "Version from {timestamp} of {model_name} '{instance}' has been published."
+                "Version from %(timestamp)s of %(model_name)s '%(object)s' has been published."
             )
 
             if self.object.go_live_at and self.object.go_live_at > timezone.now():
                 message = _(
-                    "Version from {timestamp} of {model_name} '{instance}' has been scheduled for publishing."
+                    "Version from %(timestamp)s of %(model_name)s '%(object)s' has been scheduled for publishing."
                 )
 
-        return message.format(
-            model_name=capfirst(self.model._meta.verbose_name),
-            instance=self.object,
-            timestamp=self.revision.created_at.strftime("%d %b %Y %H:%M"),
-        )
+        return message % {
+            "model_name": capfirst(self.model._meta.verbose_name),
+            "object": self.object,
+            "timestamp": self.revision.created_at.strftime("%d %b %Y %H:%M"),
+        }
 
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)

+ 24 - 18
wagtail/admin/views/generic/models.py

@@ -403,12 +403,14 @@ class CreateView(
     def get_success_message(self, instance):
         if isinstance(instance, DraftStateMixin) and self.action == "publish":
             if instance.go_live_at and instance.go_live_at > timezone.now():
-                return _("'{0}' created and scheduled for publishing.").format(instance)
-            return _("'{0}' created and published.").format(instance)
+                return _("'%(object)s' created and scheduled for publishing.") % {
+                    "object": instance
+                }
+            return _("'%(object)s' created and published.") % {"object": instance}
 
         if self.success_message is None:
             return None
-        return self.success_message.format(instance)
+        return self.success_message % {"object": instance}
 
     def get_success_buttons(self):
         return [
@@ -630,14 +632,14 @@ class EditView(
     def get_success_message(self):
         if self.draftstate_enabled and self.action == "publish":
             if self.object.go_live_at and self.object.go_live_at > timezone.now():
-                return _("'{0}' updated and scheduled for publishing.").format(
-                    self.object
-                )
-            return _("'{0}' updated and published.").format(self.object)
+                return _("'%(object)s' updated and scheduled for publishing.") % {
+                    "object": self.object
+                }
+            return _("'%(object)s' updated and published.") % {"object": self.object}
 
         if self.success_message is None:
             return None
-        return self.success_message.format(self.object)
+        return self.success_message % {"object": self.object}
 
     def get_success_buttons(self):
         return [
@@ -761,7 +763,7 @@ class DeleteView(
     def get_success_message(self):
         if self.success_message is None:
             return None
-        return self.success_message.format(self.object)
+        return self.success_message % {"object": self.object}
 
     def delete_action(self):
         with transaction.atomic():
@@ -883,7 +885,7 @@ class UnpublishView(HookResponseMixin, TemplateView):
     index_url_name = None
     edit_url_name = None
     unpublish_url_name = None
-    success_message = _("'{object_name}' unpublished.")
+    success_message = _("'%(object)s' unpublished.")
     template_name = "wagtailadmin/shared/confirm_unpublish.html"
 
     def setup(self, request, pk, *args, **kwargs):
@@ -910,7 +912,7 @@ class UnpublishView(HookResponseMixin, TemplateView):
     def get_success_message(self):
         if self.success_message is None:
             return None
-        return self.success_message.format(object_name=str(self.object))
+        return self.success_message % {"object": str(self.object)}
 
     def get_success_buttons(self):
         if self.edit_url_name:
@@ -977,7 +979,9 @@ class RevisionsUnscheduleView(TemplateView):
     edit_url_name = None
     history_url_name = None
     revisions_unschedule_url_name = None
-    success_message = gettext_lazy('Version {revision_id} of "{object}" unscheduled.')
+    success_message = gettext_lazy(
+        'Version %(revision_id)s of "%(object)s" unscheduled.'
+    )
     template_name = "wagtailadmin/shared/revisions/confirm_unschedule.html"
 
     def setup(self, request, pk, revision_id, *args, **kwargs):
@@ -1007,9 +1011,10 @@ class RevisionsUnscheduleView(TemplateView):
     def get_success_message(self):
         if self.success_message is None:
             return None
-        return self.success_message.format(
-            revision_id=self.revision.id, object=self.get_object_display_title()
-        )
+        return self.success_message % {
+            "revision_id": self.revision.id,
+            "object": self.get_object_display_title(),
+        }
 
     def get_success_buttons(self):
         return [
@@ -1031,9 +1036,10 @@ class RevisionsUnscheduleView(TemplateView):
         return reverse(self.history_url_name, args=(quote(self.object.pk),))
 
     def get_page_subtitle(self):
-        return _('revision {revision_id} of "{object}"').format(
-            revision_id=self.revision.id, object=self.get_object_display_title()
-        )
+        return _('revision %(revision_id)s of "%(object)s"') % {
+            "revision_id": self.revision.id,
+            "object": self.get_object_display_title(),
+        }
 
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)

+ 2 - 3
wagtail/admin/views/pages/convert_alias.py

@@ -30,9 +30,8 @@ def convert_alias(request, page_id):
 
             messages.success(
                 request,
-                _("Page '{0}' has been converted into an ordinary page.").format(
-                    page.get_admin_display_title()
-                ),
+                _("Page '%(page_title)s' has been converted into an ordinary page.")
+                % {"page_title": page.get_admin_display_title()},
             )
 
             for fn in hooks.get_hooks("after_convert_alias_page"):

+ 7 - 7
wagtail/admin/views/pages/copy.py

@@ -81,17 +81,17 @@ def copy(request, page_id):
             if form.cleaned_data.get("copy_subpages"):
                 messages.success(
                     request,
-                    _("Page '{0}' and {1} subpages copied.").format(
-                        page.specific_deferred.get_admin_display_title(),
-                        new_page.get_descendants().count(),
-                    ),
+                    _("Page '%(page_title)s' and %(subpages_count)s subpages copied.")
+                    % {
+                        "page_title": page.specific_deferred.get_admin_display_title(),
+                        "subpages_count": new_page.get_descendants().count(),
+                    },
                 )
             else:
                 messages.success(
                     request,
-                    _("Page '{0}' copied.").format(
-                        page.specific_deferred.get_admin_display_title()
-                    ),
+                    _("Page '%(page_title)s' copied.")
+                    % {"page_title": page.specific_deferred.get_admin_display_title()},
                 )
 
             for fn in hooks.get_hooks("after_copy_page"):

+ 8 - 10
wagtail/admin/views/pages/create.py

@@ -179,7 +179,8 @@ class CreateView(TemplateResponseMixin, ContextMixin, HookResponseMixin, View):
         # Notification
         messages.success(
             self.request,
-            _("Page '{0}' created.").format(self.page.get_admin_display_title()),
+            _("Page '%(page_title)s' created.")
+            % {"page_title": self.page.get_admin_display_title()},
         )
 
         response = self.run_hook("after_create_page", self.request, self.page)
@@ -220,9 +221,8 @@ class CreateView(TemplateResponseMixin, ContextMixin, HookResponseMixin, View):
         if self.page.go_live_at and self.page.go_live_at > timezone.now():
             messages.success(
                 self.request,
-                _("Page '{0}' created and scheduled for publishing.").format(
-                    self.page.get_admin_display_title()
-                ),
+                _("Page '%(page_title)s' created and scheduled for publishing.")
+                % {"page_title": self.page.get_admin_display_title()},
                 buttons=[self.get_edit_message_button()],
             )
         else:
@@ -232,9 +232,8 @@ class CreateView(TemplateResponseMixin, ContextMixin, HookResponseMixin, View):
             buttons.append(self.get_edit_message_button())
             messages.success(
                 self.request,
-                _("Page '{0}' created and published.").format(
-                    self.page.get_admin_display_title()
-                ),
+                _("Page '%(page_title)s' created and published.")
+                % {"page_title": self.page.get_admin_display_title()},
                 buttons=buttons,
             )
 
@@ -271,9 +270,8 @@ class CreateView(TemplateResponseMixin, ContextMixin, HookResponseMixin, View):
 
         messages.success(
             self.request,
-            _("Page '{0}' created and submitted for moderation.").format(
-                self.page.get_admin_display_title()
-            ),
+            _("Page '%(page_title)s' created and submitted for moderation.")
+            % {"page_title": self.page.get_admin_display_title()},
             buttons=buttons,
         )
 

+ 2 - 1
wagtail/admin/views/pages/delete.py

@@ -67,7 +67,8 @@ def delete(request, page_id):
 
                 messages.success(
                     request,
-                    _("Page '{0}' deleted.").format(page.get_admin_display_title()),
+                    _("Page '%(page_title)s' deleted.")
+                    % {"page_title": page.get_admin_display_title()},
                 )
 
                 for fn in hooks.get_hooks("after_delete_page"):

+ 44 - 34
wagtail/admin/views/pages/edit.py

@@ -57,14 +57,19 @@ class EditView(TemplateResponseMixin, ContextMixin, HookResponseMixin, View):
 
     def add_save_confirmation_message(self):
         if self.is_reverting:
-            message = _("Page '{0}' has been replaced with version from {1}.").format(
-                self.page.get_admin_display_title(),
-                self.previous_revision.created_at.strftime("%d %b %Y %H:%M"),
-            )
+            message = _(
+                "Page '%(page_title)s' has been replaced "
+                "with version from %(previous_revision_datetime)s."
+            ) % {
+                "page_title": self.page.get_admin_display_title(),
+                "previous_revision_datetime": self.previous_revision.created_at.strftime(
+                    "%d %b %Y %H:%M"
+                ),
+            }
         else:
-            message = _("Page '{0}' has been updated.").format(
-                self.page.get_admin_display_title()
-            )
+            message = _("Page '%(page_title)s' has been updated.") % {
+                "page_title": self.page.get_admin_display_title()
+            }
 
         messages.success(self.request, message)
 
@@ -424,9 +429,9 @@ class EditView(TemplateResponseMixin, ContextMixin, HookResponseMixin, View):
         return self.render_to_response(self.get_context_data())
 
     def add_cancel_workflow_confirmation_message(self):
-        message = _("Workflow on page '{0}' has been cancelled.").format(
-            self.page.get_admin_display_title()
-        )
+        message = _("Workflow on page '%(page_title)s' has been cancelled.") % {
+            "page_title": self.page.get_admin_display_title()
+        }
 
         messages.success(
             self.request,
@@ -578,21 +583,24 @@ class EditView(TemplateResponseMixin, ContextMixin, HookResponseMixin, View):
 
             if self.is_reverting:
                 message = _(
-                    "Version from {0} of page '{1}' has been scheduled for publishing."
-                ).format(
-                    self.previous_revision.created_at.strftime("%d %b %Y %H:%M"),
-                    self.page.get_admin_display_title(),
-                )
+                    "Version from %(previous_revision_datetime)s "
+                    "of page '%(page_title)s' has been scheduled for publishing."
+                ) % {
+                    "previous_revision_datetime": self.previous_revision.created_at.strftime(
+                        "%d %b %Y %H:%M"
+                    ),
+                    "page_title": self.page.get_admin_display_title(),
+                }
             else:
                 if self.page.live:
                     message = _(
-                        "Page '{0}' is live and this version has been scheduled for publishing."
-                    ).format(self.page.get_admin_display_title())
+                        "Page '%(page_title)s' is live and this version has been scheduled for publishing."
+                    ) % {"page_title": self.page.get_admin_display_title()}
 
                 else:
-                    message = _("Page '{0}' has been scheduled for publishing.").format(
-                        self.page.get_admin_display_title()
-                    )
+                    message = _(
+                        "Page '%(page_title)s' has been scheduled for publishing."
+                    ) % {"page_title": self.page.get_admin_display_title()}
 
             messages.success(
                 self.request, message, buttons=[self.get_edit_message_button()]
@@ -603,15 +611,17 @@ class EditView(TemplateResponseMixin, ContextMixin, HookResponseMixin, View):
 
             if self.is_reverting:
                 message = _(
-                    "Version from {0} of page '{1}' has been published."
-                ).format(
-                    self.previous_revision.created_at.strftime("%d %b %Y %H:%M"),
-                    self.page.get_admin_display_title(),
-                )
+                    "Version from %(datetime)s of page '%(page_title)s' has been published."
+                ) % {
+                    "datetime": self.previous_revision.created_at.strftime(
+                        "%d %b %Y %H:%M"
+                    ),
+                    "page_title": self.page.get_admin_display_title(),
+                }
             else:
-                message = _("Page '{0}' has been published.").format(
-                    self.page.get_admin_display_title()
-                )
+                message = _("Page '%(page_title)s' has been published.") % {
+                    "page_title": self.page.get_admin_display_title()
+                }
 
             buttons = []
             if self.page.url is not None:
@@ -653,9 +663,9 @@ class EditView(TemplateResponseMixin, ContextMixin, HookResponseMixin, View):
             workflow = self.page.get_workflow()
             workflow.start(self.page, self.request.user)
 
-        message = _("Page '{0}' has been submitted for moderation.").format(
-            self.page.get_admin_display_title()
-        )
+        message = _("Page '%(page_title)s' has been submitted for moderation.") % {
+            "page_title": self.page.get_admin_display_title()
+        }
 
         messages.success(
             self.request,
@@ -695,9 +705,9 @@ class EditView(TemplateResponseMixin, ContextMixin, HookResponseMixin, View):
         workflow = self.page.get_workflow()
         workflow.start(self.page, self.request.user)
 
-        message = _("Workflow on page '{0}' has been restarted.").format(
-            self.page.get_admin_display_title()
-        )
+        message = _("Workflow on page '%(page_title)s' has been restarted.") % {
+            "page_title": self.page.get_admin_display_title()
+        }
 
         messages.success(
             self.request,

+ 2 - 1
wagtail/admin/views/pages/lock.py

@@ -52,7 +52,8 @@ def unlock(request, page_id):
 
         messages.success(
             request,
-            _("Page '{0}' is now unlocked.").format(page.get_admin_display_title()),
+            _("Page '%(page_title)s' is now unlocked.")
+            % {"page_title": page.get_admin_display_title()},
             extra_tags="unlock",
         )
 

+ 19 - 15
wagtail/admin/views/pages/moderation.py

@@ -17,18 +17,19 @@ def approve_moderation(request, revision_id):
     if not revision.submitted_for_moderation:
         messages.error(
             request,
-            _("The page '{0}' is not currently awaiting moderation.").format(
-                revision.content_object.specific_deferred.get_admin_display_title()
-            ),
+            _("The page '%(page_title)s' is not currently awaiting moderation.")
+            % {
+                "page_title": revision.content_object.specific_deferred.get_admin_display_title()
+            },
         )
         return redirect("wagtailadmin_home")
 
     if request.method == "POST":
         revision.approve_moderation(user=request.user)
 
-        message = _("Page '{0}' published.").format(
-            revision.content_object.specific_deferred.get_admin_display_title()
-        )
+        message = _("Page '%(page_title)s' published.") % {
+            "page_title": revision.content_object.specific_deferred.get_admin_display_title()
+        }
         buttons = []
         if revision.content_object.url is not None:
             buttons.append(
@@ -58,9 +59,10 @@ def reject_moderation(request, revision_id):
     if not revision.submitted_for_moderation:
         messages.error(
             request,
-            _("The page '{0}' is not currently awaiting moderation.").format(
-                revision.content_object.specific_deferred.get_admin_display_title()
-            ),
+            _("The page '%(page_title)s' is not currently awaiting moderation.")
+            % {
+                "page_title": revision.content_object.specific_deferred.get_admin_display_title()
+            },
         )
         return redirect("wagtailadmin_home")
 
@@ -69,9 +71,10 @@ def reject_moderation(request, revision_id):
 
         messages.success(
             request,
-            _("Page '{0}' rejected for publication.").format(
-                revision.content_object.specific_deferred.get_admin_display_title()
-            ),
+            _("Page '%(page_title)s' rejected for publication.")
+            % {
+                "page_title": revision.content_object.specific_deferred.get_admin_display_title()
+            },
             buttons=[
                 messages.button(
                     reverse(
@@ -97,9 +100,10 @@ def preview_for_moderation(request, revision_id):
     if not revision.submitted_for_moderation:
         messages.error(
             request,
-            _("The page '{0}' is not currently awaiting moderation.").format(
-                revision.content_object.specific_deferred.get_admin_display_title()
-            ),
+            _("The page '%(page_title)s' is not currently awaiting moderation.")
+            % {
+                "page_title": revision.content_object.specific_deferred.get_admin_display_title()
+            },
         )
         return redirect("wagtailadmin_home")
 

+ 5 - 3
wagtail/admin/views/pages/move.py

@@ -56,8 +56,9 @@ def move_confirm(request, page_to_move_id, destination_id):
         messages.error(
             request,
             _(
-                "The slug '{0}' is already in use at the selected parent page. Make sure the slug is unique and try again"
-            ).format(page_to_move.slug),
+                "The slug '%(page_slug)s' is already in use at the selected parent page. Make sure the slug is unique and try again"
+            )
+            % {"page_slug": page_to_move.slug},
         )
         return redirect(
             "wagtailadmin_pages:move",
@@ -107,7 +108,8 @@ def move_confirm(request, page_to_move_id, destination_id):
 
         messages.success(
             request,
-            _("Page '{0}' moved.").format(page_to_move.get_admin_display_title()),
+            _("Page '%(page_title)s' moved.")
+            % {"page_title": page_to_move.get_admin_display_title()},
             buttons=[
                 messages.button(
                     reverse("wagtailadmin_pages:edit", args=(page_to_move.id,)),

+ 4 - 2
wagtail/admin/views/pages/unpublish.py

@@ -16,7 +16,7 @@ class Unpublish(UnpublishView):
     index_url_name = "wagtailadmin_explore"
     edit_url_name = "wagtailadmin_pages:edit"
     unpublish_url_name = "wagtailadmin_pages:unpublish"
-    success_message = _("Page '{0}' unpublished.")
+    success_message = _("Page '%(page_title)s' unpublished.")
     template_name = "wagtailadmin/pages/confirm_unpublish.html"
 
     def setup(self, request, page_id, *args, **kwargs):
@@ -36,7 +36,9 @@ class Unpublish(UnpublishView):
         return super().dispatch(request, *args, **kwargs)
 
     def get_success_message(self):
-        return self.success_message.format(self.object.get_admin_display_title())
+        return self.success_message % {
+            "page_title": self.object.get_admin_display_title()
+        }
 
     def get_next_url(self):
         next_url = get_valid_next_url_from_request(self.request)

+ 5 - 5
wagtail/admin/views/pages/workflow.py

@@ -38,9 +38,8 @@ class BaseWorkflowFormView(View):
         if not self.page.workflow_in_progress:
             messages.error(
                 request,
-                _("The page '{0}' is not currently awaiting moderation.").format(
-                    self.page.get_admin_display_title()
-                ),
+                _("The page '%(page_title)s' is not currently awaiting moderation.")
+                % {"page_title": self.page.get_admin_display_title()},
             )
             return redirect(self.redirect_to)
 
@@ -250,8 +249,9 @@ def preview_revision_for_task(request, page_id, task_id):
         messages.error(
             request,
             _(
-                "The page '{0}' is not currently awaiting moderation in task '{1}'."
-            ).format(page.get_admin_display_title(), task.name),
+                "The page '%(page_title)s' is not currently awaiting moderation in task '%(task_name)s'."
+            )
+            % {"page_title": page.get_admin_display_title(), "task_name": task.name},
         )
         return redirect("wagtailadmin_home")
 

+ 16 - 11
wagtail/admin/views/workflows.py

@@ -71,7 +71,7 @@ class Create(CreateView):
     model = Workflow
     page_title = _("New workflow")
     template_name = "wagtailadmin/workflows/create.html"
-    success_message = _("Workflow '{0}' created.")
+    success_message = _("Workflow '%(object)s' created.")
     add_url_name = "wagtailadmin_workflows:add"
     edit_url_name = "wagtailadmin_workflows:edit"
     index_url_name = "wagtailadmin_workflows:index"
@@ -142,7 +142,7 @@ class Edit(EditView):
     model = Workflow
     page_title = _("Editing workflow")
     template_name = "wagtailadmin/workflows/edit.html"
-    success_message = _("Workflow '{0}' updated.")
+    success_message = _("Workflow '%(object)s' updated.")
     add_url_name = "wagtailadmin_workflows:add"
     edit_url_name = "wagtailadmin_workflows:edit"
     delete_url_name = "wagtailadmin_workflows:disable"
@@ -244,7 +244,7 @@ class Disable(DeleteView):
     model = Workflow
     page_title = _("Disable workflow")
     template_name = "wagtailadmin/workflows/confirm_disable.html"
-    success_message = _("Workflow '{0}' disabled.")
+    success_message = _("Workflow '%(object)s' disabled.")
     add_url_name = "wagtailadmin_workflows:add"
     edit_url_name = "wagtailadmin_workflows:edit"
     delete_url_name = "wagtailadmin_workflows:disable"
@@ -305,7 +305,11 @@ def enable_workflow(request, pk):
     if not workflow.active:
         workflow.active = True
         workflow.save()
-        messages.success(request, _("Workflow '{0}' enabled.").format(workflow.name))
+        messages.success(
+            request,
+            _("Workflow '%(workflow_name)s' enabled.")
+            % {"workflow_name": workflow.name},
+        )
 
     # Redirect
     redirect_to = request.POST.get("next", None)
@@ -334,9 +338,8 @@ def remove_workflow(request, page_pk, workflow_pk=None):
             page.workflowpage.delete()
             messages.success(
                 request,
-                _("Workflow removed from Page '{0}'.").format(
-                    page.get_admin_display_title()
-                ),
+                _("Workflow removed from Page '%(page_title)s'.")
+                % {"page_title": page.get_admin_display_title()},
             )
 
     # Redirect
@@ -414,7 +417,7 @@ class CreateTask(CreateView):
     model = None
     page_title = _("New workflow task")
     template_name = "wagtailadmin/workflows/create_task.html"
-    success_message = _("Task '{0}' created.")
+    success_message = _("Task '%(object)s' created.")
     add_url_name = "wagtailadmin_workflows:add_task"
     edit_url_name = "wagtailadmin_workflows:edit_task"
     index_url_name = "wagtailadmin_workflows:task_index"
@@ -456,7 +459,7 @@ class EditTask(EditView):
     model = None
     page_title = _("Editing workflow task")
     template_name = "wagtailadmin/workflows/edit_task.html"
-    success_message = _("Task '{0}' updated.")
+    success_message = _("Task '%(object)s' updated.")
     add_url_name = "wagtailadmin_workflows:select_task_type"
     edit_url_name = "wagtailadmin_workflows:edit_task"
     delete_url_name = "wagtailadmin_workflows:disable_task"
@@ -510,7 +513,7 @@ class DisableTask(DeleteView):
     model = Task
     page_title = _("Disable task")
     template_name = "wagtailadmin/workflows/confirm_disable_task.html"
-    success_message = _("Task '{0}' disabled.")
+    success_message = _("Task '%(object)s' disabled.")
     add_url_name = "wagtailadmin_workflows:add_task"
     edit_url_name = "wagtailadmin_workflows:edit_task"
     delete_url_name = "wagtailadmin_workflows:disable_task"
@@ -552,7 +555,9 @@ def enable_task(request, pk):
     if not task.active:
         task.active = True
         task.save()
-        messages.success(request, _("Task '{0}' enabled.").format(task.name))
+        messages.success(
+            request, _("Task '%(task_name)s' enabled.") % {"task_name": task.name}
+        )
 
     # Redirect
     redirect_to = request.POST.get("next", None)

+ 1 - 1
wagtail/admin/wagtail_hooks.py

@@ -976,7 +976,7 @@ def register_reports_menu():
 def register_whats_new_in_wagtail_version_menu_item():
     version = "4.1"
     return DismissibleMenuItem(
-        _("What's new in Wagtail {version}").format(version=version),
+        _("What's new in Wagtail %(version)s") % {"version": version},
         wagtail_feature_release_whats_new_link(),
         icon_name="help",
         order=1000,

+ 4 - 2
wagtail/blocks/list_block.py

@@ -175,14 +175,16 @@ class ListBlock(Block):
         if self.meta.min_num is not None and self.meta.min_num > len(value):
             non_block_errors.append(
                 ValidationError(
-                    _("The minimum number of items is %d") % self.meta.min_num
+                    _("The minimum number of items is %(min_num)d")
+                    % {"min_num": self.meta.min_num}
                 )
             )
 
         if self.meta.max_num is not None and self.meta.max_num < len(value):
             non_block_errors.append(
                 ValidationError(
-                    _("The maximum number of items is %d") % self.meta.max_num
+                    _("The maximum number of items is %(max_num)d")
+                    % {"max_num": self.meta.max_num}
                 )
             )
 

+ 8 - 4
wagtail/blocks/stream_block.py

@@ -159,7 +159,8 @@ class BaseStreamBlock(Block):
         if self.meta.min_num is not None and self.meta.min_num > len(value):
             non_block_errors.append(
                 ValidationError(
-                    _("The minimum number of items is %d") % self.meta.min_num
+                    _("The minimum number of items is %(min_num)d")
+                    % {"min_num": self.meta.min_num}
                 )
             )
         elif self.required and len(value) == 0:
@@ -168,7 +169,8 @@ class BaseStreamBlock(Block):
         if self.meta.max_num is not None and self.meta.max_num < len(value):
             non_block_errors.append(
                 ValidationError(
-                    _("The maximum number of items is %d") % self.meta.max_num
+                    _("The maximum number of items is %(max_num)d")
+                    % {"max_num": self.meta.max_num}
                 )
             )
 
@@ -187,7 +189,8 @@ class BaseStreamBlock(Block):
                         ValidationError(
                             "{}: {}".format(
                                 block.label,
-                                _("The minimum number of items is %d") % min_num,
+                                _("The minimum number of items is %(min_num)d")
+                                % {"min_num": min_num},
                             )
                         )
                     )
@@ -196,7 +199,8 @@ class BaseStreamBlock(Block):
                         ValidationError(
                             "{}: {}".format(
                                 block.label,
-                                _("The maximum number of items is %d") % max_num,
+                                _("The maximum number of items is %(max_num)d")
+                                % {"max_num": max_num},
                             )
                         )
                     )

+ 7 - 7
wagtail/contrib/forms/forms.py

@@ -102,15 +102,15 @@ class FormBuilder:
         """
 
         if "\n" in field.choices:
-            choices = map(
-                lambda x: (
+            choices = (
+                (
                     x.strip().rstrip(",").strip(),
                     x.strip().rstrip(",").strip(),
-                ),
-                field.choices.split("\r\n"),
+                )
+                for x in field.choices.split("\r\n")
             )
         else:
-            choices = map(lambda x: (x.strip(), x.strip()), field.choices.split(","))
+            choices = ((x.strip(), x.strip()) for x in field.choices.split(","))
 
         return choices
 
@@ -201,8 +201,8 @@ class WagtailAdminFormPageForm(WagtailAdminPageForm):
                     "label",
                     django.forms.ValidationError(
                         _(
-                            "There is another field with the label %s, please change one of them."
-                            % duplicate_form_field.instance.label
+                            "There is another field with the label %(label_name)s, please change one of them."
                         )
+                        % {"label_name": duplicate_form_field.instance.label}
                     ),
                 )

+ 3 - 1
wagtail/contrib/forms/panels.py

@@ -7,7 +7,9 @@ from wagtail.admin.panels import Panel
 class FormSubmissionsPanel(Panel):
     def on_model_bound(self):
         if not self.heading:
-            self.heading = _("%s submissions") % self.model.get_verbose_name()
+            self.heading = _("%(model_name)s submissions") % {
+                "model_name": self.model.get_verbose_name()
+            }
 
     class BoundPanel(Panel.BoundPanel):
         template_name = "wagtailforms/panels/form_responses_panel.html"

+ 7 - 7
wagtail/contrib/modeladmin/helpers/button.py

@@ -39,9 +39,9 @@ class ButtonHelper:
         cn = self.finalise_classname(classnames, classnames_exclude)
         return {
             "url": self.url_helper.create_url,
-            "label": _("Add %s") % self.verbose_name,
+            "label": _("Add %(object)s") % {"object": self.verbose_name},
             "classname": cn,
-            "title": _("Add a new %s") % self.verbose_name,
+            "title": _("Add a new %(object)s") % {"object": self.verbose_name},
         }
 
     def inspect_button(self, pk, classnames_add=None, classnames_exclude=None):
@@ -55,7 +55,7 @@ class ButtonHelper:
             "url": self.url_helper.get_action_url("inspect", quote(pk)),
             "label": _("Inspect"),
             "classname": cn,
-            "title": _("Inspect this %s") % self.verbose_name,
+            "title": _("Inspect this %(object)s") % {"object": self.verbose_name},
         }
 
     def edit_button(self, pk, classnames_add=None, classnames_exclude=None):
@@ -69,7 +69,7 @@ class ButtonHelper:
             "url": self.url_helper.get_action_url("edit", quote(pk)),
             "label": _("Edit"),
             "classname": cn,
-            "title": _("Edit this %s") % self.verbose_name,
+            "title": _("Edit this %(object)s") % {"object": self.verbose_name},
         }
 
     def delete_button(self, pk, classnames_add=None, classnames_exclude=None):
@@ -83,7 +83,7 @@ class ButtonHelper:
             "url": self.url_helper.get_action_url("delete", quote(pk)),
             "label": _("Delete"),
             "classname": cn,
-            "title": _("Delete this %s") % self.verbose_name,
+            "title": _("Delete this %(object)s") % {"object": self.verbose_name},
         }
 
     def get_buttons_for_obj(
@@ -133,7 +133,7 @@ class PageButtonHelper(ButtonHelper):
             "url": self.url_helper.get_action_url("unpublish", quote(pk)),
             "label": _("Unpublish"),
             "classname": cn,
-            "title": _("Unpublish this %s") % self.verbose_name,
+            "title": _("Unpublish this %(object)s") % {"object": self.verbose_name},
         }
 
     def copy_button(self, pk, classnames_add=None, classnames_exclude=None):
@@ -147,7 +147,7 @@ class PageButtonHelper(ButtonHelper):
             "url": self.url_helper.get_action_url("copy", quote(pk)),
             "label": _("Copy"),
             "classname": cn,
-            "title": _("Copy this %s") % self.verbose_name,
+            "title": _("Copy this %(object)s") % {"object": self.verbose_name},
         }
 
     def get_buttons_for_obj(

+ 19 - 20
wagtail/contrib/modeladmin/views.py

@@ -246,9 +246,9 @@ class ModelFormView(WMABaseView, FormView):
         return fields
 
     def get_success_message(self, instance):
-        return _("%(model_name)s '%(instance)s' created.") % {
+        return _("%(model_name)s '%(object)s' created.") % {
             "model_name": capfirst(self.opts.verbose_name),
-            "instance": instance,
+            "object": instance,
         }
 
     def get_success_message_buttons(self, instance):
@@ -257,7 +257,9 @@ class ModelFormView(WMABaseView, FormView):
 
     def get_error_message(self):
         model_name = self.verbose_name
-        return _("The %s could not be created due to errors.") % model_name
+        return _("The %(object)s could not be created due to errors.") % {
+            "object": model_name
+        }
 
     def form_valid(self, form):
         self.instance = form.save()
@@ -799,7 +801,7 @@ class CreateView(ModelFormView):
         return response
 
     def get_meta_title(self):
-        return _("Create new %s") % self.verbose_name
+        return _("Create new %(object)s") % {"object": self.verbose_name}
 
     def get_page_subtitle(self):
         return capfirst(self.verbose_name)
@@ -859,12 +861,12 @@ class EditView(ModelFormView, InstanceSpecificView):
         return super().dispatch(request, *args, **kwargs)
 
     def get_meta_title(self):
-        return _("Editing %s") % self.verbose_name
+        return _("Editing %(object)s") % {"object": self.verbose_name}
 
     def get_success_message(self, instance):
-        return _("%(model_name)s '%(instance)s' updated.") % {
+        return _("%(model_name)s '%(object)s' updated.") % {
             "model_name": capfirst(self.verbose_name),
-            "instance": instance,
+            "object": instance,
         }
 
     def get_context_data(self, **kwargs):
@@ -889,7 +891,7 @@ class EditView(ModelFormView, InstanceSpecificView):
 
     def get_error_message(self):
         name = self.verbose_name
-        return _("The %s could not be saved due to errors.") % name
+        return _("The %(object)s could not be saved due to errors.") % {"object": name}
 
     def get_template_names(self):
         return self.model_admin.get_edit_template()
@@ -923,7 +925,7 @@ class ChooseParentView(WMABaseView):
         return super().dispatch(request, *args, **kwargs)
 
     def get_page_title(self):
-        return _("Add %s") % self.verbose_name
+        return _("Add %(object)s") % {"object": self.verbose_name}
 
     def get_form(self, request):
         parents = self.permission_helper.get_valid_parent_pages(request.user)
@@ -971,25 +973,22 @@ class DeleteView(InstanceSpecificView):
         return super().dispatch(request, *args, **kwargs)
 
     def get_meta_title(self):
-        return _("Confirm deletion of %s") % self.verbose_name
+        return _("Confirm deletion of %(object)s") % {"object": self.verbose_name}
 
     def confirmation_message(self):
-        return (
-            _(
-                "Are you sure you want to delete this %s? If other things in your "
-                "site are related to it, they may also be affected."
-            )
-            % self.verbose_name
-        )
+        return _(
+            "Are you sure you want to delete this %(object)s? If other things in your "
+            "site are related to it, they may also be affected."
+        ) % {"object": self.verbose_name}
 
     def delete_instance(self):
         self.instance.delete()
 
     def post(self, request, *args, **kwargs):
         try:
-            msg = _("%(model_name)s '%(instance)s' deleted.") % {
+            msg = _("%(model_name)s '%(object)s' deleted.") % {
                 "model_name": self.verbose_name,
-                "instance": self.instance,
+                "object": self.instance,
             }
             with transaction.atomic():
                 log(instance=self.instance, action="wagtail.delete")
@@ -1065,7 +1064,7 @@ class InspectView(InstanceSpecificView):
         )
 
     def get_meta_title(self):
-        return _("Inspecting %s") % self.verbose_name
+        return _("Inspecting %(object)s") % {"object": self.verbose_name}
 
     def get_field_label(self, field_name, field=None):
         """Return a label to display for a field"""

+ 15 - 5
wagtail/contrib/redirects/views.py

@@ -111,7 +111,8 @@ def edit(request, redirect_id):
                 log(instance=theredirect, action="wagtail.edit")
             messages.success(
                 request,
-                _("Redirect '{0}' updated.").format(theredirect.title),
+                _("Redirect '%(redirect_title)s' updated.")
+                % {"redirect_title": theredirect.title},
                 buttons=[
                     messages.button(
                         reverse("wagtailredirects:edit", args=(theredirect.id,)),
@@ -152,7 +153,9 @@ def delete(request, redirect_id):
             log(instance=theredirect, action="wagtail.delete")
             theredirect.delete()
         messages.success(
-            request, _("Redirect '{0}' deleted.").format(theredirect.title)
+            request,
+            _("Redirect '%(redirect_title)s' deleted.")
+            % {"redirect_title": theredirect.title},
         )
         return redirect("wagtailredirects:index")
 
@@ -176,7 +179,8 @@ def add(request):
 
             messages.success(
                 request,
-                _("Redirect '{0}' added.").format(theredirect.title),
+                _("Redirect '%(redirect_title)s' added.")
+                % {"redirect_title": theredirect.title},
                 buttons=[
                     messages.button(
                         reverse("wagtailredirects:edit", args=(theredirect.id,)),
@@ -239,7 +243,9 @@ def start_import(request):
 
     if extension not in supported_extensions:
         messages.error(
-            request, _('File format of type "{}" is not supported').format(extension)
+            request,
+            _('File format of type "%(extension)s" is not supported')
+            % {"extension": extension},
         )
         return redirect("wagtailredirects:start_import")
 
@@ -253,7 +259,11 @@ def start_import(request):
             data = force_str(data, from_encoding)
         dataset = input_format.create_dataset(data)
     except UnicodeDecodeError as e:
-        messages.error(request, _("Imported file has a wrong encoding: %s") % e)
+        messages.error(
+            request,
+            _("Imported file has a wrong encoding: %(error_message)s")
+            % {"error_message": e},
+        )
         return redirect("wagtailredirects:start_import")
     except Exception as e:  # pragma: no cover
         messages.error(

+ 2 - 2
wagtail/contrib/search_promotions/views.py

@@ -137,7 +137,7 @@ def add(request):
                     log(search_pick, "wagtail.create")
                 messages.success(
                     request,
-                    _("Editor's picks for '{0}' created.").format(query),
+                    _("Editor's picks for '%(query)s' created.") % {"query": query},
                     buttons=[
                         messages.button(
                             reverse("wagtailsearchpromotions:edit", args=(query.id,)),
@@ -197,7 +197,7 @@ def edit(request, query_id):
             if save_searchpicks(query, new_query, searchpicks_formset):
                 messages.success(
                     request,
-                    _("Editor's picks for '{0}' updated.").format(new_query),
+                    _("Editor's picks for '%(query)s' updated.") % {"query": new_query},
                     buttons=[
                         messages.button(
                             reverse("wagtailsearchpromotions:edit", args=(query.id,)),

+ 3 - 3
wagtail/contrib/simple_translation/forms.py

@@ -42,10 +42,10 @@ class SubmitTranslationForm(forms.Form):
             if descendant_count > 0:
                 hide_include_subtree = False
                 self.fields["include_subtree"].label = ngettext(
-                    "Include subtree ({} page)",
-                    "Include subtree ({} pages)",
+                    "Include subtree (%(descendant_count)s page)",
+                    "Include subtree (%(descendant_count)s pages)",
                     descendant_count,
-                ).format(descendant_count)
+                ) % {"descendant_count": descendant_count}
 
         if hide_include_subtree:
             self.fields["include_subtree"].widget = forms.HiddenInput()

+ 14 - 12
wagtail/contrib/simple_translation/views.py

@@ -74,8 +74,10 @@ class SubmitTranslationView(SingleObjectMixin, TemplateView):
                         form.cleaned_data["locales"][0]
                     )
                 else:
-                    # Note: always plural
-                    locales = _("{} locales").format(len(form.cleaned_data["locales"]))
+                    # Translators: always plural
+                    locales = _("%(locales_count)s locales") % {
+                        "locales_count": len(form.cleaned_data["locales"])
+                    }
 
                 messages.success(self.request, self.get_success_message(locales))
 
@@ -117,15 +119,15 @@ class SubmitPageTranslationView(SubmitTranslationView):
 
     def get_success_message(self, locales):
         return _(
-            "The page '{page_title}' was successfully created in {locales}"
-        ).format(page_title=self.object.get_admin_display_title(), locales=locales)
+            "The page '%(page_title)s' was successfully created in %(locales)s"
+        ) % {"page_title": self.object.get_admin_display_title(), "locales": locales}
 
 
 class SubmitSnippetTranslationView(SubmitTranslationView):
     def get_title(self):
-        return _("Translate {model_name}").format(
-            model_name=self.object._meta.verbose_name
-        )
+        return _("Translate %(model_name)s") % {
+            "model_name": self.object._meta.verbose_name
+        }
 
     def get_object(self):
         model = get_snippet_model_from_url_params(
@@ -153,8 +155,8 @@ class SubmitSnippetTranslationView(SubmitTranslationView):
         )
 
     def get_success_message(self, locales):
-        return _("Successfully created {locales} for {model_name} '{object}'").format(
-            model_name=self.object._meta.verbose_name,
-            object=str(self.object),
-            locales=locales,
-        )
+        return _("Successfully created %(locales)s for %(model_name)s '%(object)s'") % {
+            "model_name": self.object._meta.verbose_name,
+            "object": str(self.object),
+            "locales": locales,
+        }

+ 12 - 5
wagtail/documents/views/documents.py

@@ -137,7 +137,8 @@ def add(request):
 
             messages.success(
                 request,
-                _("Document '{0}' added.").format(doc.title),
+                _("Document '%(document_title)s' added.")
+                % {"document_title": doc.title},
                 buttons=[
                     messages.button(
                         reverse("wagtaildocs:edit", args=(doc.id,)), _("Edit")
@@ -188,7 +189,8 @@ def edit(request, document_id):
 
             messages.success(
                 request,
-                _("Document '{0}' updated").format(doc.title),
+                _("Document '%(document_title)s' updated")
+                % {"document_title": doc.title},
                 buttons=[messages.button(edit_url, _("Edit"))],
             )
             return redirect(redirect_url)
@@ -247,7 +249,10 @@ def delete(request, document_id):
 
     if request.method == "POST":
         doc.delete()
-        messages.success(request, _("Document '{0}' deleted.").format(doc.title))
+        messages.success(
+            request,
+            _("Document '%(document_title)s' deleted.") % {"document_title": doc.title},
+        )
         return redirect(next_url) if next_url else redirect("wagtaildocs:index")
 
     return TemplateResponse(
@@ -279,11 +284,13 @@ def usage(request, document_id):
     for object, references in object_page:
         edit_url = url_finder.get_edit_url(object)
         if edit_url is None:
-            label = _("(Private %s)") % object._meta.verbose_name
+            label = _("(Private %(object)s)") % {"object": object._meta.verbose_name}
             edit_link_title = None
         else:
             label = str(object)
-            edit_link_title = _("Edit this %s") % object._meta.verbose_name
+            edit_link_title = _("Edit this %(object)s") % {
+                "object": object._meta.verbose_name
+            }
         results.append((label, edit_url, edit_link_title, references))
 
     return TemplateResponse(

+ 23 - 18
wagtail/images/fields.py

@@ -26,7 +26,7 @@ class WagtailImageField(ImageField):
         self.max_image_pixels = getattr(
             settings, "WAGTAILIMAGES_MAX_IMAGE_PIXELS", 128 * 1000000
         )
-        max_upload_size_text = filesizeformat(self.max_upload_size)
+        self.max_upload_size_text = filesizeformat(self.max_upload_size)
 
         # Help text
         if self.max_upload_size is not None:
@@ -34,7 +34,7 @@ class WagtailImageField(ImageField):
                 "Supported formats: %(supported_formats)s. Maximum filesize: %(max_upload_size)s."
             ) % {
                 "supported_formats": SUPPORTED_FORMATS_TEXT,
-                "max_upload_size": max_upload_size_text,
+                "max_upload_size": self.max_upload_size_text,
             }
         else:
             self.help_text = _("Supported formats: %(supported_formats)s.") % {
@@ -42,27 +42,27 @@ class WagtailImageField(ImageField):
             }
 
         # Error messages
-        self.error_messages["invalid_image_extension"] = (
-            _("Not a supported image format. Supported formats: %s.")
-            % SUPPORTED_FORMATS_TEXT
-        )
+        # Translation placeholders should all be interpolated at the same time to avoid escaping,
+        # either right now if all values are known, otherwise when used.
+        self.error_messages["invalid_image_extension"] = _(
+            "Not a supported image format. Supported formats: %(supported_formats)s."
+        ) % {"supported_formats": SUPPORTED_FORMATS_TEXT}
 
         self.error_messages["invalid_image_known_format"] = _(
-            "Not a valid .%s image. The extension does not match the file format (%s)"
+            "Not a valid .%(extension)s image. The extension does not match the file format (%(image_format)s)"
         )
 
-        self.error_messages["file_too_large"] = (
-            _("This file is too big (%%s). Maximum filesize %s.") % max_upload_size_text
+        self.error_messages["file_too_large"] = _(
+            "This file is too big (%(file_size)s). Maximum filesize %(max_filesize)s."
         )
 
-        self.error_messages["file_too_many_pixels"] = (
-            _("This file has too many pixels (%%s). Maximum pixels %s.")
-            % self.max_image_pixels
+        self.error_messages["file_too_many_pixels"] = _(
+            "This file has too many pixels (%(num_pixels)s). Maximum pixels %(max_pixels_count)s."
         )
 
-        self.error_messages["file_too_large_unknown_size"] = (
-            _("This file is too big. Maximum filesize %s.") % max_upload_size_text
-        )
+        self.error_messages["file_too_large_unknown_size"] = _(
+            "This file is too big. Maximum filesize %(max_filesize)s."
+        ) % {"max_filesize": self.max_upload_size_text}
 
     def check_image_file_format(self, f):
         # Check file extension
@@ -82,7 +82,7 @@ class WagtailImageField(ImageField):
         if extension != f.image.format_name:
             raise ValidationError(
                 self.error_messages["invalid_image_known_format"]
-                % (extension, f.image.format_name),
+                % {"extension": extension, "image_format": f.image.format_name},
                 code="invalid_image_known_format",
             )
 
@@ -94,7 +94,11 @@ class WagtailImageField(ImageField):
         # Check the filesize
         if f.size > self.max_upload_size:
             raise ValidationError(
-                self.error_messages["file_too_large"] % (filesizeformat(f.size),),
+                self.error_messages["file_too_large"]
+                % {
+                    "file_size": filesizeformat(f.size),
+                    "max_filesize": self.max_upload_size_text,
+                },
                 code="file_too_large",
             )
 
@@ -110,7 +114,8 @@ class WagtailImageField(ImageField):
 
         if num_pixels > self.max_image_pixels:
             raise ValidationError(
-                self.error_messages["file_too_many_pixels"] % (num_pixels),
+                self.error_messages["file_too_many_pixels"]
+                % {"num_pixels": num_pixels, "max_pixels_count": self.max_image_pixels},
                 code="file_too_many_pixels",
             )
 

+ 12 - 6
wagtail/images/views/images.py

@@ -203,7 +203,7 @@ def edit(request, image_id):
 
             messages.success(
                 request,
-                _("Image '{0}' updated.").format(image.title),
+                _("Image '%(image_title)s' updated.") % {"image_title": image.title},
                 buttons=[messages.button(edit_url, _("Edit again"))],
             )
             return redirect(redirect_url)
@@ -226,7 +226,8 @@ def edit(request, image_id):
                 request,
                 _(
                     "The source image file could not be found. Please change the source or delete the image."
-                ).format(image.title),
+                )
+                % {"image_title": image.title},
                 buttons=[
                     messages.button(
                         reverse("wagtailimages:delete", args=(image.id,)), _("Delete")
@@ -349,7 +350,10 @@ def delete(request, image_id):
 
     if request.method == "POST":
         image.delete()
-        messages.success(request, _("Image '{0}' deleted.").format(image.title))
+        messages.success(
+            request,
+            _("Image '%(image_title)s' deleted.") % {"image_title": image.title},
+        )
         return redirect(next_url) if next_url else redirect("wagtailimages:index")
 
     return TemplateResponse(
@@ -375,7 +379,7 @@ def add(request):
 
             messages.success(
                 request,
-                _("Image '{0}' added.").format(image.title),
+                _("Image '%(image_title)s' added.") % {"image_title": image.title},
                 buttons=[
                     messages.button(
                         reverse("wagtailimages:edit", args=(image.id,)), _("Edit")
@@ -415,11 +419,13 @@ def usage(request, image_id):
     for object, references in object_page:
         edit_url = url_finder.get_edit_url(object)
         if edit_url is None:
-            label = _("(Private %s)") % object._meta.verbose_name
+            label = _("(Private %(object)s)") % {"object": object._meta.verbose_name}
             edit_link_title = None
         else:
             label = str(object)
-            edit_link_title = _("Edit this %s") % object._meta.verbose_name
+            edit_link_title = _("Edit this %(object)s") % {
+                "object": object._meta.verbose_name
+            }
         results.append((label, edit_url, edit_link_title, references))
 
     return TemplateResponse(

+ 3 - 3
wagtail/locales/views.py

@@ -48,12 +48,12 @@ class IndexView(generic.IndexView):
 
 class CreateView(generic.CreateView):
     page_title = gettext_lazy("Add locale")
-    success_message = gettext_lazy("Locale '{0}' created.")
+    success_message = gettext_lazy("Locale '%(object)s' created.")
     template_name = "wagtaillocales/create.html"
 
 
 class EditView(generic.EditView):
-    success_message = gettext_lazy("Locale '{0}' updated.")
+    success_message = gettext_lazy("Locale '%(object)s' updated.")
     error_message = gettext_lazy("The locale could not be saved due to errors.")
     delete_item_label = gettext_lazy("Delete locale")
     context_object_name = "locale"
@@ -62,7 +62,7 @@ class EditView(generic.EditView):
 
 
 class DeleteView(generic.DeleteView):
-    success_message = gettext_lazy("Locale '{0}' deleted.")
+    success_message = gettext_lazy("Locale '%(object)s' deleted.")
     page_title = gettext_lazy("Delete locale")
     confirmation_message = gettext_lazy("Are you sure you want to delete this locale?")
     template_name = "wagtaillocales/confirm_delete.html"

+ 31 - 17
wagtail/locks.py

@@ -45,29 +45,37 @@ class BasicLock(BaseLock):
         if self.page.locked_by_id == user.pk:
             if self.page.locked_at:
                 return format_html(
-                    _("<b>Page '{}' was locked</b> by <b>you</b> on <b>{}</b>."),
-                    self.page.get_admin_display_title(),
-                    self.page.locked_at.strftime("%d %b %Y %H:%M"),
+                    # nosemgrep: translation-no-new-style-formatting (new-style only w/ format_html)
+                    _(
+                        "<b>Page '{page_title}' was locked</b> by <b>you</b> on <b>{datetime}</b>."
+                    ),
+                    page_title=self.page.get_admin_display_title(),
+                    datetime=self.page.locked_at.strftime("%d %b %Y %H:%M"),
                 )
 
             else:
                 return format_html(
-                    _("<b>Page '{}' is locked</b> by <b>you</b>."),
-                    self.page.get_admin_display_title(),
+                    # nosemgrep: translation-no-new-style-formatting (new-style only w/ format_html)
+                    _("<b>Page '{page_title}' is locked</b> by <b>you</b>."),
+                    page_title=self.page.get_admin_display_title(),
                 )
         else:
             if self.page.locked_by and self.page.locked_at:
                 return format_html(
-                    _("<b>Page '{}' was locked</b> by <b>{}</b> on <b>{}</b>."),
-                    self.page.get_admin_display_title(),
-                    str(self.page.locked_by),
-                    self.page.locked_at.strftime("%d %b %Y %H:%M"),
+                    # nosemgrep: translation-no-new-style-formatting (new-style only w/ format_html)
+                    _(
+                        "<b>Page '{page_title}' was locked</b> by <b>{user}</b> on <b>{datetime}</b>."
+                    ),
+                    page_title=self.page.get_admin_display_title(),
+                    user=str(self.page.locked_by),
+                    datetime=self.page.locked_at.strftime("%d %b %Y %H:%M"),
                 )
             else:
                 # Page was probably locked with an old version of Wagtail, or a script
                 return format_html(
-                    _("<b>Page '{}' is locked</b>."),
-                    self.page.get_admin_display_title(),
+                    # nosemgrep: translation-no-new-style-formatting (new-style only w/ format_html)
+                    _("<b>Page '{page_title}' is locked</b>."),
+                    page_title=self.page.get_admin_display_title(),
                 )
 
 
@@ -92,9 +100,12 @@ class WorkflowLock(BaseLock):
                 workflow_info = _("This page is currently awaiting moderation.")
             else:
                 workflow_info = format_html(
-                    _("This page is awaiting <b>'{}'</b> in the <b>'{}'</b> workflow."),
-                    self.task.name,
-                    self.page.current_workflow_state.workflow.name,
+                    # nosemgrep: translation-no-new-style-formatting (new-style only w/ format_html)
+                    _(
+                        "This page is awaiting <b>'{task_name}'</b> in the <b>'{workflow_name}'</b> workflow."
+                    ),
+                    task_name=self.task.name,
+                    workflow_name=self.page.current_workflow_state.workflow.name,
                 )
 
             return mark_safe(
@@ -122,7 +133,10 @@ class ScheduledForPublishLock(BaseLock):
         scheduled_revision = self.page.scheduled_revision
 
         return format_html(
-            _("Page '{}' is locked and has been scheduled to go live at {}"),
-            self.page.get_admin_display_title(),
-            scheduled_revision.approved_go_live_at.strftime("%d %b %Y %H:%M"),
+            # nosemgrep: translation-no-new-style-formatting (new-style only w/ format_html)
+            _(
+                "Page '{page_title}' is locked and has been scheduled to go live at {datetime}"
+            ),
+            page_title=self.page.get_admin_display_title(),
+            datetime=scheduled_revision.approved_go_live_at.strftime("%d %b %Y %H:%M"),
         )

+ 14 - 6
wagtail/models/__init__.py

@@ -3871,9 +3871,13 @@ class WorkflowState(models.Model):
         return super().save(*args, **kwargs)
 
     def __str__(self):
-        return _("Workflow '{0}' on Page '{1}': {2}").format(
-            self.workflow, self.page, self.status
-        )
+        return _(
+            "Workflow '%(workflow_name)s' on Page '%(page_title)s': %(status)s"
+        ) % {
+            "workflow_name": self.workflow,
+            "page_title": self.page,
+            "status": self.status,
+        }
 
     def resume(self, user=None):
         """Put a STATUS_NEEDS_CHANGES workflow state back into STATUS_IN_PROGRESS, and restart the current task"""
@@ -4231,9 +4235,13 @@ class TaskState(models.Model):
                 self.content_type = ContentType.objects.get_for_model(self)
 
     def __str__(self):
-        return _("Task '{0}' on Page Revision '{1}': {2}").format(
-            self.task, self.page_revision, self.status
-        )
+        return _(
+            "Task '%(task_name)s' on Page Revision '%(revision_info)s': %(status)s"
+        ) % {
+            "task_name": self.task,
+            "revision_info": self.page_revision,
+            "status": self.status,
+        }
 
     @cached_property
     def specific(self):

+ 3 - 2
wagtail/models/audit_log.py

@@ -201,9 +201,10 @@ class BaseLogEntry(models.Model):
         if not log_action_registry.action_exists(self.action):
             raise ValidationError(
                 {
-                    "action": _("The log action '{}' has not been registered.").format(
-                        self.action
+                    "action": _(
+                        "The log action '%(action_name)s' has not been registered."
                     )
+                    % {"action_name": self.action}
                 }
             )
 

+ 3 - 3
wagtail/sites/views.py

@@ -31,12 +31,12 @@ class IndexView(generic.IndexView):
 
 class CreateView(generic.CreateView):
     page_title = _("Add site")
-    success_message = _("Site '{0}' created.")
+    success_message = _("Site '%(object)s' created.")
     template_name = "wagtailsites/create.html"
 
 
 class EditView(generic.EditView):
-    success_message = _("Site '{0}' updated.")
+    success_message = _("Site '%(object)s' updated.")
     error_message = _("The site could not be saved due to errors.")
     delete_item_label = _("Delete site")
     context_object_name = "site"
@@ -44,7 +44,7 @@ class EditView(generic.EditView):
 
 
 class DeleteView(generic.DeleteView):
-    success_message = _("Site '{0}' deleted.")
+    success_message = _("Site '%(object)s' deleted.")
     page_title = _("Delete site")
     confirmation_message = _("Are you sure you want to delete this site?")
 

+ 6 - 6
wagtail/snippets/bulk_actions/delete.py

@@ -31,16 +31,16 @@ class DeleteBulkAction(SnippetBulkAction):
 
     def get_success_message(self, num_parent_objects, num_child_objects):
         if num_parent_objects == 1:
-            return _("%(snippet_type)s '%(instance)s' deleted.") % {
-                "snippet_type": capfirst(self.model._meta.verbose_name),
-                "instance": self.actionable_objects[0],
+            return _("%(model_name)s '%(object)s' deleted.") % {
+                "model_name": capfirst(self.model._meta.verbose_name),
+                "object": self.actionable_objects[0],
             }
         else:
             return ngettext(
-                "%(count)d %(snippet_type)s deleted.",
-                "%(count)d %(snippet_type)s deleted.",
+                "%(count)d %(model_name)s deleted.",
+                "%(count)d %(model_name)s deleted.",
                 num_parent_objects,
             ) % {
-                "snippet_type": capfirst(self.model._meta.verbose_name_plural),
+                "model_name": capfirst(self.model._meta.verbose_name_plural),
                 "count": num_parent_objects,
             }

+ 29 - 25
wagtail/snippets/views/snippets.py

@@ -243,17 +243,17 @@ class CreateView(generic.CreateView):
         return reverse(self.index_url_name) + urlquery
 
     def get_success_message(self, instance):
-        message = _("%(snippet_type)s '%(instance)s' created.")
+        message = _("%(model_name)s '%(object)s' created.")
         if isinstance(instance, DraftStateMixin) and self.action == "publish":
-            message = _("%(snippet_type)s '%(instance)s' created and published.")
+            message = _("%(model_name)s '%(object)s' created and published.")
             if instance.go_live_at and instance.go_live_at > timezone.now():
                 message = _(
-                    "%(snippet_type)s '%(instance)s' created and scheduled for publishing."
+                    "%(model_name)s '%(object)s' created and scheduled for publishing."
                 )
 
         return message % {
-            "snippet_type": capfirst(self.model._meta.verbose_name),
-            "instance": instance,
+            "model_name": capfirst(self.model._meta.verbose_name),
+            "object": instance,
         }
 
     def get_success_buttons(self):
@@ -376,24 +376,24 @@ class EditView(generic.EditView):
         return reverse(self.index_url_name)
 
     def get_success_message(self):
-        message = _("%(snippet_type)s '%(instance)s' updated.")
+        message = _("%(model_name)s '%(object)s' updated.")
 
         if self.draftstate_enabled and self.action == "publish":
-            message = _("%(snippet_type)s '%(instance)s' updated and published.")
+            message = _("%(model_name)s '%(object)s' updated and published.")
 
             if self.object.go_live_at and self.object.go_live_at > timezone.now():
                 message = _(
-                    "%(snippet_type)s '%(instance)s' has been scheduled for publishing."
+                    "%(model_name)s '%(object)s' has been scheduled for publishing."
                 )
 
                 if self.object.live:
                     message = _(
-                        "%(snippet_type)s '%(instance)s' is live and this version has been scheduled for publishing."
+                        "%(model_name)s '%(object)s' is live and this version has been scheduled for publishing."
                     )
 
         return message % {
-            "snippet_type": capfirst(self.model._meta.verbose_name),
-            "instance": self.object,
+            "model_name": capfirst(self.model._meta.verbose_name),
+            "object": self.object,
         }
 
     def get_success_buttons(self):
@@ -506,20 +506,20 @@ class DeleteView(generic.DeleteView):
     def get_success_message(self):
         count = len(self.objects)
         if count == 1:
-            return _("%(snippet_type)s '%(instance)s' deleted.") % {
-                "snippet_type": capfirst(self.model._meta.verbose_name),
-                "instance": self.objects[0],
+            return _("%(model_name)s '%(object)s' deleted.") % {
+                "model_name": capfirst(self.model._meta.verbose_name),
+                "object": self.objects[0],
             }
 
         # This message is only used in plural form, but we'll define it with ngettext so that
         # languages with multiple plural forms can be handled correctly (or, at least, as
         # correctly as possible within the limitations of verbose_name_plural...)
         return ngettext(
-            "%(count)d %(snippet_type)s deleted.",
-            "%(count)d %(snippet_type)s deleted.",
+            "%(count)d %(model_name)s deleted.",
+            "%(count)d %(model_name)s deleted.",
             count,
         ) % {
-            "snippet_type": capfirst(self.model._meta.verbose_name_plural),
+            "model_name": capfirst(self.model._meta.verbose_name_plural),
             "count": count,
         }
 
@@ -590,11 +590,15 @@ class UsageView(generic.IndexView):
         for object, references in context.get("page_obj"):
             edit_url = url_finder.get_edit_url(object)
             if edit_url is None:
-                label = _("(Private %s)") % object._meta.verbose_name
+                label = _("(Private %(object)s)") % {
+                    "object": object._meta.verbose_name
+                }
                 edit_link_title = None
             else:
                 label = str(object)
-                edit_link_title = _("Edit this %s") % object._meta.verbose_name
+                edit_link_title = _("Edit this %(object)s") % {
+                    "object": object._meta.verbose_name
+                }
             results.append((label, edit_url, edit_link_title, references))
 
         context.update(
@@ -729,15 +733,15 @@ class RevisionsCompareView(PermissionCheckedMixin, generic.RevisionsCompareView)
 
     @property
     def edit_label(self):
-        return _("Edit this {model_name}").format(
-            model_name=self.model._meta.verbose_name
-        )
+        return _("Edit this %(model_name)s") % {
+            "model_name": self.model._meta.verbose_name
+        }
 
     @property
     def history_label(self):
-        return _("{model_name} history").format(
-            model_name=self.model._meta.verbose_name
-        )
+        return _("%(model_name)s history") % {
+            "model_name": self.model._meta.verbose_name
+        }
 
 
 class UnpublishView(PermissionCheckedMixin, generic.UnpublishView):

+ 3 - 3
wagtail/snippets/widgets.py

@@ -21,9 +21,9 @@ class AdminSnippetChooser(BaseChooser):
     def __init__(self, model, **kwargs):
         self.model = model
         name = self.model._meta.verbose_name
-        self.choose_one_text = _("Choose %s") % name
-        self.choose_another_text = _("Choose another %s") % name
-        self.link_to_chosen_text = _("Edit this %s") % name
+        self.choose_one_text = _("Choose %(object)s") % {"object": name}
+        self.choose_another_text = _("Choose another %(object)s") % {"object": name}
+        self.link_to_chosen_text = _("Edit this %(object)s") % {"object": name}
 
         super().__init__(**kwargs)
 

+ 3 - 3
wagtail/users/views/groups.py

@@ -80,7 +80,7 @@ class IndexView(generic.IndexView):
 
 class CreateView(PermissionPanelFormsMixin, generic.CreateView):
     page_title = _("Add group")
-    success_message = _("Group '{0}' created.")
+    success_message = _("Group '%(object)s' created.")
     template_name = "wagtailusers/groups/create.html"
 
     def post(self, request, *args, **kwargs):
@@ -116,7 +116,7 @@ class CreateView(PermissionPanelFormsMixin, generic.CreateView):
 
 
 class EditView(PermissionPanelFormsMixin, generic.EditView):
-    success_message = _("Group '{0}' updated.")
+    success_message = _("Group '%(object)s' updated.")
     error_message = _("The group could not be saved due to errors.")
     delete_item_label = _("Delete group")
     context_object_name = "group"
@@ -154,7 +154,7 @@ class EditView(PermissionPanelFormsMixin, generic.EditView):
 
 
 class DeleteView(generic.DeleteView):
-    success_message = _("Group '{0}' deleted.")
+    success_message = _("Group '%(object)s' deleted.")
     page_title = _("Delete group")
     confirmation_message = _("Are you sure you want to delete this group?")
     template_name = "wagtailusers/groups/confirm_delete.html"

+ 3 - 3
wagtail/users/views/users.py

@@ -178,12 +178,12 @@ class Edit(EditView):
     index_url_name = "wagtailusers_users:index"
     edit_url_name = "wagtailusers_users:edit"
     delete_url_name = "wagtailusers_users:delete"
-    success_message = _("User '{0}' updated.")
+    success_message = _("User '%(object)s' updated.")
     context_object_name = "user"
     error_message = gettext_lazy("The user could not be saved due to errors.")
 
     def get_page_title(self):
-        return _("Editing %s") % self.object.get_username()
+        return _("Editing %(object)s") % {"object": self.object.get_username()}
 
     def get_page_subtitle(self):
         return ""
@@ -250,7 +250,7 @@ class Delete(DeleteView):
     index_url_name = "wagtailusers_users:index"
     page_title = gettext_lazy("Delete user")
     context_object_name = "user"
-    success_message = _("User '{0}' deleted.")
+    success_message = _("User '%(object)s' deleted.")
 
     def dispatch(self, request, *args, **kwargs):
         self.object = self.get_object()