We recently worked on a Wagtail project with Mozilla that required pages to be translated into many (up to 80) languages. Due to pages potentially needing to have different structures in different languages, wagtail-localize was used to provide page-level translations. However, we ran into several issues, which we hoped to solve by creating a dashboard for translated pages.
Wagtail Translation Options
First, I should mention that at the time of writing, a number of libraries exist to support translating pages and fields. Here are a few popular ones:
- wagtail-localize: An official translation plugin for Wagtail that stores translations at the page level using Wagtail's built-in locale system. It supports translating pages within Wagtail's admin interface, integrates with external translation services (like Pontoon and DeepL), and provides PO file import/export capabilities. This is the recommended solution for Wagtail 2.11+.
- wagtail-modeltranslation: An older approach that stores translations at the model field level rather than the page level. This package automatically reads field values based on the current language. While it predates Wagtail's official internationalization support, it may still be useful for projects that need field-level translation storage.
- wagtail.contrib.simple_translation: A built-in Wagtail module that allows editors to copy pages and snippets between languages. However, it creates copies in the source language rather than providing actual translation functionality, so editors must manually translate the content after copying.
- wagtailtrans: Another third-party package that takes a different approach to managing translations, though it requires editors to work with separate pages for each language.
Solving a CMS user pain-point
The wagtail-localize library does a great job of supporting field translations within pages, tracking which fields have been translated or not, and updating the relevant pages when needed. However, several (CMS) user pain points came up in our discussions:
- it can be difficult to find a page in the Wagtail page explorer page tree, especially if there are many page trees with pages in many languages
- it can be difficult to track which page has been translated into which language, and to what extent. It is possible to find the page in each language's page tree, and manually count the number of fields which have been translated and which haven't, but ideally, users wanted to be able to answer questions like 'has this page been completely translated into all 80 languages?' or 'does this page have at least 80% of its fields translated in Spanish?'
Design Direction
We began by trying to gather users' requests, like:
- I want to see which pages have been completely translated into all languages
- I want to see how many languages each page has been translated into
- I want to see the percent completed for each of the translations of this page
As a result we came up with a loose idea of a list page showing all pages displayed in their initial language, with a column showing all translations with percentages, and relevant links to go edit the pages.
A designer drew up what each row could look like:
And with this, we began our implementation.
Current Wagtail Dashboard
Wagtail currently has a page explorer dashboard, which supports searching and filtering, and it works pretty well. Our first thought was whether we could add such functionality by extending the dashboard columns and filters. However, it appears that doing so isn't straightforward and would require a significant amount of work around the way that columns and filters currently work (though custom columns are being considered for a future release, possibly Wagtail 7.3, see https://github.com/wagtail/wagtail/issues/11931 for more information).
Wanting to ship a usable product, rather than experimenting with the complexities of overriding wagtail behavior, we decided on building a custom dashboard to specifically address our users' needs (though keeping in mind the possibility of incorporating the features into the Wagtail page explorer in the future, and building the code in a way that could make this possible).
Implementation
We began by following Wagtail documentation about building a custom view:
1. we created a view to show all pages only in their original language
# views.py
from django.contrib.admin.views.decorators import staff_member_required
from django.db.models import Min
from django.utils.decorators import method_decorator
from django.views.generic import ListView
from wagtail.admin.views.generic.base import BaseListingView
from wagtail.models import Page
@method_decorator(staff_member_required, name="dispatch")
class TranslationsListView(ListView, BaseListingView):
"""
A view that shows a list of pages with their translations.
"""
model = Page
template_name = "cms/translations_list.html"
context_object_name = "pages"
paginate_by = 50
def get_queryset(self):
"""
Get original pages only, excluding root pages and translations of pages.
"""
# Get all pages (live and draft), excluding root pages
# Exclude root page (depth=1) and locale root pages (depth=2)
all_pages = Page.objects.filter(depth__gt=2).select_related("locale")
# All translations of a page should have the same translation_key, so we
# get the ids of the original translations by using the minimum ID for
# each unique translation_key.
min_ids_by_translation_key = (
all_pages.order_by("translation_key")
.values("translation_key")
.annotate(min_id=Min("id"))
.values_list("min_id", flat=True)
)
# Return original pages (not any of their translations).
return Page.objects.filter(id__in=min_ids_by_translation_key).order_by("title")
2. we added it to the URLs file
# urls.py
from .views import TranslationsListView
urlpatterns = [
path("pages-list/", TranslationsListView.as_view(), name="translations_list"),
]
3. we added a template file
# translations_list.html
{% extends "wagtailadmin/base.html" %}
{% load wagtailadmin_tags %}
{% block titletag %}Translations List{% endblock %}
{% block bodyclass %}listing{% endblock %}
{% block content %}
<div>
<header class="w-sticky w-top-0 w-z-header pl-4em">
<div class="merged-header__icon icon icon-wagtail-localize-language"></div>
<h1
class="w-pl-slim-header sm:w-pl-5 w-min-h-slim-header sm:w-pr-2 w-w-full w-flex-1 w-overflow-x-auto w-box-border"
>
Translations List
</h1>
</header>
{% if pages_with_translations %}
<div>
<table class="listing full-width">
<thead>
<tr class="table-headers">
<th class="title">Page Title</th>
<th>Status</th>
<th>Translations</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for page_data in pages_with_translations %}
<tr>
<td class="title">
<div class="title-wrapper">
<h2>
<a href="{{ page_data.edit_url }}">
{{ page_data.page.get_admin_display_title }}
</a>
</h2>
<div class="page-slug">{{ page_data.page.get_url }}</div>
</div>
</td>
<td>
{% if page_data.page.live %}
<span class="status-tag primary status-published">
Published
</span>
{% else %}
<span class="status-tag status-draft">
Draft
</span>
{% endif %}
</td>
<td>include translations here</td>
<td>
<div>
<a
href="{{ page_data.edit_url }}"
class="button button-small"
>
Edit
</a>
{% if page_data.page.live %}
<a
href="{{ page_data.view_url }}"
class="button button-small button-secondary"
target="_blank"
>
View
</a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No pages found.</p>
{% endif %}
</div>
{% endblock %}
4. we also added a menu item for our new view in the left-hand menu:
# wagtail_hooks.py
@hooks.register("register_admin_menu_item")
def register_pages_list_link():
return MenuItem(
"Translations List",
reverse("cms:translations_list"),
icon_name="wagtail-localize-language",
order=101,
)
Note: we also added tests for the new TranslationsListView, but I've omitted them here for brevity.
And we had a simple list view of pages in their original language.
To add translation data to the table, we added the data to the page context:
# views.py
class TranslationsListView(ListView):
...
def get_context_data(self, **kwargs):
"""Add translation data to the context."""
context = super().get_context_data(**kwargs)
# Add translation data for each page
pages_with_translations = []
for page in context["pages"]:
translations = []
try:
# Get all translations for this page
for translation in page.get_translations():
translations.append(
{
"locale": translation.locale.language_code,
"edit_url": f"/cms-admin/pages/{translation.id}/edit/",
"view_url": translation.get_url() if hasattr(translation, "get_url") else "#",
}
)
except (ValueError, AttributeError):
# Handle cases where translations might not be available
pass
pages_with_translations.append(
{
"page": page,
"translations": translations,
"edit_url": f"/cms-admin/pages/{page.id}/edit/",
"view_url": page.get_url() if hasattr(page, "get_url") else "#",
}
)
context["pages_with_translations"] = pages_with_translations
return context
and replaced <td>include translations here</td> in the HTML template code above with
<td>
{% if page_data.translations %}
<div>
{% for translation in page_data.translations %}
<a
href="{{ translation.edit_url }}"
class="button button-small button-secondary"
title="Edit {{ translation.locale }} version"
>
{{ translation.locale }}
</a>
{% endfor %}
</div>
{% else %}
<span class="no-translations">No translations</span>
{% endif %}
</td>
And we had a dashboard that displayed pages in their original language, with all translations shown in each row. After creating some test data in my database, it looked like:
Next, to include the percent of each translation, we created some helper functions to do the calculations:
# utils.py
import logging
from wagtail_localize.models import (
TranslatableObject,
Translation,
TranslationSource,
)
logger = logging.getLogger(__name__)
def get_translation_percentages_for_page(source_page, target_locale):
try:
# Find the translation source for the original page
translation_source = TranslationSource.objects.get_for_instance(
source_page
)
# Find the Translation record for this locale
translation_record = Translation.objects.get(
source=translation_source,
target_locale=target_locale,
)
# Get the actual translation progress using wagtail-localize logic
total_segments, translated_segments = translation_record.get_progress()
percent_translated = (
int((translated_segments / total_segments * 100))
if total_segments > 0
else 100
)
return percent_translated
except (
TranslationSource.DoesNotExist,
Translation.DoesNotExist,
TranslatableObject.DoesNotExist,
):
return None
def calculate_page_translation_data(source_page):
"""
Args:
page: A Page object
"""
translations_data = []
try:
page_translations = source_page.get_translations()
# Loop over all translations for this source_page,
# and get data for each of them.
for translation in page_translations:
# Get the translation data based on a translation
# from the source_page.
percent_translated = get_translation_percentages_for_page(
source_page,
translation.locale,
)
# Add the data to the translations_data.
translations_data.append(
{
"locale": translation.locale.language_code,
"edit_url": f"/cms-admin/pages/{translation.id}/edit/",
"view_url": (
translation.get_url()
if hasattr(translation, "get_url")
else "#"
),
"percent_translated": percent_translated,
}
)
except (ValueError, AttributeError) as error:
# If there is an unexpected error, then log it.
logger.exception(error, stack_info=True)
return translations_data
and we called the calculate_page_translation_data() function from our view
# views.py
from .utils import calculate_page_translation_data
class TranslationsListView(ListView):
...
def get_context_data(self, **kwargs):
"""Add translation data to the context."""
context = super().get_context_data(**kwargs)
# Add translation data for each page
pages_with_translations = []
for page in context["pages"]:
translations = calculate_page_translation_data(page)
pages_with_translations.append(
{
"page": page,
"translations": translations,
"edit_url": f"/cms-admin/pages/{page.id}/edit/",
"view_url": page.get_url() if hasattr(page, "get_url") else "#",
}
)
context["pages_with_translations"] = pages_with_translations
return context
Finally, we updated our template to show the translation percentages. We replaced:
{% for translation in page_data.translations %}
<a
href="{{ translation.edit_url }}"
class="button button-small button-secondary"
title="Edit {{ translation.locale }} version"
>
{{ translation.locale }}
</a>
{% endfor %}
with
{% for translation in page_data.translations %}
<a
href="{{ translation.edit_url }}"
class="button button-small button-secondary {% if translation.percent_translated == 100 %}btn-success{% elif translation.percent_translated >= 80 %}btn-warning{% else %}btn-danger{% endif %}"
title="Edit {{ translation.locale }} version - {{ translation.percent_translated }}% complete"
>
{{ translation.locale.upper }} {{ translation.percent_translated }}%
</a>
{% endfor %}
And after going through and translating some fields for some pages, we had a dashboard that displayed pages in their original language, with all translations shown in each row, and percentages of each translation.
We also used icons to show how complete a translation was (a green checkmark for 100%, an orange exclamation point for at least 80%, and a red exclamation point for less than 80%), but I didn’t include that code as a part of this example.
Our next step was to add filters, so we began with adding a filter for the original language.
# forms.py
from django import forms
from django.conf import settings
class TranslationsFilterForm(forms.Form):
original_language = forms.ChoiceField(
choices=[("", "All languages")] + list(settings.WAGTAIL_CONTENT_LANGUAGES),
required=False,
label="Original Language",
widget=forms.Select(attrs={"class": "w-field__input"}),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Ensure choices are always up to date
self.fields["original_language"].choices = [
("", "All languages")
] + list(settings.WAGTAIL_CONTENT_LANGUAGES)
# views.py
from .forms import TranslationsFilterForm
...
class TranslationsListView(ListView):
...
def get_queryset(self):
...
# Get the original pages (not any of their translations).
pages_qs = Page.objects.filter(
id__in=min_ids_by_translation_key
).order_by("title")
# Filter by original language (if specified).
form = TranslationsFilterForm(self.request.GET)
if not form.is_valid():
pages_qs = pages_qs.none()
elif form.cleaned_data.get("original_language"):
pages_qs = pages_qs.filter(
locale__language_code=form.cleaned_data["original_language"]
)
return pages_qs
def get_context_data(self, **kwargs):
...
# Add filter form to context
context["filter_form"] = TranslationsFilterForm(self.request.GET)
return context
and adding the form to our HTML template:
# translations_list.html
...
</header>
<div class="pl-4em">
<form method="get" class="w-flex w-gap-4 w-items-end sm:w-pl-5">
<div class="w-flex w-flex-col">
{{ filter_form.original_language.label_tag }}
{{ filter_form.original_language }}
</div>
<div class="flex align-items-end">
<button type="submit" class="button">Filter</button>
{% if filter_form.original_language.value %}
<a href="?" class="button button-secondary">Clear</a>
{% endif %}
</div>
</form>
</div>
{% if pages_with_translations %}
...
Note: we also added tests for the new TranslationsFilterForm, but I've omitted them here for brevity.
And we had a filterable dashboard that displayed pages in their original language, with all translations shown in each row, and percentages of each translation.
We also ended up adding filters for whether a page exists in a particular languages (including an option for 'All languages'), and a search.
A final addition was to make it possible to link from a page in the wagtail page explorer to our dashboard entry for that page.
We did this by adding to the buttons in the Wagtail page explorer.
Since each page in the Wagtail page explorer has the same translation_key as all of its translations, we added the ability to filter our dashboard by translation_key:
# forms.py
...
class TranslationsFilterForm(forms.Form):
...
translation_key = forms.UUIDField(
required=False,
label="Translation Key",
widget=forms.TextInput(
attrs={
"class": "w-field__input",
"placeholder": "Filter by translation key...",
}
),
)
# views.py
class TranslationsListView(ListView):
...
def get_queryset(self):
...
# Get the original pages (not any of their translations).
pages_qs = Page.objects.filter(
id__in=min_ids_by_translation_key
).order_by("title")
# Filter pages_qs by any filters (if specified).
form = TranslationsFilterForm(self.request.GET)
if not form.is_valid():
pages_qs = pages_qs.none()
else:
# Filter by translation key.
translation_key = form.cleaned_data.get("translation_key")
if translation_key:
pages_qs = pages_qs.filter(
translation_key=translation_key
)
# Filter by original language.
if form.cleaned_data.get("original_language"):
pages_qs = pages_qs.filter(
locale__language_code=form.cleaned_data["original_language"]
)
return pages_qs
def get_context_data(self, **kwargs):
...
# Add filter form to context
context["filter_form"] = TranslationsFilterForm(self.request.GET)
return context
and adding the filter to our HTML template:
# translations_list.html
...
</header>
<div class="pl-4em">
<form method="get" class="w-flex w-gap-4 w-items-end sm:w-pl-5">
<div class="w-flex w-flex-col">
{{ filter_form.original_language.label_tag }}
{{ filter_form.original_language }}
</div>
<div class="w-flex w-flex-col">
{{ filter_form.translation_key.label_tag }}
{{ filter_form.translation_key }}
</div>
<div class="flex align-items-end">
<button type="submit" class="button">Filter</button>
{% if filter_form.original_language.value or filter_form.translation_key.value %}
<a href="?" class="button button-secondary">Clear</a>
{% endif %}
</div>
</form>
</div>
{% if pages_with_translations %}
...
Note: we also added tests for the new filter, but I've omitted them here for brevity.
Then, we added a button to each row in the Wagtail page explorer by following the (Wagtail documentation:
# wagtail_hooks.py
from django.shortcuts import reverse
from wagtail.admin.widgets.button import Button
...
@hooks.register("construct_page_listing_buttons")
def add_custom_link_button(buttons, page, user, context=None):
"""
Add a custom 'See Translations' button to pages in the explorer.
Note: since home pages (and the root page) are not visible on the
translations list page, we do not show a 'See Translations' link
for the home pages (or the root page).
"""
# Only show the button for descendants of home pages
if page.depth > 2:
translations_button = Button(
label="See Translations",
classname="button",
attrs={"target": "_blank"},
url=(
f"{reverse('cms:translations_list')}?"
f"translation_key={page.translation_key}"
),
)
buttons.append(translations_button)
return buttons
and we had button to go from the Wagtail page explorer to our dashboard for a particular page.
When clicking on the "See Translations" button, the user would be taken to a filtered view of the translations dashboard.
Summary
Beginning with the functionality that `wagtail-localize` provides, we were able to build a dashboard to address users' pain points about seeing the relevant data in Wagtail, so that they can better fulfill their roles. The custom dashboard displays all pages in their original language alongside translation progress percentages, making it easy to see at a glance which pages are fully translated and which need attention. By adding filters for language and translation completion, along with a link from the existing Wagtail page explorer, we created a tool that significantly improves the workflow for content managers working with multilingual sites.
Added Note
These changes were implemented on the Mozilla firefox.com codebase, so if you’re interested in seeing more details that were not included in this post (such as icon implementation, more filters, unit tests, or page load improvements), here are the relevant pull requests: