Skip to content

Commit 94515a7

Browse files
LGA-2819 - Enable translation using flask-babel package (#29)
* Enable translation using flask-babel package * Add language switcher to footer * Update README with steps for how to translate text to welsh --------- Co-authored-by: Ben Millar <ben.millar@digital.justice.gov.uk>
1 parent cea0eb2 commit 94515a7

26 files changed

+255
-17
lines changed

.github/workflows/test.yml

+2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ jobs:
2626
2727
- name: Unit test with pytest
2828
run: |
29+
# Remove this after we move from build assets in-app to esbuild
30+
npm install
2931
pip install pytest pytest-cov
3032
coverage run -m pytest tests/unit_tests
3133
coverage xml

.gitignore

-6
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,6 @@ coverage.xml
7777
.pytest_cache/
7878
cover/
7979

80-
# Translations
81-
*.mo
82-
*.pot
83-
8480
# Django stuff:
8581
*.log
8682
local_settings.py
@@ -440,7 +436,6 @@ pyrightconfig.json
440436
### venv ###
441437
# Virtualenv
442438
# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/
443-
[Bb]in
444439
[Ii]nclude
445440
[Ll]ib
446441
[Ll]ib64
@@ -524,7 +519,6 @@ x86/
524519
[Aa][Rr][Mm]/
525520
[Aa][Rr][Mm]64/
526521
bld/
527-
[Bb]in/
528522
[Oo]bj/
529523
[Ll]og/
530524
[Ll]ogs/

Dockerfile

+6-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
ARG BASE_IMAGE=python:3.12-slim
22
FROM $BASE_IMAGE AS base
3-
4-
ARG REQUIREMENTS=requirements-production.txt
3+
ARG REQUIREMENTS_FILE=requirements-production.txt
54

65
# Set environment variables
76
ENV FLASK_APP=govuk-frontend-flask.py
@@ -21,10 +20,14 @@ RUN apt-get update \
2120
&& apt-get -y install nodejs npm \
2221
&& apt-get clean
2322

23+
# This is required because there has been an upgrade of this package which hasn't yet been updated in the base image
24+
# This can be removed when the base image is updated with the upgrade
25+
RUN apt-get install --only-upgrade libexpat1 -y
26+
2427
COPY package*.json ./
2528
RUN npm install
2629

27-
COPY requirements/generated/$REQUIREMENTS requirements.txt
30+
COPY requirements/generated/$REQUIREMENTS_FILE requirements.txt
2831
RUN pip install --upgrade pip
2932
RUN pip install -r requirements.txt
3033

README.md

+22
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,28 @@ To format all files in the directory, run:
134134
```shell
135135
ruff format
136136
```
137+
## Translation
138+
We are using the [Flask-Babel](https://python-babel.github.io/flask-babel/#) package to translate text.
139+
There are 4 key components to translating text on the website.
140+
1. babel.cfg - Identifies which files to look for strings that can be translated
141+
2. ./bin/translate.sh - Script to collect/update all translatable strings
142+
3. pybabel compile - Should be run after updating any messages.po files. The full command is given in the output of translate.sh script
143+
4. There are two languages(English and Welsh) available on the site. No translation is provided for English as that is the default language
144+
145+
### How to translate text in template
146+
1. Wrap text in `{% trans %}...{% endtrans %}`
147+
2. Run `./bin/translate.sh`
148+
3. Update `app/translations/cy/LC_MESSAGES/messages.po` with welsh text
149+
4. Run `pybabel compile -d app/translations -l cy -f`
150+
151+
### How to translate text in python
152+
```
153+
from flask_babel import lazy_gettext as _
154+
_("text to translate")
155+
```
156+
2. Run `./bin/translate.sh`
157+
3. Update `app/translations/cy/LC_MESSAGES/messages.po` with welsh text
158+
4. Run `pybabel compile -d app/translations -l
137159

138160
[Standards Link]: https://operations-engineering-reports.cloud-platform.service.justice.gov.uk/public-report/laa-access-civil-legal-aid "Repo standards badge."
139161
[Standards Icon]: https://img.shields.io/endpoint?labelColor=231f20&color=005ea5&style=for-the-badge&label=MoJ%20Compliant&url=https%3A%2F%2Foperations-engineering-reports.cloud-platform.service.justice.gov.uk%2Fapi%2Fv1%2Fcompliant_public_repositories%2Fendpoint%2Flaa-access-civil-legal-aid&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAABmJLR0QA/wD/AP+gvaeTAAAHJElEQVRYhe2YeYyW1RWHnzuMCzCIglBQlhSV2gICKlHiUhVBEAsxGqmVxCUUIV1i61YxadEoal1SWttUaKJNWrQUsRRc6tLGNlCXWGyoUkCJ4uCCSCOiwlTm6R/nfPjyMeDY8lfjSSZz3/fee87vnnPu75z3g8/kM2mfqMPVH6mf35t6G/ZgcJ/836Gdug4FjgO67UFn70+FDmjcw9xZaiegWX29lLLmE3QV4Glg8x7WbFfHlFIebS/ANj2oDgX+CXwA9AMubmPNvuqX1SnqKGAT0BFoVE9UL1RH7nSCUjYAL6rntBdg2Q3AgcAo4HDgXeBAoC+wrZQyWS3AWcDSUsomtSswEtgXaAGWlVI2q32BI0spj9XpPww4EVic88vaC7iq5Hz1BvVf6v3qe+rb6ji1p3pWrmtQG9VD1Jn5br+Knmm70T9MfUh9JaPQZu7uLsR9gEsJb3QF9gOagO7AuUTom1LpCcAkoCcwQj0VmJregzaipA4GphNe7w/MBearB7QLYCmlGdiWSm4CfplTHwBDgPHAFmB+Ah8N9AE6EGkxHLhaHU2kRhXc+cByYCqROs05NQq4oR7Lnm5xE9AL+GYC2gZ0Jmjk8VLKO+pE4HvAyYRnOwOH5N7NhMd/WKf3beApYBWwAdgHuCLn+tatbRtgJv1awhtd838LEeq30/A7wN+AwcBt+bwpD9AdOAkYVkpZXtVdSnlc7QI8BlwOXFmZ3oXkdxfidwmPrQXeA+4GuuT08QSdALxC3OYNhBe/TtzON4EziZBXD36o+q082BxgQuqvyYL6wtBY2TyEyJ2DgAXAzcC1+Xxw3RlGqiuJ6vE6QS9VGZ/7H02DDwAvELTyMDAxbfQBvggMAAYR9LR9J2cluH7AmnzuBowFFhLJ/wi7yiJgGXBLPq8A7idy9kPgvAQPcC9wERHSVcDtCfYj4E7gr8BRqWMjcXmeB+4tpbyG2kG9Sl2tPqF2Uick8B+7szyfvDhR3Z7vvq/2yqpynnqNeoY6v7LvevUU9QN1fZ3OTeppWZmeyzRoVu+rhbaHOledmoQ7LRd3SzBVeUo9Wf1DPs9X90/jX8m/e9Rn1Mnqi7nuXXW5+rK6oU7n64mjszovxyvVh9WeDcTVnl5KmQNcCMwvpbQA1xE8VZXhwDXAz4FWIkfnAlcBAwl6+SjD2wTcmPtagZnAEuA3dTp7qyNKKe8DW9UeBCeuBsbsWKVOUPvn+MRKCLeq16lXqLPVFvXb6r25dlaGdUx6cITaJ8fnpo5WI4Wuzcjcqn5Y8eI/1F+n3XvUA1N3v4ZamIEtpZRX1Y6Z/DUK2g84GrgHuDqTehpBCYend94jbnJ34DDgNGArQT9bict3Y3p1ZCnlSoLQb0sbgwjCXpY2blc7llLW1UAMI3o5CD4bmuOlwHaC6xakgZ4Z+ibgSxnOgcAI4uavI27jEII7909dL5VSrimlPKgeQ6TJCZVQjwaOLaW8BfyWbPEa1SaiTH1VfSENd85NDxHt1plA71LKRvX4BDaAKFlTgLeALtliDUqPrSV6SQCBlypgFlbmIIrCDcAl6nPAawmYhlLKFuB6IrkXAadUNj6TXlhDcCNEB/Jn4FcE0f4UWEl0NyWNvZxGTs89z6ZnatIIrCdqcCtRJmcCPwCeSN3N1Iu6T4VaFhm9n+riypouBnepLsk9p6p35fzwvDSX5eVQvaDOzjnqzTl+1KC53+XzLINHd65O6lD1DnWbepPBhQ3q2jQyW+2oDkkAtdt5udpb7W+Q/OFGA7ol1zxu1tc8zNHqXercfDfQIOZm9fR815Cpt5PnVqsr1F51wI9QnzU63xZ1o/rdPPmt6enV6sXqHPVqdXOCe1rtrg5W7zNI+m712Ir+cer4POiqfHeJSVe1Raemwnm7xD3mD1E/Z3wIjcsTdlZnqO8bFeNB9c30zgVG2euYa69QJ+9G90lG+99bfdIoo5PU4w362xHePxl1slMab6tV72KUxDvzlAMT8G0ZohXq39VX1bNzzxij9K1Qb9lhdGe931B/kR6/zCwY9YvuytCsMlj+gbr5SemhqkyuzE8xau4MP865JvWNuj0b1YuqDkgvH2GkURfakly01Cg7Cw0+qyXxkjojq9Lw+vT2AUY+DlF/otYq1Ixc35re2V7R8aTRg2KUv7+ou3x/14PsUBn3NG51S0XpG0Z9PcOPKWSS0SKNUo9Rv2Mmt/G5WpPF6pHGra7Jv410OVsdaz217AbkAPX3ubkm240belCuudT4Rp5p/DyC2lf9mfq1iq5eFe8/lu+K0YrVp0uret4nAkwlB6vzjI/1PxrlrTp/oNHbzTJI92T1qAT+BfW49MhMg6JUp7ehY5a6Tl2jjmVvitF9fxo5Yq8CaAfAkzLMnySt6uz/1k6bPx59CpCNxGfoSKA30IPoH7cQXdArwCOllFX/i53P5P9a/gNkKpsCMFRuFAAAAABJRU5ErkJggg==

app/__init__.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from flask import Flask
2+
from flask_babel import Babel
23
from flask_assets import Bundle, Environment
34
from flask_compress import Compress
45
from flask_limiter import Limiter
@@ -7,11 +8,13 @@
78
from flask_wtf.csrf import CSRFProtect
89
from govuk_frontend_wtf.main import WTFormsHelpers
910
from jinja2 import ChoiceLoader, PackageLoader, PrefixLoader
10-
from .main.gtm import get_gtm_anon_id
11+
from app.main.gtm import get_gtm_anon_id
12+
from app.main import get_locale
1113
import sentry_sdk
1214

1315
from app.config import Config
1416

17+
1518
compress = Compress()
1619
csrf = CSRFProtect()
1720
limiter = Limiter(get_remote_address, default_limits=["2 per second", "60 per minute"])
@@ -142,6 +145,8 @@ def create_app(config_class=Config):
142145
if "headscripts" not in assets:
143146
assets.register("headscripts", headscripts)
144147

148+
Babel(app, locale_selector=get_locale)
149+
145150
# Register blueprints
146151
from app.main import bp as main_bp
147152

app/config/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,5 @@ class Config(object):
1818
SERVICE_PHASE = os.environ.get("SERVICE_PHASE", "Beta")
1919
SERVICE_URL = os.environ.get("SERVICE_URL", "")
2020
SESSION_COOKIE_HTTPONLY = True
21-
# SESSION_COOKIE_SECURE = True
2221
SENTRY_DSN = os.environ.get("SENTRY_DSN")
22+
LANGUAGES = {"en": "English", "cy": "Welsh"}

app/main/__init__.py

+27-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,32 @@
1-
from flask import Blueprint
1+
from flask import Blueprint, request, url_for
2+
from flask import current_app
23

34
bp = Blueprint("main", __name__, template_folder="../templates/main")
45

56
from app.main import routes # noqa: E402,F401
67
from app.main import filters # noqa: E402,F401
8+
9+
10+
def get_locale():
11+
locale = request.cookies.get("locale")
12+
if locale in current_app.config["LANGUAGES"]:
13+
return locale
14+
15+
language_keys = current_app.config["LANGUAGES"].keys()
16+
return request.accept_languages.best_match(language_keys) or "en"
17+
18+
19+
@bp.context_processor
20+
def inject_language_switcher():
21+
locale = get_locale()
22+
code = "cy" if locale == "en" else "en"
23+
text = "Cymraeg" if locale == "en" else "English"
24+
return {
25+
"language": {
26+
"current": locale,
27+
"switch": {
28+
"href": url_for("main.set_locale", locale=code),
29+
"text": text,
30+
},
31+
}
32+
}

app/main/routes.py

+36-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1-
from flask import flash, json, make_response, redirect, render_template, request
1+
import datetime
2+
from urllib.parse import urlparse
3+
from flask import (
4+
flash,
5+
json,
6+
make_response,
7+
redirect,
8+
render_template,
9+
request,
10+
current_app,
11+
abort,
12+
)
213
from flask_wtf.csrf import CSRFError
314
from werkzeug.exceptions import HTTPException
415

@@ -11,6 +22,30 @@ def index():
1122
return render_template("index.html")
1223

1324

25+
@bp.route("/locale/<locale>")
26+
def set_locale(locale):
27+
"""
28+
Set locale cookie
29+
"""
30+
if locale not in current_app.config["LANGUAGES"]:
31+
abort(404)
32+
33+
if request.referrer:
34+
parse = urlparse(request.referrer)
35+
redirect_url = ["/", parse.path.strip("/")]
36+
if parse.query:
37+
redirect_url.append("?" + parse.query)
38+
else:
39+
redirect_url = ["/"]
40+
41+
response = redirect("".join(redirect_url))
42+
expires = datetime.datetime.now() + datetime.timedelta(days=30)
43+
response.set_cookie(
44+
"locale", locale, expires=expires, secure=(not current_app.debug), httponly=True
45+
)
46+
return response
47+
48+
1449
@bp.route("/accessibility", methods=["GET"])
1550
def accessibility():
1651
return render_template("accessibility.html")

app/templates/base.html

+6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
{% set htmlLang = language.current %}
12
{% extends 'govuk_frontend_jinja/template.html' %}
23

34
{%- from 'govuk_frontend_jinja/components/cookie-banner/macro.html' import govukCookieBanner-%}
@@ -147,6 +148,11 @@
147148
{{ govukFooter({
148149
'meta': {
149150
'items': [
151+
{
152+
'href': language.switch.href,
153+
'text': language.switch.text,
154+
'attributes': {'x-data': 'language-switcher'},
155+
},
150156
{
151157
'href': url_for('main.accessibility'),
152158
'text': "Accessibility"

app/templates/main/privacy.html

+2-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
<div class="govuk-grid-row">
1717
<div class="govuk-grid-column-two-thirds">
1818
{{ super() }}
19-
<h1 class="govuk-heading-xl">Privacy notice</h1>
19+
<h1 class="govuk-heading-xl">{% trans %}Privacy notice{% endtrans %}</h1>
20+
<h2 class="govuk-heading-xl">{% trans %}Start of privacy notice{% endtrans %}</h2>
2021
</div>
2122
</div>
2223
{% endblock %}
606 Bytes
Binary file not shown.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#: app/templates/main/privacy.html:19
2+
msgid "Privacy notice"
3+
msgstr "Welsh Privacy notice"
4+
5+
#: app/templates/main/privacy.html:20
6+
msgid "Start of privacy notice"
7+
msgstr "WELSH Start of privacy notice"
8+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Translations template for PROJECT.
2+
# Copyright (C) 2024 ORGANIZATION
3+
# This file is distributed under the same license as the PROJECT project.
4+
# FIRST AUTHOR <EMAIL@ADDRESS>, 2024.
5+
#
6+
#, fuzzy
7+
msgid ""
8+
msgstr ""
9+
"Project-Id-Version: PROJECT VERSION\n"
10+
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
11+
"POT-Creation-Date: 2024-09-23 21:52+0100\n"
12+
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13+
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
14+
"Language-Team: LANGUAGE <LL@li.org>\n"
15+
"MIME-Version: 1.0\n"
16+
"Content-Type: text/plain; charset=utf-8\n"
17+
"Content-Transfer-Encoding: 8bit\n"
18+
"Generated-By: Babel 2.16.0\n"
19+
20+
#: app/templates/main/privacy.html:19
21+
msgid "Privacy notice"
22+
msgstr ""
23+
24+
#: app/templates/main/privacy.html:20
25+
msgid "Start of privacy notice"
26+
msgstr ""
27+

babel.cfg

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[python: app/**.py]
2+
[jinja2: app/templates/**.html]

bin/playright.sh

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#!/usr/bin/env bash
2+
export SECRET_KEY=thisisatest
3+
playwright install
4+
pytest tests/functional_tests

bin/translate.sh

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#!/usr/bin/env bash
2+
TRANSLATIONS_DIR='app/translations'
3+
CATALOGUE=$TRANSLATIONS_DIR/en/LC_MESSAGES/messages.pot
4+
TARGET_LANGUAGE=cy
5+
TARGET_MESSAGES=$TRANSLATIONS_DIR/$TARGET_LANGUAGE/LC_MESSAGES/messages.po
6+
pybabel extract -F babel.cfg -k lazy_gettext -o $CATALOGUE .
7+
if [ ! -f $TARGET_MESSAGES ]; then
8+
pybabel init -i $CATALOGUE -d $TRANSLATIONS_DIR -l $TARGET_LANGUAGE
9+
fi
10+
pybabel update -i $CATALOGUE -d $TRANSLATIONS_DIR -l $TARGET_LANGUAGE --no-fuzzy-matching --omit-header
11+
pybabel compile -d $TRANSLATIONS_DIR -l $TARGET_LANGUAGE -f
12+
13+
printf "\n-----------------------IMPORTANT-----------------------\n"
14+
echo "After updating $TARGET_MESSAGES. Run the following:"
15+
echo "pybabel compile -d $TRANSLATIONS_DIR -l $TARGET_LANGUAGE -f"
16+
printf "\n--------------------------------------------------------\n"

compose.yml

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ services:
55
context: .
66
args:
77
BASE_IMAGE: python:3.12-bookworm
8+
REQUIREMENTS_FILE: "requirements-development.txt"
89
target: development
910
restart: always
1011
environment:

messages.pot

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Translations template for PROJECT.
2+
# Copyright (C) 2024 ORGANIZATION
3+
# This file is distributed under the same license as the PROJECT project.
4+
# FIRST AUTHOR <EMAIL@ADDRESS>, 2024.
5+
#
6+
#, fuzzy
7+
msgid ""
8+
msgstr ""
9+
"Project-Id-Version: PROJECT VERSION\n"
10+
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
11+
"POT-Creation-Date: 2024-09-17 09:49+0100\n"
12+
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13+
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
14+
"Language-Team: LANGUAGE <LL@li.org>\n"
15+
"MIME-Version: 1.0\n"
16+
"Content-Type: text/plain; charset=utf-8\n"
17+
"Content-Transfer-Encoding: 8bit\n"
18+
"Generated-By: Babel 2.16.0\n"
19+

requirements/generated/requirements-development.txt

+8
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
#
77
axe-core-python==0.1.0
88
# via -r requirements/source/requirements-development.in
9+
babel==2.16.0
10+
# via flask-babel
911
blinker==1.8.2
1012
# via
1113
# flask
@@ -30,6 +32,7 @@ flask==3.0.3
3032
# via
3133
# -r requirements/source/requirements-base.in
3234
# flask-assets
35+
# flask-babel
3336
# flask-compress
3437
# flask-limiter
3538
# flask-wtf
@@ -38,6 +41,8 @@ flask==3.0.3
3841
# sentry-sdk
3942
flask-assets==2.1.0
4043
# via -r requirements/source/requirements-base.in
44+
flask-babel==4.0.0
45+
# via -r requirements/source/requirements-base.in
4146
flask-compress==1.15
4247
# via -r requirements/source/requirements-base.in
4348
flask-limiter==3.8.0
@@ -69,6 +74,7 @@ itsdangerous==2.2.0
6974
jinja2==3.1.4
7075
# via
7176
# flask
77+
# flask-babel
7278
# govuk-frontend-jinja
7379
# govuk-frontend-wtf
7480
jsmin==3.0.1
@@ -116,6 +122,8 @@ python-dotenv==1.0.1
116122
# via -r requirements/source/requirements-base.in
117123
python-slugify==8.0.4
118124
# via pytest-playwright
125+
pytz==2024.2
126+
# via flask-babel
119127
pyyaml==6.0.1
120128
# via -r requirements/source/requirements-base.in
121129
requests==2.32.3

0 commit comments

Comments
 (0)