Adding wagtail-modeltranslation to get at least some sort of localization. (#1566)

* Added localization through wagtail-modeltranslation + custom UX scripts + documentation on what was added and how to undo things when wagtail eventually comes with built-in localization
This commit is contained in:
Pomax 2018-08-21 10:30:47 -07:00 коммит произвёл GitHub
Родитель b9def72eb2
Коммит fc55d43563
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
12 изменённых файлов: 1036 добавлений и 35 удалений

Просмотреть файл

@ -24,11 +24,12 @@ Pillow = "*"
python-slugify = "*"
requests = "*"
social-auth-app-django = "*"
wagtail = "*"
wagtail = "==2.1.2"
wagtail-factories = {ref = "1ead51cadaad3b4530ba2197ccf45d2dca87dbdf", git = "https://github.com/mvantellingen/wagtail-factories"}
wagtail-inventory = "*"
wagtail-metadata = "*"
whitenoise = "==4.0b4"
wagtail-modeltranslation = "*"
[dev-packages]
django-debug-toolbar = "*"

65
Pipfile.lock сгенерированный
Просмотреть файл

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "a3477cdef2ce7197daa58f5f01ddf538929b8d6570fc560f1b9507b4a35a3373"
"sha256": "aa58893cd3438cef52209a1f76777843cd61131925d6863e756f5910467efe68"
},
"pipfile-spec": 6,
"requires": {
@ -18,33 +18,33 @@
"default": {
"beautifulsoup4": {
"hashes": [
"sha256:11a9a27b7d3bddc6d86f59fb76afb70e921a25ac2d6cc55b40d072bd68435a76",
"sha256:7015e76bf32f1f574636c4288399a6de66ce08fb7b2457f628a8d70c0fbabb11",
"sha256:808b6ac932dccb0a4126558f7dfdcf41710dd44a4ef497a0bb59a77f9f078e89"
"sha256:194ec62a25438adcb3fdb06378b26559eda1ea8a747367d34c33cef9c7f48d57",
"sha256:90f8e61121d6ae58362ce3bed8cd997efb00c914eae0ff3d363c32f9a9822d10",
"sha256:f0abd31228055d698bb392a826528ea08ebb9959e6bea17c606fd9c9009db938"
],
"version": "==4.6.0"
"version": "==4.6.3"
},
"boto3": {
"hashes": [
"sha256:1fb25a1d8455b97276ef5f1e14255c04f59a985a14ddb69804ddf6c8a3449e08",
"sha256:71ee5169b6957298fb178b294452592cd7c734e5c0d1a67487b56f993085f254"
"sha256:72f9f303e76ded17b47b1f5333debe2d852d4e87f798924817d488e37634ef1d",
"sha256:a1aa7581d6050094a74c2e1715fb91d0c9111862b04687e141a6920976dd6158"
],
"index": "pypi",
"version": "==1.7.71"
"version": "==1.7.76"
},
"botocore": {
"hashes": [
"sha256:9302ad235db66efa9d11c664b1cb0b259826d82a206446460ea05bcfcc431a4a",
"sha256:ffa673c9a53f3ab4eba4ce8cf9d736177ca67509827e716cb5070f0b621fb0a7"
"sha256:c24d9f17b46be393268d66f45fca9822e71cfab5761b8d070e11441ee7fe454d",
"sha256:f3a29d655355e52afe3760a6c6e990a11d69e9f63f6e6a542863d32fb12f0ff7"
],
"version": "==1.10.71"
"version": "==1.10.76"
},
"certifi": {
"hashes": [
"sha256:13e698f54293db9f89122b0581843a782ad0934a4fe0172d2a980ba77fc61bb7",
"sha256:9fa520c1bacfb634fa7af20a76bcbd3d5fb390481724c597da32c719a7dca4b0"
"sha256:4c1d68a1408dd090d2f3a869aa94c3947cc1d967821d1ed303208c9f41f0f2f4",
"sha256:b6e8b28b2b7e771a41ecdd12d4d43262ecab52adebbafa42c77d6b57fb6ad3a4"
],
"version": "==2018.4.16"
"version": "==2018.8.13"
},
"chardet": {
"hashes": [
@ -58,6 +58,7 @@
"sha256:24d7f2f94f7f3cb6061acb215685e5125fbcdc40a857eff9de22518820b0a4f4",
"sha256:702a91ade2968a82beb0db1e0766a6a273f33d4616a6ce8cde475d8e09853b20"
],
"markers": "python_version >= '3.0'",
"version": "==0.5.0"
},
"dj-database-url": {
@ -117,9 +118,15 @@
},
"django-modelcluster": {
"hashes": [
"sha256:2a6c0638ba295d44fc316c42b6da20509b186fe3500c674539441d451de5abfe"
"sha256:42bd7fa91af9996d7dfd34e6b027445acbece188d371d63abd19dde4c7ac8fc8"
],
"version": "==4.1"
"version": "==4.2"
},
"django-modeltranslation": {
"hashes": [
"sha256:40149f959edefc9ab0824e8a7437298e8ca232b0aaa4762224c580ced3f1db17"
],
"version": "==0.12.2"
},
"django-storages": {
"hashes": [
@ -131,10 +138,10 @@
},
"django-taggit": {
"hashes": [
"sha256:58aa3e59e0643446e102523f22d137300298e2a537b1c5b0c310d99143f2c2b8",
"sha256:fd13e304ba37ff09e601c4797d893fb7d3e699a789b5afb0b09d686f94470441"
"sha256:a21cbe7e0879f1364eef1c88a2eda89d593bf000ebf51c3f00423c6927075dce",
"sha256:db4430ec99265341e05d0274edb0279163bd74357241f7b4d9274bdcb3338b17"
],
"version": "==0.22.2"
"version": "==0.23.0"
},
"django-treebeard": {
"hashes": [
@ -173,11 +180,11 @@
},
"faker": {
"hashes": [
"sha256:0e9a1227a3a0f3297a485715e72ee6eb77081b17b629367042b586e38c03c867",
"sha256:b4840807a94a3bad0217d6ed3f9b65a1cc6e1db1c99e1184673056ae2c0a4c4d"
"sha256:ea7cfd3aeb1544732d08bd9cfba40c5b78e3a91e17b1a0698ab81bfc5554c628",
"sha256:f6d67f04abfb2b4bea7afc7fa6c18cf4c523a67956e455668be9ae42bccc21ad"
],
"index": "pypi",
"version": "==0.8.17"
"version": "==0.9.0"
},
"filebrowser-safe": {
"hashes": [
@ -448,6 +455,14 @@
"index": "pypi",
"version": "==2.0.0"
},
"wagtail-modeltranslation": {
"hashes": [
"sha256:026777941eb1245de992fa655ba918ffc7bd2e4b06afcf369a406cc908bbd9c6",
"sha256:d9fa2666c8c51b78d3d966945c6080dc39b5422b028d70391e6cc0091c937cf1"
],
"index": "pypi",
"version": "==0.9.0"
},
"webencodings": {
"hashes": [
"sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78",
@ -474,10 +489,10 @@
"develop": {
"certifi": {
"hashes": [
"sha256:13e698f54293db9f89122b0581843a782ad0934a4fe0172d2a980ba77fc61bb7",
"sha256:9fa520c1bacfb634fa7af20a76bcbd3d5fb390481724c597da32c719a7dca4b0"
"sha256:4c1d68a1408dd090d2f3a869aa94c3947cc1d967821d1ed303208c9f41f0f2f4",
"sha256:b6e8b28b2b7e771a41ecdd12d4d43262ecab52adebbafa42c77d6b57fb6ad3a4"
],
"version": "==2018.4.16"
"version": "==2018.8.13"
},
"chardet": {
"hashes": [

Просмотреть файл

@ -1,2 +1,2 @@
release: cd network-api && python ./manage.py migrate --no-input && python ./manage.py block_inventory
release: cd network-api && python ./manage.py migrate --no-input && python ./manage.py block_inventory && python ./manage.py sync_page_translation_fields && python ./manage.py update_translation_fields
web: cd network-api && gunicorn networkapi.wsgi:application

Просмотреть файл

@ -0,0 +1,47 @@
# (Temporary) localised content in Wagtail
This document exists primarily because the current localisation approach is at best a patch to ensure there is _some_ localisation possible of Wagtail content in lieu of an official localisation solution that would ideally take the form of:
- An API for retrieving strings tied to specific pages using a unique page id,
- a mechanism to send those strings to Pontoon, or similar translation/localisation community
- a mechanism to receive translations from Pontoon (or similar service)
- an API for instructing wagtail which localised strings correspond to which original strings, applicable to which locale.
While Wagtail forms a plan of attack around tackling this, we need a localisation solution, and so we have implemented the following:
- `wagtail-modeltranslation` is used to enable content localisation based on a `LANGUAGE_CODE` default language identifier, and `LANGUAGES` list of tuples (of the form `('code', 'full name of locale')`).
- a custom javascript library that improves usability by allowing users of the CMS to hide/reveal localisable fields based on the language they belong to.
The modeltranslation library adds localised URLs to Wagtail, interjecting the locale code in the URL betewen the host domain and the content path, such that a url like:
https://foundation.mozilla.org/campaigns/aadhaar
with "en" as locale, becomes:
https://foundation.mozilla.org/en/campaigns/aadhaar
This functionality is enabled in the main `urls.py` file, where any (subset of) routes that need this kind of infix locale must be wrapped by the Django `i18npatterns` call.
urlpatterns += i18n_patterns(
url(r'', include(wagtail_urls)),
)
Additionally, there is now a `translations.py` file in the `wagtailpages` app that describes which models should have which fields automatically added to the set of localizable fields. This is, quite wisely, tracked in separate tables, so that removing wagtail-modeltranslations does not lead to a complete loss of all pages ever made, or massive migrations beyond "drop the translation wrapper tables".
The [wagtail-modeltranslation](https://github.com/infoportugal/wagtail-modeltranslation) README.md covers all the steps taken to enable this on our end in, so please give that a read-over as well.
There is an open PR over on https://github.com/infoportugal/wagtail-modeltranslation/pull/211 which was opened to put our usability script in the modeltranslation package itself, however that PR has not landed at the time of writing this document, and so in order to land the PR that this document is in, the updated `wagtailhooks.py` that was necessary to get access to some of the settings over in the client environment has been manually added to a special app called `wagtail-l10n-customization`.
This special app has the following dir structure and content:
wagtail-l10n-customization
|- static
| |-css
| | `-language_toggles.css : styling for the UX improvements
| `-js
| `-language_toggles.js : UX improvements for working with localisable fields
`- wagtail_hooks.py : hooks into wagtail that load the UX improver in /cms
In order to ensure that things deploy smoothly, both the `Procfile` and the `tasks.py` have been updated with instructions specific to wagtail-modeltranslation use, so in the interest of knowing where to look when it comes time to take this back out, all the relevant changes happened by landing the following PR:
https://github.com/mozilla/foundation.mozilla.org/pull/1566

Просмотреть файл

@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/1.10/ref/settings/
import os
import environ
import dj_database_url
from django.utils.translation import gettext_lazy as _
app = environ.Path(__file__) - 1
root = app - 1
@ -170,7 +171,13 @@ INSTALLED_APPS = list(filter(None, [
'networkapi.highlights',
'networkapi.milestones',
# wagtail-specific app
# wagtail localisation app
'networkapi.wagtail_l10n_customization',
'wagtail_modeltranslation',
'wagtail_modeltranslation.makemigrations',
'wagtail_modeltranslation.migrate',
# wagtail-specific app prefixed so that it can be localised
'networkapi.wagtailpages',
'networkapi.buyersguide',
]))
@ -187,6 +194,7 @@ MIDDLEWARE = list(filter(None, [
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware', # should be after SessionMiddleware and before CommonMiddleware
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
@ -307,7 +315,16 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization
# https://docs.djangoproject.com/en/1.10/topics/i18n/
LANGUAGE_CODE = 'en-us'
LANGUAGE_CODE = 'en'
LANGUAGES = (
('en', _('English')),
('de', _('German')),
('pt', _('Portuguese')),
('es', _('Spanish')),
('fr', _('French')),
('pl', _('Polish')),
)
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True

Просмотреть файл

@ -13,13 +13,13 @@ Including another URLconf
1. Import the include() function: from django.conf.urls import url, include
2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls'))
'''
from django.conf.urls import url, include
from django.contrib import admin
from django.conf import settings
from django.conf.urls import url, include
from django.conf.urls.i18n import i18n_patterns
from django.conf.urls.static import static
from django.contrib import admin
from django.views.generic.base import RedirectView
from wagtail.admin import urls as wagtailadmin_urls
from wagtail.documents import urls as wagtaildocs_urls
from wagtail.core import urls as wagtail_urls
@ -61,19 +61,22 @@ urlpatterns = list(filter(None, [
url(r'^help/', review_app_help_view, name='Review app help'),
# Wagtail CMS routes
url(
r'^how-do-i-wagtail/',
RedirectView.as_view(url='/docs/how-do-i-wagtail/'),
name='how-do-i-wagtail'
),
url(r'^cms/', include(wagtailadmin_urls)),
url(r'^documents/', include(wagtaildocs_urls)),
url('^sitemap\.xml$', sitemap) if settings.DEBUG else None,
url(r'', include(wagtail_urls)),
]))
# Anything that needs to respect the localised
# url format with /<language_code>/ infixed needs
# to be wrapped by django's i18n_patterns feature:
urlpatterns += i18n_patterns(
url(r'', include(wagtail_urls)),
)
if settings.USE_S3 is not True:
urlpatterns += static(

Просмотреть файл

@ -0,0 +1,51 @@
form .l10n-hidden {
display: none;
}
div.locale-picker {
display: block;
text-align: right;
margin-top: 1.5em;
margin-bottom: 0.5em;
}
form > div.locale-picker {
/* calc, to highlight what we're doing here:
wagtail's header has a 2em margin-bottom,
and so we correct that for the 1.5em
margin-bottom used in the normal definition
for the div.locale-picker, above */;
margin-top: calc(1.5em - 2em);
margin-bottom: 1em;
}
div.locale-picker h2 {
display: inline;
}
div.locale-picker ul.locales {
display: inline-block;
margin: auto 1em;
}
div.locale-picker ul.locales li.locale {
display: inline;
margin: auto 0.5em;
}
div.locale-picker ul.locales li.locale button.locale-toggle {
background-color: rgba(57, 151, 150, 0.3);
text-transform: uppercase;
outline: none;
border: none;
font-weight: 700;
font-size: 1.2em;
text-decoration: none;
color: #fff;
padding: .25em;
max-height: 2em;
}
div.locale-picker ul.locales li.locale button.locale-toggle.showing-locale {
background-color: #43b1b0;
}

Просмотреть файл

@ -0,0 +1,146 @@
///////////////
jQuery( () => {
///////////////
const tabbedContent = $(`form .tab-content`);
const topLevel = (tabbedContent.length > 0) ? tabbedContent : $(`.content form`);
if (topLevel.length === 0) {
// obviously, if we don't have an element to attach
// the picker to, we might as well stop right now.
return;
}
if (topLevel.attr(`class`) && topLevel.attr(`class`).indexOf(`search`) > -1) {
// if the only forms on the page are search forms,
// we're not actually dealing with page/snippets
return;
}
/**
* ...
*/
function filterForLocale(index, element) {
var tc = element.textContent;
var res = tc.match(/ \[\w\w\]/);
if (res === null) return;
var code = res[0],
locale = code.replace(/[ \[\]]/g,'');
// Verify this is a known locale and not a fluke,
// using the global "langs" variable, which is an
// array of all language codes specified in the
// settings.LANGUAGES variable for Django.
if (langs.indexOf(locale) === -1) return;
// We do our show/hiding based on list items,
// otherwise we're just "emptying" a list item
// while leaving its spacing CSS intact.
if (element.nodeName !== "LI") {
element = $(element).closest("li")[0];
}
// Bootstrap an empty bin if we don't have one.
if (!localisedElements[locale]) {
localisedElements[locale] = [];
}
// Add this element to our bin, provided it had
// not already been added.
var bin = localisedElements[locale];
if (bin.indexOf(element) === -1) {
bin.push(element);
element.classList.add(`l10n-hidden`);
// also note that "field-col" elements may now look horribly
// wrong, due to how Wagtail computes which of "col3"..."col12"
// to use. Because wagtail-modeltranslation introduces many more
// elements to show in an "inline" element, things that were
// "col6" before end up being "col1", looking terribly wrong indeed.
element.classList.remove(...columnCSS);
}
}
/**
* Build the set of fields-per-locale. Each set will receive
* a button to toggle visibility for all fields in that set,
* with the note that unlocalised content (such as images)
* will always stay visible.
*/
function buildSets() {
$(`li.object, div.field`, topLevel).each( filterForLocale );
}
/**
* Build a locale picker bar, with buttons that toggle
* visibility for each locale's fields.
*/
function buildLocaleToggler() {
var bar = $(`<div class="locale-picker"><h2>View/edit fields for:</h2></div>`);
var ul = $(`<ul class="locales"></ul>`);
bar.append(ul);
var toggles = {};
locales.forEach( locale => {
var li = $(`<li class="locale"><button class="locale-toggle">${locale}</button></li>`);
ul.append(li);
$(`button.locale-toggle`, li).each( (index, toggle) => {
toggle.addEventListener(`click`, e => {
e.preventDefault();
toggle.classList.toggle(`showing-locale`);
toggleLocale(locale);
});
toggles[locale] = toggle;
});
});
bar.prependTo(topLevel);
return toggles;
}
/**
* This function allows either blind toggling
* of a field's visibility, or explicitly
* making visible/invisible based on the
* value of `state` (a boolean).
*/
function toggleLocale(locale, state) {
var action = `toggle`;
if (state !== undefined) {
action = state ? `remove` : `add`
}
localisedElements[locale].forEach(element => {
element.classList[action](`l10n-hidden`);
});
}
var default_locale = `en`;
var localisedElements = {};
var columnCSS = [`field-col`];
for (var i=1; i<=12; i++) { columnCSS.push(`col${i}`); }
// Build the sets that track which fields
// belong to which language code.
buildSets();
var locales = Object.keys(localisedElements).sort();
// If there are no locale sets, then there is
// no locale field picker to build, either.
if (locales.length === 0) return;
// If there are locale sets, make sure to
// enable at least the default locale after
// building and hiding all locale sets.
var localeToggler = buildLocaleToggler();
localeToggler[default_locale].click();
///////////////
});
///////////////

Просмотреть файл

@ -0,0 +1,48 @@
from django.conf import settings
from django.utils.html import format_html_join
from django.utils.translation import gettext as _
from wagtail.core import hooks
@hooks.register('insert_global_admin_js')
def language_toggles():
"""
On any admin page, try to load the l10n code that aggregates
fieldsets per locale, then gives it a button that you can
click to show/hide all those fields.
"""
js_files = ['js/language_toggles.js']
lang_codes = []
for lang in settings.LANGUAGES:
lang_codes.append("'%s'" % lang[0])
js_languages = """
<script>
wagtailModelTranslations = {{
languages: [{languages}],
defaultLanguage: '{language_code}',
viewEditString: '{view_edit_string}',
}};
</script>
""".format(
languages=", ".join(lang_codes),
language_code=settings.LANGUAGE_CODE,
view_edit_string=_('View / edit fields for')
)
js_includes = format_html_join(
'\n', '<script src="{0}{1}"></script>',
((settings.STATIC_URL, filename) for filename in js_files)
)
css_files = ['css/language_toggles.css']
css_includes = format_html_join(
'\n', '<link rel="stylesheet" href="{0}{1}">',
((settings.STATIC_URL, filename) for filename in css_files)
)
return js_languages + js_includes + css_includes

Различия файлов скрыты, потому что одна или несколько строк слишком длинны

Просмотреть файл

@ -0,0 +1,124 @@
from .models import (
ModularPage,
MiniSiteNameSpace,
PrimaryPage,
NewsPage,
InitiativesPage,
ParticipatePage,
PeoplePage,
Styleguide,
Homepage,
RedirectingPage,
OpportunityPage,
CampaignPage,
CTA,
Petition,
)
from .donation_modal import DonationModal
from modeltranslation.translator import TranslationOptions
from modeltranslation.decorators import register
@register(ModularPage)
class ModularPageTR(TranslationOptions):
fields = (
'header',
'body',
)
@register(MiniSiteNameSpace)
class MiniSiteNameSpaceTR(TranslationOptions):
fields = ()
@register(CampaignPage)
class CampaignPageTR(TranslationOptions):
fields = (
)
@register(DonationModal)
class DonationModalTR(TranslationOptions):
fields = (
'name',
'header',
'body',
'donate_text',
'dismiss_text',
)
@register(OpportunityPage)
class OpportunityPageTR(TranslationOptions):
fields = (
)
@register(CTA)
class CTATR(TranslationOptions):
fields = (
'name',
'header',
'description',
)
@register(Petition)
class PetitionTR(TranslationOptions):
fields = (
)
@register(PrimaryPage)
class PrimaryPageTR(TranslationOptions):
fields = (
'header',
'body',
)
@register(NewsPage)
class NewsPageTR(TranslationOptions):
fields = ()
@register(InitiativesPage)
class InitiativesPageTR(TranslationOptions):
fields = ()
@register(ParticipatePage)
class ParticipatePageTR(TranslationOptions):
fields = ()
@register(PeoplePage)
class PeoplePageTR(TranslationOptions):
fields = ()
@register(Styleguide)
class StyleguideTR(TranslationOptions):
fields = ()
@register(Homepage)
class HomepageTR(TranslationOptions):
fields = (
'hero_headline',
'hero_story_description',
'hero_button_text',
'hero_button_url',
)
@register(RedirectingPage)
class RedirectingPageTR(TranslationOptions):
fields = (
'URL',
)

Просмотреть файл

@ -47,6 +47,19 @@ def makemigrations(ctx):
manage(ctx, "makemigrations")
@task
def l10n_sync(ctx):
"""Sync localizable fields in the database"""
manage(ctx, "sync_page_translation_fields")
@task
def l10n_update(ctx):
"""Update localizable field data (copies from
original unlocalized to default localized field)"""
manage(ctx, "update_translation_fields")
@task
def test(ctx):
"""Run tests"""
@ -72,6 +85,10 @@ def setup(ctx):
ctx.run("inv manage load_fake_data")
print("Updating block information")
ctx.run("inv manage block_inventory")
print("Updating localizable fields");
ctx.run("inv l10n-sync")
ctx.run("inv l10n-update")
# Windows doesn't support pty, skipping this step
if platform == 'win32':
print("All done!\n"
@ -93,3 +110,6 @@ def catch_up(ctx):
ctx.run("inv migrate")
print("Updating block information")
ctx.run("inv manage block_inventory")
print("Updating localizable fields");
ctx.run("inv l10n-sync")
ctx.run("inv l10n-update")