# Wagtail 6.2 release notes

_August 1, 2024_

```{contents}
---
local:
depth: 1
---
```

## What's new

### Content metrics

The page editor’s Checks panel now displays two content metrics: word count, and reading time.
They are calculated based on the contents of the page preview, with a new mechanism to extract content from the previewed page for processing within the page editor. The Checks panel has also been redesigned to accommodate a wider breadth of types of checks, and interactive checks, in future releases.

This feature was developed by Albina Starykova and sponsored by The Motley Fool.

### Concurrent editing notifications

When multiple users concurrently work on the same content, Wagtail now displays notifications to inform them of potential editing conflicts. When a user saves their work, other users are informed and presented with options: they can refresh the page to view the latest changes, or proceed with their own changes, overwriting the other user's work.

Concurrent editing notifications are available for pages, and snippets. Specific messaging about conflicting versions is only available for pages and snippets with [support for saving revisions](wagtailsnippets_saving_revisions_of_snippets). To configure how often notifications are updated, use [`WAGTAIL_EDITING_SESSION_PING_INTERVAL`](wagtail_editing_session_ping_interval).

This feature was implemented by Matt Westcott and Sage Abdullah.

### Alt text accessibility check

The [built-in accessibility checker](authoring_accessible_content) now enforces a new `alt-text-quality` rule, which tests alt text for the presence of known bad patterns such as file extensions and underscores. This rule is enabled by default, but can be disabled if necessary.

This feature was implemented by Albina Starykova, with support from the Wagtail accessibility team.

### Universal listings designs for report views

All built-in and custom report views now use the Universal Listings visual design and filtering features introduced in all other listings in the admin interface over past releases. Thank you to Sage Abdullah for implementing this feature and continuing the rollout of the new designs.

### Compact StreamField representation for migrations

StreamField definitions within migrations are now represented in a more compact form, where blocks that appear in multiple places within a StreamField structure are only defined once. For complex and deeply-nested StreamFields, this considerably reduces the size of migration files, and the memory consumption when loading them. This feature was developed by Matt Westcott.

### Other features

 * Optimize and consolidate redirects report view into the index view (Jake Howard, Dan Braghis)
 * Support a [`HOSTNAMES` parameter on `WAGTAILFRONTENDCACHE`](frontendcache_multiple_backends) to define which hostnames a backend should respond to (Jake Howard, sponsored by Oxfam America)
 * Refactor redirects edit view to use the generic `EditView` and breadcrumbs (Rohit Sharma)
 * Allow custom permission policies on snippets to prevent superusers from creating or editing them (Sage Abdullah)
 * Do not link to edit view from listing views if user has no permission to edit (Sage Abdullah)
 * Allow access to snippets and other model viewsets to users with "View" permission (Sage Abdullah)
 * Skip `ChooseParentView` if only one possible valid parent page is available (Matthias Brück)
 * Add `copy_for_translation_done` signal when a page is copied for translation (Arnar Tumi Þorsteinsson)
 * Remove reduced opacity for draft page title in listings (Inju Michorius)
 * Implement a new design for locale labels in listings (Albina Starykova)
 * Add a `deactivate()` method to `ProgressController` (Alex Morega)
 * Allow manually specifying credentials for CloudFront frontend cache backend (Jake Howard)
 * Automatically register permissions for models registered with a `ModelViewSet` (Sage Abdullah)
 * Make `routable_resolver_match` attribute available on RoutablePageMixin responses (Andy Chosak)
 * Support customizations to `UserViewSet` via the app config (Sage Abdullah)
 * Allow changing available privacy options per page model (Shlomo Markowitz)
 * Add "soft" client-side validation for `StreamBlock` / `ListBlock` `min_num` / `max_num` (Matt Westcott)
 * Log accessibility checker results in the console to help developers with troubleshooting (Thibaud Colas)
 * Disable pointer events on checker highlights to simplify DevTools inspections (Thibaud Colas)

### Bug fixes

 * Make `WAGTAILIMAGES_CHOOSER_PAGE_SIZE` setting functional again (Rohit Sharma)
 * Enable `richtext` template tag to convert lazy translation values (Benjamin Bach)
 * Ensure permission labels on group permissions page are translated where available (Matt Westcott)
 * Preserve whitespace in comment replies (Elhussein Almasri)
 * Address layout issues in the title cell of universal listings (Sage Abdullah)
 * Support SVG icon id attributes with single quotes in the styleguide (Sage Abdullah)
 * Do not show delete button on model edit views if per-instance permissions prevent deletion (Matt Westcott)
 * Remove duplicate header in privacy dialog when a privacy setting is set on a parent page or collection (Matthias Brück)
 * Allow renditions of `.ico` images (Julie Rymer)
 * Fix the rendering of grouped choices when using ChoiceFilter in combination with choices (Sébastien Corbin)
 * Add separators when displaying multiple error messages on a StructBlock (Kyle Bayliss)
 * Specify `verbose_name` on `TranslatableMixin.locale` so that it is translated when used as a label (Romein van Buren)
 * Disallow null characters in API filter values (Jochen Wersdörfer)
 * Fix image preview when Willow optimizers are enabled (Alex Tomkins)
 * Ensure external-to-internal link conversion works when the `wagtail_serve` view is on a non-root path (Sage Abdullah)
 * Add missing `for_instance` method to `PageLogEntryManager` (Matt Westcott)
 * Ensure that "User" column on history view is translatable (Romein van Buren)
 * Handle StreamField migrations where the field value is null (Joshua Munn)
 * Prevent incorrect menu ordering when order value is 0 (Ben Dickinson)
 * Fix dynamic image serve view with certain backends (Sébastien Corbin)
 * Show not allowed extension in error message (Sahil Jangra)
 * Fix focal point chooser when localization enabled (Sébastien Corbin)
 * Ensure that system checks for `WAGTAIL_DATE_FORMAT`, `WAGTAIL_DATETIME_FORMAT` and `WAGTAIL_TIME_FORMAT` take `FORMAT_MODULE_PATH` into account (Sébastien Corbin)
 * Prevent rich text fields inside choosers from being duplicated when opened repeatedly (Sage Abdullah)

### Documentation

 * Remove duplicate section on frontend caching proxies from performance page (Jake Howard)
 * Document `restriction_type` field on [PageViewRestriction](../reference/pages/model_reference.md) (Shlomo Markowitz)
 * Document Wagtail's bug bounty policy (Jake Howard)
 * Fix incorrect Sphinx-style code references to use MyST style (Byron Peebles)
 * Document the fact that `Orderable` is not required for inline panels (Bojan Mihelac)
 * Add note about `prefers-reduced-motion` to the [accessibility documentation](accessibility_resources) (Roel Koper)
 * Update deployment instructions for Fly.io (Jeroen de Vries)
 * Add better docs for generating URLs on [creating admin views](../extending/admin_views.md) (Shlomo Markowitz)
 * Document the `vary_fields` property for [custom image filters](custom_image_filters) (Daniel Kirkham)
 * Fix documentation build errors (Himanshu Garg, Chris Shenton)
 * Fix PDF export (Nathanaël Jourdane)

### Maintenance

 * Use `DjangoJSONEncoder` instead of custom `LazyStringEncoder` to serialize Draftail config (Sage Abdullah)
 * Refactor image chooser pagination to check `WAGTAILIMAGES_CHOOSER_PAGE_SIZE` at runtime (Matt Westcott)
 * Exclude the `client/scss` directory in Tailwind content config to speed up CSS compilation (Sage Abdullah)
 * Split `contrib.frontend_cache.backends` into dedicated sub-modules (Andy Babic)
 * Remove unused `docs/autobuild.sh` script (Sævar Öfjörð Magnússon)
 * Replace `urlparse` with `urlsplit` to improve performance (Jake Howard)
 * Optimize embed finder lookups (Jake Howard)
 * Improve performance of initial admin loading by moving sprite hashing out of module import time (Jake Howard)
 * Remove workaround and inline scripts for activating workflow actions (Sage Abdullah)
 * Prevent `'BlockWidget' object has no attribute '_block_json'` from masking errors during StreamField serialization (Matt Westcott)


## Upgrade considerations - changes affecting all projects

### Specifying a dict of distribution IDs for CloudFront cache invalidation is deprecated

Previous versions allowed passing a dict for `DISTRIBUTION_ID` within the `WAGTAILFRONTENDCACHE` configuration for a CloudFront backend, to allow specifying different distribution IDs for different hostnames. This is now deprecated; instead, multiple distribution IDs should be defined as [multiple backends](frontendcache_multiple_backends), with a `HOSTNAMES` parameter to define the hostnames associated with each one. For example, a configuration such as:

```python
WAGTAILFRONTENDCACHE = {
    'cloudfront': {
        'BACKEND': 'wagtail.contrib.frontend_cache.backends.CloudfrontBackend',
        'DISTRIBUTION_ID': {
            'www.wagtail.org': 'your-distribution-id',
            'www.madewithwagtail.org': 'other-distribution-id',
        },
    },
}
```

should now be rewritten as:

```python
WAGTAILFRONTENDCACHE = {
    'mainsite': {
        'BACKEND': 'wagtail.contrib.frontend_cache.backends.CloudfrontBackend',
        'DISTRIBUTION_ID': 'your-distribution-id',
        'HOSTNAMES': ['www.wagtail.org'],
    },
    'madewithwagtail': {
        'BACKEND': 'wagtail.contrib.frontend_cache.backends.CloudfrontBackend',
        'DISTRIBUTION_ID': 'other-distribution-id',
        'HOSTNAMES': ['www.madewithwagtail.org'],
    },
}
```

### Changes to permissions registration for models with `ModelViewSet` and `SnippetViewSet`

Models registered with a `ModelViewSet` will now automatically have their {class}`~django.contrib.auth.models.Permission` objects registered in the Groups administration area. Previously, you need to use the [`register_permissions`](register_permissions) hook to register them.

If you have a model registered with a `ModelViewSet` and you registered the model's permissions using the `register_permissions` hook, you can now safely remove the hook.

If the viewset has {attr}`~wagtail.admin.viewsets.model.ModelViewSet.inspect_view_enabled` set to `True`, all permissions for the model are registered. Otherwise, the "view" permission is excluded from the registration.

To customize which permissions get registered for the model, you can override the {meth}`~wagtail.admin.viewsets.model.ModelViewSet.get_permissions_to_register` method.

This behavior now applies to snippets as well. Previously, the "view" permission for snippets is always registered regardless of `inspect_view_enabled`. If you wish to register the "view" permission, you can enable the inspect view:

```py
class FooViewSet(SnippetViewSet):
    ...
    inspect_view_enabled = True
```

Alternatively, if you wish to register the "view" permission without enabling the inspect view (i.e. the previous behavior), you can override `get_permissions_to_register` like the following:

```py
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType

class FooViewSet(SnippetViewSet):
    def get_permissions_to_register(self):
        content_type = ContentType.objects.get_for_model(self.model)
        return Permission.objects.filter(content_type=content_type)
```

### Deprecation of `WAGTAIL_USER_EDIT_FORM`, `WAGTAIL_USER_CREATION_FORM`, and `WAGTAIL_USER_CUSTOM_FIELDS` settings

This release introduces a customizable `UserViewSet` class, which can be used to customize various aspects of Wagtail's admin views for managing users, including the form classes for creating and editing users. As a result, the [`WAGTAIL_USER_EDIT_FORM`, `WAGTAIL_USER_CREATION_FORM`, and `WAGTAIL_USER_CUSTOM_FIELDS` settings](user_form_settings) have been deprecated in favor of customizing the form classes via `UserViewSet.get_form_class()`.

If you use the aforementioned settings, you can migrate your code by making the following changes.

#### Before

Given the following custom user model:

```py
class User(AbstractUser):
    country = models.CharField(verbose_name='country', max_length=255)
    status = models.ForeignKey(MembershipStatus, on_delete=models.SET_NULL, null=True, default=1)
```

The following custom forms:

```py
class CustomUserEditForm(UserEditForm):
    status = forms.ModelChoiceField(queryset=MembershipStatus.objects, required=True, label=_("Status"))


class CustomUserCreationForm(UserCreationForm):
    status = forms.ModelChoiceField(queryset=MembershipStatus.objects, required=True, label=_("Status"))
```

And the following settings:

```py
WAGTAIL_USER_EDIT_FORM = "myapp.forms.CustomUserEditForm"
WAGTAIL_USER_CREATION_FORM = "myapp.forms.CustomUserCreationForm"
WAGTAIL_USER_CUSTOM_FIELDS = ["country", "status"]
```

#### After

Change the custom forms to the following:

```py
class CustomUserEditForm(UserEditForm):
    status = forms.ModelChoiceField(queryset=MembershipStatus.objects, required=True, label=_("Status"))

    # Use ModelForm's automatic form fields generation for the model's `country` field,
    # but use an explicit custom form field for `status`.
    # This replaces the `WAGTAIL_USER_CUSTOM_FIELDS` setting.
    class Meta(UserEditForm.Meta):
        fields = UserEditForm.Meta.fields | {"country", "status"}

class CustomUserCreationForm(UserCreationForm):
    status = forms.ModelChoiceField(queryset=MembershipStatus.objects, required=True, label=_("Status"))

    # Use ModelForm's automatic form fields generation for the model's `country` field,
    # but use an explicit custom form field for `status`.
    # This replaces the `WAGTAIL_USER_CUSTOM_FIELDS` setting.
    class Meta(UserCreationForm.Meta):
        fields = UserEditForm.Meta.fields | {"country", "status"}
```

Create a custom `UserViewSet` subclass in e.g. `myapp/viewsets.py`:

```py
# myapp/viewsets.py
from wagtail.users.views.users import UserViewSet as WagtailUserViewSet

from .forms import CustomUserCreationForm, CustomUserEditForm


class UserViewSet(WagtailUserViewSet):
    # This replaces the WAGTAIL_USER_EDIT_FORM and WAGTAIL_USER_CREATION_FORM settings
    def get_form_class(self, for_update=False):
        if for_update:
            return CustomUserEditForm
        return CustomUserCreationForm
```

If you already have a custom `GroupViewSet` as described in [](customizing_group_views), you can reuse the custom `WagtailUsersAppConfig` subclass. Otherwise, create an `apps.py` file within your project folder (the one containing the top-level settings and urls modules) e.g. `myproject/apps.py`. Then, create a custom `WagtailUsersAppConfig` subclass in that file, with a `user_viewset` attribute pointing to the custom `UserViewSet` subclass:

```py
# myproject/apps.py
from wagtail.users.apps import WagtailUsersAppConfig


class CustomUsersAppConfig(WagtailUsersAppConfig):
    user_viewset = "myapp.viewsets.UserViewSet"
    # If you have customized the GroupViewSet before
    group_viewset = "myapp.viewsets.GroupViewSet"
```

Replace `wagtail.users` in `settings.INSTALLED_APPS` with the path to `CustomUsersAppConfig`:

```python
INSTALLED_APPS = [
    ...,
    # Make sure you have two separate entries for the custom user model's app
    # and the custom app config for the wagtail.users app
    "myapp",  # an app that contains the custom user model
    "myproject.apps.CustomUsersAppConfig",  # a custom app config for the wagtail.users app
    # "wagtail.users",  # this should be removed in favour of the custom app config
    ...,
]
```

```{warning}
You can also place the `WagtailUsersAppConfig` subclass inside the same `apps.py` file of your custom user model's app (instead of in a `myproject/apps.py` file), but you need to be careful. Make sure to use two separate config classes instead of turning your existing `AppConfig` subclass into a `WagtailUsersAppConfig` subclass, as that would cause Django to pick up your custom user model as being part of `wagtail.users`. You may also need to set {attr}`~django.apps.AppConfig.default` to `True` in your own app's `AppConfig`, unless you already use a dotted path to the app's `AppConfig` subclass in `INSTALLED_APPS`.
```

For more details, see [](custom_userviewset).

## Upgrade considerations - changes affecting Wagtail customisations

### Changes to report views with the new Universal Listings UI

The report views have been reimplemented to use the new Universal Listings UI, which introduces AJAX-based filtering and support for the `wagtail.admin.ui.tables` framework.
As a result, a number of changes have been made to the `ReportView` and `PageReportView` classes, as well as their templates.

If you have custom report views as documented in [](adding_reports), you will need to make the following changes.

#### Change `title` to `page_title`

The `title` attribute on the view class should be renamed to `page_title`:

```diff
 class UnpublishedChangesReportView(PageReportView):
-    title = "Pages with unpublished changes"
+    page_title = "Pages with unpublished changes"
```

#### Set up the results-only view

Set the `index_url_name` and `index_results_url_name` attributes on the view class:

```diff
 class UnpublishedChangesReportView(PageReportView):
+    index_url_name = "unpublished_changes_report"
+    index_results_url_name = "unpublished_changes_report_results"
```

and register the results-only view:

```diff
 @hooks.register("register_admin_urls")
 def register_unpublished_changes_report_url():
     return [
         path("reports/unpublished-changes/", UnpublishedChangesReportView.as_view(), name="unpublished_changes_report"),
+        # Add a results-only view to add support for AJAX-based filtering
+        path("reports/unpublished-changes/results/", UnpublishedChangesReportView.as_view(results_only=True), name="unpublished_changes_report_results"),
     ]
```

#### Adjust the templates

If you are only extending the templates to add your own markup for the listing table (and not other parts of the view template), you need to change the `template_name` into `results_template_name` on the view class.

For a page report, the following changes are needed:

- Change `template_name` to `results_template_name`, and optionally rename the template (e.g. `reports/unpublished_changes_report.html` to `reports/unpublished_changes_report_results.html`).
- The template should extend from `wagtailadmin/reports/base_page_report_results.html`.
- The `listing` and `no_results` blocks should be renamed to `results` and `no_results_message`, respectively.

```diff
 class UnpublishedChangesReportView(PageReportView):
-    template_name = "reports/unpublished_changes_report.html"
+    results_template_name = "reports/unpublished_changes_report_results.html"
```

```diff
 {# <project>/templates/reports/unpublished_changes_report_results.html #}

-{% extends "wagtailadmin/reports/base_page_report.html" %}
+{% extends "wagtailadmin/reports/base_page_report_results.html" %}

-{% block listing %}
+{% block results %}
     {% include "reports/include/_list_unpublished_changes.html" %}
 {% endblock %}
-{% block no_results %}
+{% block no_results_message %}
     <p>No pages with unpublished changes.</p>
 {% endblock %}
```

For a non-page report, the following changes are needed:

- Change `template_name` to `results_template_name`, and optionally rename the template (e.g. `reports/custom_non_page_report.html` to `reports/custom_non_page_report_results.html`).
- The template should extend from `wagtailadmin/reports/base_report_results.html`.
- Existing templates will typically define a `results` block containing both the results listing and the "no results" message; these should now become separate blocks named `results` and `no_results_message`.

**Before:**

```py
class CustomNonPageReportView(ReportView):
    template_name = "reports/custom_non_page_report.html"
```

```html+django
{# <project>/templates/reports/custom_non_page_report.html #}
{% extends "wagtailadmin/reports/base_report.html" %}

{% block results %}
    {% if object_list %}
        <table class="listing">
            <!-- Table markup goes here -->
        </table>
    {% else %}
        <p>No results found.</p>
    {% endif %}
{% endblock %}
```

**After:**

```py
class CustomNonPageReportView(ReportView):
    results_template_name = "reports/custom_non_page_report_results.html"
```

```html+django
{# <project>/templates/reports/custom_non_page_report_results.html #}
{% extends "wagtailadmin/reports/base_report_results.html" %}

{% block results %}
    <table class="listing">
        <!-- Table markup goes here -->
    </table>
{% endblock %}

{% block no_results_message %}
    <p>No results found.</p>
{% endblock %}
```

If you need to completely customize the view's template, you can still override the `template_name` attribute on the view class. Note that both `ReportView` and `PageReportView` now use the `wagtailadmin/reports/base_report.html` template, which now extends the `wagtailadmin/generic/listing.html` template. The `wagtailadmin/reports/base_page_report.html` template is now unused and should be replaced with `wagtailadmin/reports/base_report.html`.

If you override `template_name`, it is still necessary to set `results_template_name` to a template that extends `wagtailadmin/reports/base_report_results.html` (or `wagtailadmin/reports/base_page_report_results.html` for page reports), so the view can correctly update the listing and show the active filters as you apply or remove any filters.

## Upgrade considerations - changes to undocumented internals

### Deprecation of `window.ActivateWorkflowActionsForDashboard` and `window.ActivateWorkflowActionsForEditView`

The undocumented usage of the JavaScript `window.ActivateWorkflowActionsForDashboard` and `window.ActivateWorkflowActionsForEditView` functions will be removed in a future release.

These functions are only used by Wagtail to initialize event listeners to workflow action buttons via inline scripts and are never intended to be used in custom code. The inline scripts have been removed in favour of initializing the event listeners directly in the included `workflow-action.js` script. As a result, the functions no longer need to be globally-accessible.

Any custom workflow actions should be done using the [documented approach for customising the behavior of custom task types](custom_tasks_behavior), such as by overriding `Task.get_actions()`.
