From f0d99c59dc13348efbddfe404b0e95ac7351d74f Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 23 Dec 2024 12:15:52 +0000 Subject: [PATCH] Revert "Remove locale selector/translation support" This reverts commit ea29db12f8f618b365bc5c7ec57190658488bba6. --- CHANGELOG.md | 1 + README.md | 51 +++++++++++++++++++++++++++ fsd_utils/__init__.py | 2 ++ fsd_utils/locale_selector/__init__.py | 2 ++ fsd_utils/locale_selector/get_lang.py | 29 +++++++++++++++ fsd_utils/locale_selector/set_lang.py | 33 +++++++++++++++++ tests/test_get_lang.py | 33 +++++++++++++++++ tests/test_set_lang.py | 14 ++++++++ 8 files changed, 165 insertions(+) create mode 100644 fsd_utils/locale_selector/__init__.py create mode 100644 fsd_utils/locale_selector/get_lang.py create mode 100644 fsd_utils/locale_selector/set_lang.py create mode 100644 tests/test_get_lang.py create mode 100644 tests/test_set_lang.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 12ec576f..34bf9f77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index b1aa81ea..9459fe12 100644 --- a/README.md +++ b/README.md @@ -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/` 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. diff --git a/fsd_utils/__init__.py b/fsd_utils/__init__.py index bfc2078a..ba847718 100644 --- a/fsd_utils/__init__.py +++ b/fsd_utils/__init__.py @@ -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, @@ -18,6 +19,7 @@ CommonConfig, NotifyConstants, healthchecks, + LanguageSelector, init_sentry, clear_sentry, date_utils, diff --git a/fsd_utils/locale_selector/__init__.py b/fsd_utils/locale_selector/__init__.py new file mode 100644 index 00000000..8d3d6b1b --- /dev/null +++ b/fsd_utils/locale_selector/__init__.py @@ -0,0 +1,2 @@ +from . import get_lang # noqa +from . import set_lang # noqa diff --git a/fsd_utils/locale_selector/get_lang.py b/fsd_utils/locale_selector/get_lang.py new file mode 100644 index 00000000..e6767f50 --- /dev/null +++ b/fsd_utils/locale_selector/get_lang.py @@ -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" diff --git a/fsd_utils/locale_selector/set_lang.py b/fsd_utils/locale_selector/set_lang.py new file mode 100644 index 00000000..93bb8dda --- /dev/null +++ b/fsd_utils/locale_selector/set_lang.py @@ -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 + ) + + def __init__(self, app): + self.flask_app = app + self.flask_app.add_url_rule("/language/", view_func=self.select_language) + + @staticmethod + def select_language(locale): + # TODO: Perform additional validation on referrer + response = make_response(redirect(request.referrer or "/", 302)) + LanguageSelector.set_language_cookie(locale, response) + + return response diff --git a/tests/test_get_lang.py b/tests/test_get_lang.py new file mode 100644 index 00000000..76f82f05 --- /dev/null +++ b/tests/test_get_lang.py @@ -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" diff --git a/tests/test_set_lang.py b/tests/test_set_lang.py new file mode 100644 index 00000000..b9630216 --- /dev/null +++ b/tests/test_set_lang.py @@ -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/", 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")