Skip to content

Commit

Permalink
Revert "Remove locale selector/translation support"
Browse files Browse the repository at this point in the history
This reverts commit ea29db1.
  • Loading branch information
samuelhwilliams committed Dec 23, 2024
1 parent e51c2d9 commit f0d99c5
Show file tree
Hide file tree
Showing 8 changed files with 165 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
### 6.0.1

* Revert: "Fix a DeprecationWarning from pythonjsonlogger" which actually broke things
* Revert: "Removes `locale_selector` module, which has now been lifted directly into `pre-award-frontend` (and modified to support `host_matching` mode)." because this is still used by pre-award-stores

### 6.0.0

Expand Down
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,57 @@ Where
- `bool` is a True or False whether the check was successful
- `str` is a message to display in the result JSON, typically `OK` or `Fail`

## Translations
Multiple language support is provided by `flask-babel`. Docs here: https://python-babel.github.io/flask-babel/#

### Python Setup
The python setup relies on 2 methods and an initialisation. In `create_app` we initialise `babel` on our app:

from fsd_utils.locale_selector.get_lang import get_lang

babel = Babel(flask_app)
babel.locale_selector_func = get_lang
LanguageSelector(flask_app)

The `get_lang()` function reads the user-selected language from a cookie (if set by `LanguageSelector` - see below), or if that is not present uses a built-in function of babel to negotiate the language based on the request headers and the supported languages.

`LanguageSelector` creates an additional route `/language/<language>` that sets the user's selected language in a cookie. Used the cookie rather than the session so it can be shared across the microservices.

Set `COOKIE_DOMAIN` on the app to the domain you want to set the cookie on.

### Creating Translations
1. Add `trans` tags around items in your jinja html file that you want to translate. What's contained in the `trans` tag should be the english version of this text. eg:

A greeting is: {% trans %}Good Morning{% endtrans %}

1. Generate a new translations template file (`messages.pot`)

pybabel extract -F babel.cfg -o messages.pot .

1. If initialising a new set of translations for a new repo or adding a new language, use `init`. *This will override any changes already made in the `translations` directory!*

# Initialise a welsh translations file in app/translations
pybabel init -i messages.pot -d app/translations -l cy
The directory supplied to `-d` (eg. `app/translations`) must sit at the same level as the `templates` folder, in our case within the `app` directory. This command generates a new `messages.po` file in `translations/cy/LC_MESSAGES/` Where `cy` is the language code. (cy = Welsh/Cymraeg)

1. If you've added new strings to an existing template, or added new templates after running `init`, use `update`:

pybabel update -i messages.pot -d app/translations

This will append new strings to the existing `messages.po` file, preserving any translations you already have in there.

1. To add translations for strings, edit the `messages.po` file.

#: app/templates/index.html:10
msgid "Good Morning"
msgstr "Bore da"

where `msgid` is the english version of the string from the original template file, and `msgstr` is the translation of the string.

1. Once the translations are ready, use `compile` to generate the binary for use at runtime:

pybabel compile -d app/translations

## Sentry
Enables Sentry integration.

Expand Down
2 changes: 2 additions & 0 deletions fsd_utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from fsd_utils.config.configclass import configclass # noqa
from fsd_utils.config.notify_constants import NotifyConstants # noqa
from fsd_utils.decision.evaluate_response_against_schema import Decision, evaluate_response
from fsd_utils.locale_selector.set_lang import LanguageSelector
from fsd_utils.mapping.application.application_utils import generate_text_of_application
from fsd_utils.mapping.application.qa_mapping import (
extract_questions_and_answers,
Expand All @@ -18,6 +19,7 @@
CommonConfig,
NotifyConstants,
healthchecks,
LanguageSelector,
init_sentry,
clear_sentry,
date_utils,
Expand Down
2 changes: 2 additions & 0 deletions fsd_utils/locale_selector/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import get_lang # noqa
from . import set_lang # noqa
29 changes: 29 additions & 0 deletions fsd_utils/locale_selector/get_lang.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from babel import negotiate_locale
from flask import request

from fsd_utils import CommonConfig


def get_lang():
# get lang if lang query arg is set
language_from_query_args = request.args.get("lang")
if language_from_query_args:
if language_from_query_args not in ["cy", "en"]:
return "en"
return language_from_query_args

# get locale from cookie if set
locale_from_cookie = request.cookies.get(CommonConfig.FSD_LANG_COOKIE_NAME)
if locale_from_cookie:
if locale_from_cookie not in ["cy", "en"]:
return "en"
return locale_from_cookie

# otherwise guess preference based on user accept header
preferred = [accept_language.replace("-", "_") for accept_language in request.accept_languages.values()]
negotiated_locale = negotiate_locale(preferred, ["en", "cy"])
if negotiated_locale:
return negotiated_locale

# default is to return english
return "en"
33 changes: 33 additions & 0 deletions fsd_utils/locale_selector/set_lang.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from flask import Response, current_app, make_response, redirect, request

from fsd_utils import CommonConfig


class LanguageSelector:
@staticmethod
def get_cookie_domain(cookie_domain):
if not cookie_domain:
return None
else:
return cookie_domain

@staticmethod
def set_language_cookie(locale: str, response: Response):
response.set_cookie(
CommonConfig.FSD_LANG_COOKIE_NAME,
locale,
domain=LanguageSelector.get_cookie_domain(current_app.config["COOKIE_DOMAIN"]),
max_age=86400 * 30, # 30 days
)

Check warning

Code scanning / CodeQL

Failure to use secure cookies Medium

Cookie is added without the Secure and HttpOnly attributes properly set.

def __init__(self, app):
self.flask_app = app
self.flask_app.add_url_rule("/language/<locale>", view_func=self.select_language)

@staticmethod
def select_language(locale):
# TODO: Perform additional validation on referrer
response = make_response(redirect(request.referrer or "/", 302))

Check warning

Code scanning / CodeQL

URL redirection from remote source Medium

Untrusted URL redirection depends on a
user-provided value
.
LanguageSelector.set_language_cookie(locale, response)

return response
33 changes: 33 additions & 0 deletions tests/test_get_lang.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from fsd_utils.locale_selector.get_lang import get_lang


class TestGetLang:
def test_get_lang_query_arg_valid(self, flask_test_client):
with flask_test_client.application.test_request_context("/?lang=cy"):
assert get_lang() == "cy"

def test_get_lang_query_arg_invalid(self, flask_test_client):
with flask_test_client.application.test_request_context("/?lang=fr"):
assert get_lang() == "en"

def test_get_lang_cookie_preference(self, flask_test_client):
with flask_test_client.application.test_request_context("/", headers={"Cookie": "language=cy"}):
assert get_lang() == "cy"

def test_get_lang_cookie_preference_for_non_en_cy_language(self, flask_test_client):
with flask_test_client.application.test_request_context("/", headers={"Cookie": "language=de"}):
assert get_lang() == "en"

def test_get_lang_accept_language_preference_en(self, flask_test_client):
with flask_test_client.application.test_request_context(
"/",
headers={"Accept-Language": "en,en-GB;q=0.9,cy;q=0.8,en-US;q=0.7"},
):
assert get_lang() == "en"

def test_get_lang_accept_language_preference_cy(self, flask_test_client):
with flask_test_client.application.test_request_context(
"/",
headers={"Accept-Language": "cy,en;q=0.9,en-GB;q=0.8,en-US;q=0.7"}, # noqa: E501
):
assert get_lang() == "cy"
14 changes: 14 additions & 0 deletions tests/test_set_lang.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from unittest.mock import ANY, Mock

from fsd_utils.locale_selector.set_lang import LanguageSelector


def test_set_lang(flask_test_client):
mock_app = Mock()
set_lang = LanguageSelector(mock_app)
mock_app.add_url_rule.assert_called_with("/language/<locale>", view_func=ANY)
with flask_test_client.application.test_request_context():
response = set_lang.select_language("cy")
response_cookie = response.headers.get("Set-Cookie")
assert response_cookie is not None, "No cookie set for language"
assert response_cookie.split(";")[0] == ("language" + "=cy")

0 comments on commit f0d99c5

Please sign in to comment.