From 37feebcb61c5d38dc0aeef52d6000cfafd050442 Mon Sep 17 00:00:00 2001 From: Jan Max Meyer Date: Fri, 15 Nov 2024 20:20:14 +0100 Subject: [PATCH 1/3] feat: Public translations interface This interface was already implemented in some projects, and was now cleaned and standardised. It allows to set translate()-objects to public, so and unauthorized client is allowed to dump them without further authentication. The function /json/_translate/get_public was made available to dump translations. Example calls: - `/json/_translation/get_public` get public translations for current language - `/json/_translation/get_public?languages=en` for english translations - `/json/_translation/get_public?languages=en&languages=de` for english and german translations - `/json/_translation/get_public?languages=*` for all available languages --- src/viur/core/i18n.py | 47 +++++++++++++++++++++--- src/viur/core/modules/translation.py | 55 ++++++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/src/viur/core/i18n.py b/src/viur/core/i18n.py index 11adfac30..2b3b36ce8 100644 --- a/src/viur/core/i18n.py +++ b/src/viur/core/i18n.py @@ -150,9 +150,23 @@ class translate: translation issues with bones, which can now take an instance of this class as it's description/hints. """ - __slots__ = ["key", "defaultText", "hint", "translationCache", "force_lang"] - - def __init__(self, key: str, defaultText: str = None, hint: str = None, force_lang: str = None): + __slots__ = ( + "key", + "defaultText", + "hint", + "translationCache", + "force_lang", + "public", + ) + + def __init__( + self, + key: str, + defaultText: str = None, + hint: str = None, + force_lang: str = None, + public: bool = False, + ): """ :param key: The unique key defining this text fragment. Usually it's a path/filename and a unique descriptor in that file @@ -163,16 +177,20 @@ def __init__(self, key: str, defaultText: str = None, hint: str = None, force_la as the key/defaultText may have different meanings in the target language. :param force_lang: Use this language instead the one of the request. + :param public: """ super().__init__() - key = str(key) # ensure key is a str - self.key = key.lower() + + self.key = str(key).lower() self.defaultText = defaultText or key self.hint = hint + self.translationCache = None if force_lang is not None and force_lang not in conf.i18n.available_dialects: raise ValueError(f"The language {force_lang=} is not available") + self.force_lang = force_lang + self.public = public def __repr__(self) -> str: return f"" @@ -211,6 +229,7 @@ def __str__(self) -> str: default_text=self.defaultText, filename=filename, lineno=lineno, + public=self.public, ) self.translationCache = self.merge_alias(systemTranslations.get(self.key, {})) @@ -272,15 +291,19 @@ class TranslationExtension(jinja2.Extension): force the use of a specific language, not the language of the request. """ - tags = {"translate"} + tags = { + "translate", + } def parse(self, parser): # Parse the translate tag global systemTranslations + args = [] # positional args for the `_translate()` method kwargs = {} # keyword args (force_lang + substitute vars) for the `_translate()` method lineno = parser.stream.current.lineno filename = parser.stream.filename + # Parse arguments (args and kwargs) until the current block ends lastToken = None while parser.stream.current.type != 'block_end': @@ -301,14 +324,19 @@ def parse(self, parser): else: raise SyntaxError() lastToken = None + if lastToken: # TODO: what's this? what it is doing? logging.debug(f"final append {lastToken = }") args.append(lastToken.value) + if not 0 < len(args) <= 3: raise SyntaxError("Translation-Key missing or excess parameters!") + args += [""] * (3 - len(args)) args += [kwargs] tr_key = args[0].lower() + public = kwargs.pop("_public_", False) or False + if tr_key not in systemTranslations: add_missing_translation( key=tr_key, @@ -317,6 +345,7 @@ def parse(self, parser): filename=filename, lineno=lineno, variables=list(kwargs.keys()), + public=public, ) translations = translate.merge_alias(systemTranslations.get(tr_key, {})) @@ -369,7 +398,9 @@ def initializeTranslations() -> None: translations = { "_default_text_": entity.get("default_text") or None, + "_public_": entity.get("public") or False, } + for lang, translation in entity["translations"].items(): if lang not in conf.i18n.available_dialects: # Don't store unknown languages in the memory @@ -378,6 +409,7 @@ def initializeTranslations() -> None: # Skip empty values continue translations[lang] = translation + systemTranslations[entity["tr_key"]] = translations @@ -390,6 +422,7 @@ def add_missing_translation( filename: str | None = None, lineno: int | None = None, variables: list[str] = None, + public: bool = False, ) -> None: """Add missing translations to datastore""" try: @@ -426,11 +459,13 @@ def add_missing_translation( skel["usage_lineno"] = lineno skel["usage_variables"] = variables or [] skel["creator"] = Creator.VIUR + skel["public"] = public skel.toDB() # Add to system translation to avoid triggering this method again systemTranslations[key] = { "_default_text_": default_text or None, + "_public_": public, } diff --git a/src/viur/core/modules/translation.py b/src/viur/core/modules/translation.py index ab0b51944..bfff4831f 100644 --- a/src/viur/core/modules/translation.py +++ b/src/viur/core/modules/translation.py @@ -1,8 +1,10 @@ import enum +import json import logging +import os from datetime import timedelta as td - -from viur.core import conf, db, utils +from viur.core import conf, db, utils, current, errors +from viur.core.decorators import exposed from viur.core.bones import * from viur.core.i18n import KINDNAME, initializeTranslations, systemTranslations, translate from viur.core.prototypes.list import List @@ -108,6 +110,14 @@ class TranslationSkel(Skeleton): defaultValue=Creator.USER, ) + public = BooleanBone( + descr=translate( + "core.translationskel.public.descr", + "Is this translation public?", + ), + defaultValue=False, + ) + @classmethod def toDB(cls, skelValues: SkeletonInstance, **kwargs) -> db.Key: # Ensure we have only lowercase keys @@ -173,4 +183,43 @@ def _reload_translations(self): systemTranslations.clear() initializeTranslations() - _last_reload = None + _last_reload = None # Cut my strings into pieces, this is my last reload... + + @exposed + def get_public(self, *, languages: list[str] = None) -> dict[str, str] | dict[str, dict[str, str]]: + """ + Dumps public translations as JSON. + """ + if not utils.string.is_prefix(self.render.kind, "json"): + raise errors.BadRequest("Can only use this function on JSON-based renders") + + current.request.get().response.headers["Content-Type"] = "application/json" + + if ( + not (conf.debug.disable_cache and current.request.get().disableCache) + and any(os.getenv("HTTP_HOST", "") in x for x in conf.i18n.domain_language_mapping) + ): + # cache it 7 days + current.request.get().response.headers["Cache-Control"] = f"public, max-age={7 * 24 * 60 * 60}" + + if languages: + if len(languages) == 1 and languages[0] == "*": + languages = conf.i18n.available_dialects + + return json.dumps({ + lang: { + tr_key: str(translate(tr_key, force_lang=lang)) + for tr_key, values in systemTranslations.items() + if values.get("_public_") + } + for lang in languages + }) + + return json.dumps({ + tr_key: str(translate(tr_key)) + for tr_key, values in systemTranslations.items() + if values.get("_public_") + }) + + +Translation.json = True From a38b91527457d12b3a44bc0cc9dde0254ba2e3bb Mon Sep 17 00:00:00 2001 From: Jan Max Meyer Date: Fri, 15 Nov 2024 20:33:15 +0100 Subject: [PATCH 2/3] Extended docstrings --- src/viur/core/i18n.py | 2 +- src/viur/core/modules/translation.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/viur/core/i18n.py b/src/viur/core/i18n.py index 2b3b36ce8..d82aeb93c 100644 --- a/src/viur/core/i18n.py +++ b/src/viur/core/i18n.py @@ -177,7 +177,7 @@ def __init__( as the key/defaultText may have different meanings in the target language. :param force_lang: Use this language instead the one of the request. - :param public: + :param public: Flag for public translations, which can be obtained via /json/_translate/get_public. """ super().__init__() diff --git a/src/viur/core/modules/translation.py b/src/viur/core/modules/translation.py index bfff4831f..ddc5f3c15 100644 --- a/src/viur/core/modules/translation.py +++ b/src/viur/core/modules/translation.py @@ -189,6 +189,13 @@ def _reload_translations(self): def get_public(self, *, languages: list[str] = None) -> dict[str, str] | dict[str, dict[str, str]]: """ Dumps public translations as JSON. + + Example calls: + + - `/json/_translation/get_public` get public translations for current language + - `/json/_translation/get_public?languages=en` for english translations + - `/json/_translation/get_public?languages=en&languages=de` for english and german translations + - `/json/_translation/get_public?languages=*` for all available languages """ if not utils.string.is_prefix(self.render.kind, "json"): raise errors.BadRequest("Can only use this function on JSON-based renders") From bdf623b481ac07ba547ae923a4f2708c12b57a21 Mon Sep 17 00:00:00 2001 From: Jan Max Meyer Date: Mon, 18 Nov 2024 13:36:19 +0100 Subject: [PATCH 3/3] Add CHANGELOG entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b4307821..472c2f256 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ This file documents any relevant changes done to ViUR-core since version 3. ## [3.6.24] - feat: `SkelModule.structure()` with actions and with access control (#1321) +- feat: Public translations interface (#1323) ## [3.6.23]