Translating Static Template Text with Wagtail Localize

Introduction

You have all your page content and snippet components translating successfully, but what to do with all those bits of static text in the non-Wagtail pages?

Static text lurks in the error pages, search results, e-mail templates and any Django pages that may be getting served on your site.

Static text may also simply be labels that appear on your pages or stream blocks that you'd want to keep constant (the "Posted in:" & "Tagged with:" labels on this page for example).

The Traditional Route

The traditional route would be to tag all your template text to be translated with {% blocktrans %}, run makemessages, edit the PO file (or send it to the translation service) then finally compile the PO file.

There are several disadvantages to this, mainly that it's a lengthy process requiring multiple people and access to the server command line at both start and end. Another issue is that running makemessages will produce a PO file with every bit of translatable text in your site (both models and templates), you'll need to sift through everything just to get to those few lines that require translating.

So how else to deal with it?

You could hard code the translations into the templates and add some logic to test the language being served. Apart from putting unnecessary logic into templates (bad practice), this is a maintenance nightmare and still requires access to the template files sitting on the server. There's also potential to break a template in all sorts of ways on this route.

The translations could even be hard-coded into template tags that deliver the correct content. Still a maintenance headache as it requires developer access to update the code each time there's a change, not to mention needing to fork/edit/test/merge. Messy.

Solution

This is where the ease of Wagtail Localize can come into play - translate text directly in the CMS, upload/download targeted PO files or get instant translations using an online service like DEEPL.

Although static template text isn't currently translated by Wagtail Localize, here's an easy way to keep it all under the Wagtail Localize umbrella without the pain of making PO files and peppering your templates with {% blocktrans %} tags. Did I just say easy? Everything's relative I guess ...

Overview

I took the route of creating sets of what I called template texts to give the ability to separate the static text into manageable chunks according to category (e.g. page errors, email templates, user accounts etc.). Each of these holds a collection of tag names and text to be translated. In the template, the text set is loaded and the translated text is swapped in for the tag placeholder.

While this is a basic one-to-many relationship, because of the way the Wagtail interface works, the editing interface is much easier to treat this as a clusterable model with orderables. The order of set items makes no difference, it's just easier this way.

Imports

First off, the imports you'll need for both the clusterable and orderable models:

from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from modelcluster.fields import ParentalKey
from modelcluster.models import ClusterableModel
from wagtail.snippets.models import register_snippet
from wagtail_localize.fields import TranslatableField

from wagtail.models import Locale
from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel
from wagtail.models import Orderable, TranslatableMixin

The TemplateText model

This just needs one field for the set name, which needs to be both unique and non-translatable. There's a bit of complexity with unique fields and translatable sites, which I discussed in the previous blog, so I'll just add the code as is here. If you're wondering about the extra logic involved in the block, be sure to read that afterwards.

In a nutshell:

  • Fields can only be unique per locale as they are copied in translation.
  • Uniqueness needs to be defined in Meta unique_together property
  • A custom clean() method is required to handle any posted form that violates the unique_together clause
  • Extra logic prevents TemplateText instances being created outside of the default locale
  • A custom delete method removes all translated copies if the original is being deleted
  • The translatable_fields property is set to only include the orderable relationship which prevents the template_set field from being translated
@register_snippet
class TemplateText(TranslatableMixin, ClusterableModel):
    template_set = models.CharField(
        max_length=50,
        verbose_name="Set Name",
        help_text=_("The set needs to be loaded in template tags then text references as {{set.tag}}")
    )    

    translatable_fields = [
        TranslatableField('templatetext_items'),
    ]    

    panels = [
        FieldPanel("template_set"),
        MultiFieldPanel(
            [
                InlinePanel("templatetext_items"),
            ],
            heading=_("Text Items"),
        ),
    ]

    def __str__(self):
        return self.template_set
    
    class Meta:
        verbose_name = _('Template Text')
        unique_together = ('translation_key', 'locale'), ('locale', 'template_set')

    def clean(self):
        def_lang = Locale.get_default()
        
        if self.locale==Locale.get_default():
            if TemplateText.objects.filter(template_set=self.template_set).filter(locale=self.locale_id).exclude(pk=self.pk).count()>0:
                raise ValidationError(_("This template set name is already in use. Please only use a unique name."))
        elif self.get_translations().count()==0:
            raise ValidationError(_(f"Template sets can only be created in the default language ({def_lang}). \
                                      Please create the set in {def_lang} and use the translate option."))

    def delete(self):
        if self.locale==Locale.get_default():
            for trans in self.get_translations():
                trans.delete()
        super().delete()

Adding the Child Class

Now that we have the parent class defined, we'll define the items to add to it to get translated.

This requires three fields:

  1. set is the pointer back to TemplateText to say which set of texts it belongs to (required in any clusterable/orderable relationship). This is where we define the "templatetext_items" relationship name used in the translatable_fields property in the parent above.
  2. template_tag is placeholder name for the template that will be swapped out for the translated text. It needs to be non-translatable and unique within any set of template texts. This field will be used to create the key name in a dictionary object later. For this reason, a SlugField is used.
  3. text is the text to be translated and swapped into the template.
class TemplateTextSetItem(TranslatableMixin, Orderable):
    set = ParentalKey(
        "TemplateText",
        related_name="templatetext_items",
        help_text=_("Template Set to which this item belongs."),
        verbose_name="Set Name",
    )
    template_tag = models.SlugField(
        max_length=50,
        help_text=_("Enter a tag without spaces, consisting of letters, numbers, underscores or hyphens."),
        verbose_name="Template Tag",
    )    
    text = models.TextField(
        null=True,
        blank=True,
        help_text=_("The text to be inserted in the template.")
    )

    translatable_fields = [
        TranslatableField('text'),
    ]

    panels = [
        FieldPanel('template_tag'),
        FieldPanel('text'),
    ]

    def __str__(self):
        return self.template_tag

    class Meta:
        unique_together = ('set', 'template_tag'), ('translation_key', 'locale')

Once again, because unique isn't supported in translated models, we define the unique relationship as

  • unique_together = ('set', 'template_tag')

Because this is an Orderable, we don't need any further error handling for this as it's already included in the built-in validation.

Run makemigrations and migrate, go to your Snippets folder in Wagtail Admin and you'll see Template Texts added to the list.

Create a set and add some items to it.

Here, I've created a set named 'common' and added a couple of items for testing.

adding translatable text items for static template text
translating a Wagtail snippet

To make this translatable, click on the Translate button, select the language to publish in, and submit.

Now back on the list, hover over your template text set once again and select the Sync option. Tick the "Publish Immediately" box and submit.

Edit the set once again and change the language on the top bar dropdown. You can see the static text ready for translating now.

 
translating text in wagtail localize
 

Adding the Template Tag

We have our sets and translation items defined in the database now. How to deliver these to the templates during rendering?

We'll use a template tag to read in the set, convert it to a dictionary and return this to the template:

from django import template
register = template.Library()

@register.simple_tag()
def get_template_set(set):
    try:
        template_set = TemplateText.objects.filter(template_set=set).first().localized 
        if template_set:
            items = template_set.templatetext_items.all()
            if items:
                text_dict = {}
                for i in items:
                    text_dict[i.template_tag] = i.text
                return text_dict
        return TemplateText.objects.none()
                
    except (AttributeError, TemplateText.DoesNotExist):
        return TemplateText.objects.none()
  • We attempt to load the set. Since there will be an instance in each language with the same, we specify the set name as a filter and take the first (which will be the canonical set, though this isn't important).
  • With the set returned, the localized function is applied to return the translated set relevant to the language in the request object (or the default if the translation doesn't exist).
  • If the set is found with items, a dictionary is created with the template_tag's as key names and the corresponding text as key values. This is passed back to the template.
  • If the set either doesn't exist, or is empty, an empty object is returned.

Where you save this will depend on how you organise your site. I generally create a core app to keep various models, functions, etc. that don't belong in any of the other apps and have a core/templatetags/core_tags.py file for general tags like this.

Adding the Translated Text to Templates

The final step of the process is to add the code to load the template set and display the text during rendering.

  • Load the file that you saved the template tag to in the previous step.
  • Call get_template_set with the set name you need and assign that to a template variable.
  • Add placeholders to the template in the appropriate places using the format {{ variable.template_tag }}

In the (abbreviated) example below, a template set called 'social' has been created to deal with messages for logging in with and managing linked social accounts. The set includes two tags: login_error_title and login_error_message. The pages are served by Django outside of Wagtail.

In the authentication error template, the tags are loaded as follows:

{% extends "base.html" %}

{% load core_tags %}

{% block content %}
{% get_template_set 'social' as trans %}
<title>EnzedOnline - {{ trans.login_error_title }}</title>
<h5><strong>{{ trans.login_error_title }}<strong></h5>

<p>{{ trans.login_error_message }}.</p>

{% endblock %}
  • The template tag file is loaded ({% load core_tags %})
  • The set is loaded as assigned to an object variable trans
  • The trans object includes the two key/value pairs
  • login_error_title: "Social Network Login Failure"
  • login_error_message: "An error occurred while attempting to login via your social network account"
  • The tags are called as properties of that object {{ trans.login_error_title }} & {{ trans.login_error_message }}
  • Now, the authentication error is displayed in the language the page is being viewed in.
Social Network Login Failure

An error occurred while attempting to login via your social network account

Conclusion

There was a bit of work to set this up in the first place, but once in place, the gains in both usability and lower maintenance more than make up for it.

Another good feature of this is that even if someone deletes a tag or a set, or renames them, the worst thing that happens is blank space, no broken templates or 500 errors.

It does require the editors to know which tags go where in the templates. Documentation is the obvious solution to that.

Inevitably, if text needs to be moved or new text blocks are added, it still requires editing the template, but this will happen on any route.

The above code is all you need to drop into any Wagtail Localize site and start delivering your translated static content.

If you want to see this in action, the error pages are delivered via template texts (404 - Not Found for example), although, at this stage, I only have English enabled on this site. The "Previous Post"/"Next Post"/"Posted In"/"Tagged With" labels on this page are all also rendered using this method.

Thanks for reading this far, I hope this will prove useful to you.


  Please feel free to leave any questions or comments below, or send me a message here