From dd62d698dabb8df505aba988db388976fc408b88 Mon Sep 17 00:00:00 2001 From: Jan Max Meyer Date: Thu, 9 Jan 2025 01:03:54 +0100 Subject: [PATCH 1/6] fix: General fixing and clean-up on `translate()` This pull request summarizes several problems: 1. Let all ViUR-core related translations have a `viur.core`-tr_key-prefix 2. Make `tr_key` required 3. Provide a way for `conf.i18n.add_missing_translations` to only add or ingore a specific pattern (fnmatch) --- src/viur/core/bones/select.py | 23 +++++++-------- src/viur/core/i18n.py | 11 +++++++- src/viur/core/modules/moduleconf.py | 2 +- src/viur/core/modules/script.py | 2 +- src/viur/core/modules/translation.py | 42 +++++++++++++++------------- src/viur/core/modules/user.py | 42 ++++++++++++++-------------- 6 files changed, 68 insertions(+), 54 deletions(-) diff --git a/src/viur/core/bones/select.py b/src/viur/core/bones/select.py index 9ab063bba..0d3726571 100644 --- a/src/viur/core/bones/select.py +++ b/src/viur/core/bones/select.py @@ -92,17 +92,18 @@ def __getattribute__(self, item): assert isinstance(values, (dict, OrderedDict)) - prefix = self.translation_key_prefix - if callable(prefix): - prefix = prefix(self) - - values = { - key: label if isinstance(label, translate) else translate( - f"{prefix}{label}", str(label), - f"value {key} for {self.name}<{type(self).__name__}> in {self.skel_cls.__name__} in {self.skel_cls}" - ) - for key, label in values.items() - } + if self.languages: + prefix = self.translation_key_prefix + if callable(prefix): + prefix = prefix(self) + + values = { + key: label if isinstance(label, translate) else translate( + f"{prefix}{label}", str(label), + f"value {key} for {self.name}<{type(self).__name__}> in {self.skel_cls.__name__} in {self.skel_cls}" + ) + for key, label in values.items() + } return values diff --git a/src/viur/core/i18n.py b/src/viur/core/i18n.py index 70346f63d..31c8ecc51 100644 --- a/src/viur/core/i18n.py +++ b/src/viur/core/i18n.py @@ -205,7 +205,12 @@ def __str__(self) -> str: from viur.core.render.html.env.viur import translate as jinja_translate - if self.key not in systemTranslations and conf.i18n.add_missing_translations: + if ( + not self.key.startswith("core.") + and not self.key.startswith("viur.") + and self.key not in systemTranslations + and conf.i18n.add_missing_translations + ): # This translation seems to be new and should be added filename = lineno = None is_jinja = False @@ -428,6 +433,10 @@ def add_missing_translation( public: bool = False, ) -> None: """Add missing translations to datastore""" + + logging.error(f"{key=} {hint=} {default_text=} {filename=} {lineno=} {variables=} {public=}") + raise RuntimeError("ICH WILL DAS NICHT") + try: from viur.core.modules.translation import TranslationSkel, Creator except ImportError as exc: diff --git a/src/viur/core/modules/moduleconf.py b/src/viur/core/modules/moduleconf.py index 02148edc2..deb018623 100644 --- a/src/viur/core/modules/moduleconf.py +++ b/src/viur/core/modules/moduleconf.py @@ -49,7 +49,7 @@ class ModuleConfScriptSkel(skeleton.RelSkel): access = SelectBone( descr="Required access rights", values=lambda: { - right: i18n.translate(f"viur.modules.user.accessright.{right}", defaultText=right) + right: i18n.translate(f"viur.core.modules.user.accessright.{right}", defaultText=right) for right in sorted(conf.user.access_rights) }, multiple=True, diff --git a/src/viur/core/modules/script.py b/src/viur/core/modules/script.py index 9634e3d59..fd5c249a2 100644 --- a/src/viur/core/modules/script.py +++ b/src/viur/core/modules/script.py @@ -74,7 +74,7 @@ class ScriptLeafSkel(BaseScriptAbstractSkel): access = SelectBone( descr="Required access rights to run this Script", values=lambda: { - right: translate(f"viur.modules.user.accessright.{right}", defaultText=right) + right: translate(f"viur.core.modules.user.accessright.{right}", defaultText=right) for right in sorted(conf.user.access_rights) }, multiple=True, diff --git a/src/viur/core/modules/translation.py b/src/viur/core/modules/translation.py index 8c605e0d7..7257a3072 100644 --- a/src/viur/core/modules/translation.py +++ b/src/viur/core/modules/translation.py @@ -21,25 +21,29 @@ class TranslationSkel(Skeleton): kindName = KINDNAME tr_key = StringBone( + required=True, descr=translate( - "core.translationskel.tr_key.descr", + "viur.core.translationskel.tr_key.descr", "Translation key", ), searchable=True, - unique=UniqueValue(UniqueLockMethod.SameValue, False, - "This translation key exist already"), + unique=UniqueValue( + UniqueLockMethod.SameValue, + False, + "This translation key exist already" + ), ) translations = StringBone( descr=translate( - "core.translationskel.translations.descr", + "viur.core.translationskel.translations.descr", "Translations", ), searchable=True, languages=conf.i18n.available_dialects, params={ "tooltip": translate( - "core.translationskel.translations.tooltip", + "viur.core.translationskel.translations.tooltip", "The languages {{main}} are required,\n {{accent}} can be filled out" )(main=", ".join(conf.i18n.available_languages), accent=", ".join(conf.i18n.language_alias_map.keys())), @@ -48,7 +52,7 @@ class TranslationSkel(Skeleton): translations_missing = SelectBone( descr=translate( - "core.translationskel.translations_missing.descr", + "viur.core.translationskel.translations_missing.descr", "Translation missing for language", ), multiple=True, @@ -64,21 +68,21 @@ class TranslationSkel(Skeleton): default_text = StringBone( descr=translate( - "core.translationskel.default_text.descr", + "viur.core.translationskel.default_text.descr", "Fallback value", ), ) hint = StringBone( descr=translate( - "core.translationskel.hint.descr", + "viur.core.translationskel.hint.descr", "Hint / Context (internal only)", ), ) usage_filename = StringBone( descr=translate( - "core.translationskel.usage_filename.descr", + "viur.core.translationskel.usage_filename.descr", "Used and added from this file", ), readOnly=True, @@ -86,7 +90,7 @@ class TranslationSkel(Skeleton): usage_lineno = NumericBone( descr=translate( - "core.translationskel.usage_lineno.descr", + "viur.core.translationskel.usage_lineno.descr", "Used and added from this lineno", ), readOnly=True, @@ -94,7 +98,7 @@ class TranslationSkel(Skeleton): usage_variables = StringBone( descr=translate( - "core.translationskel.usage_variables.descr", + "viur.core.translationskel.usage_variables.descr", "Receives these substitution variables", ), readOnly=True, @@ -103,7 +107,7 @@ class TranslationSkel(Skeleton): creator = SelectBone( descr=translate( - "core.translationskel.creator.descr", + "viur.core.translationskel.creator.descr", "Creator", ), readOnly=True, @@ -113,24 +117,24 @@ class TranslationSkel(Skeleton): public = BooleanBone( descr=translate( - "core.translationskel.public.descr", + "viur.core.translationskel.public.descr", "Is this translation public?", ), defaultValue=False, ) @classmethod - def write(cls, skelValues: SkeletonInstance, **kwargs) -> db.Key: + def write(cls, skel: SkeletonInstance, **kwargs) -> db.Key: # Ensure we have only lowercase keys - skelValues["tr_key"] = skelValues["tr_key"].lower() - return super().write(skelValues, **kwargs) + skel["tr_key"] = skel["tr_key"].lower() + return super().write(skel, **kwargs) @classmethod - def preProcessSerializedData(cls, skelValues: SkeletonInstance, entity: db.Entity) -> db.Entity: + def preProcessSerializedData(cls, skel: SkeletonInstance, entity: db.Entity) -> db.Entity: # Backward-compatibility: re-add the key for viur-core < v3.6 # TODO: Remove in ViUR4 - entity["key"] = skelValues["tr_key"] - return super().preProcessSerializedData(skelValues, entity) + entity["key"] = skel["tr_key"] + return super().preProcessSerializedData(skel, entity) class Translation(List): diff --git a/src/viur/core/modules/user.py b/src/viur/core/modules/user.py index 9d5b62307..46e475c84 100644 --- a/src/viur/core/modules/user.py +++ b/src/viur/core/modules/user.py @@ -77,7 +77,7 @@ class UserSkel(skeleton.Skeleton): ) roles = SelectBone( - descr=i18n.translate("viur.user.bone.roles", defaultText="Roles"), + descr=i18n.translate("viur.core.modules.user.bone.roles", defaultText="Roles"), values=conf.user.roles, required=True, multiple=True, @@ -91,10 +91,10 @@ class UserSkel(skeleton.Skeleton): ) access = SelectBone( - descr=i18n.translate("viur.user.bone.access", defaultText="Access rights"), + descr=i18n.translate("viur.core.modules.user.bone.access", defaultText="Access rights"), type_suffix="access", values=lambda: { - right: i18n.translate(f"viur.modules.user.accessright.{right}", defaultText=right) + right: i18n.translate(f"viur.core.modules.user.accessright.{right}", defaultText=right) for right in sorted(conf.user.access_rights) }, multiple=True, @@ -282,7 +282,7 @@ class LostPasswordStep2Skel(skeleton.RelSkel): required=True, params={ "tooltip": i18n.translate( - key="viur.modules.user.userpassword.lostpasswordstep2.recoverykey", + key="viur.core.modules.user.userpassword.lostpasswordstep2.recoverykey", defaultText="Please enter the validation key you've received via e-mail.", hint="Shown when the user needs more than 15 minutes to paste the key", ), @@ -302,7 +302,7 @@ class LostPasswordStep3Skel(skeleton.RelSkel): required=True, params={ "tooltip": i18n.translate( - key="viur.modules.user.userpassword.lostpasswordstep3.password", + key="viur.core.modules.user.userpassword.lostpasswordstep3.password", defaultText="Please enter a new password for your account.", ), } @@ -418,7 +418,7 @@ def pwrecover(self, recovery_key: str | None = None, skey: str | None = None, *a if not (recovery_request := securitykey.validate(recovery_key, session_bound=False)): raise errors.PreconditionFailed( i18n.translate( - key="viur.modules.user.passwordrecovery.keyexpired", + key="viur.core.modules.user.passwordrecovery.keyexpired", defaultText="The recovery key is expired or invalid. Please start the recovery process again.", hint="Shown when the user needs more than 15 minutes to paste the key, or entered an invalid key." ) @@ -432,7 +432,7 @@ def pwrecover(self, recovery_key: str | None = None, skey: str | None = None, *a if not user_skel: raise errors.NotFound( i18n.translate( - key="viur.modules.user.passwordrecovery.usernotfound", + key="viur.core.modules.user.passwordrecovery.usernotfound", defaultText="There is no account with this name", hint="We cant find an account with that name (Should never happen)" ) @@ -442,7 +442,7 @@ def pwrecover(self, recovery_key: str | None = None, skey: str | None = None, *a if not self._user_module.is_active(user_skel): raise errors.NotFound( i18n.translate( - key="viur.modules.user.passwordrecovery.accountlocked", + key="viur.core.modules.user.passwordrecovery.accountlocked", defaultText="This account is currently locked. You cannot change its password.", hint="Attempted password recovery on a locked account" ) @@ -799,7 +799,7 @@ def start(self): return self._user_module.render.edit( self.OtpSkel(), params={ - "name": i18n.translate(self.NAME), + "name": i18n.translate("viur.core.modules.user." + self.NAME), "action_name": self.ACTION_NAME, "action_url": f"{self.modulePath}/{self.ACTION_NAME}", }, @@ -843,7 +843,7 @@ def otp(self, *args, **kwargs): skel.errors = [ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Wrong OTP Token", ["otptoken"])] return self._user_module.render.edit( skel, - name=i18n.translate(self.NAME), + name=i18n.translate("viur.core.modules.user.auth." + self.NAME), action_name=self.ACTION_NAME, action_url=f"{self.modulePath}/{self.ACTION_NAME}", tpl=self.second_factor_login_template @@ -981,7 +981,7 @@ def add(self, otp=None): return self._user_module.render.second_factor_add( tpl=self.second_factor_add_template, action_name=self.ACTION_NAME, - name=i18n.translate(self.NAME), + name=i18n.translate("viur.core.modules.user.auth." + self.NAME), add_url=self.add_url, otp_uri=AuthenticatorOTP.generate_otp_app_secret_uri(otp_app_secret)) else: @@ -989,7 +989,7 @@ def add(self, otp=None): return self._user_module.render.second_factor_add( tpl=self.second_factor_add_template, action_name=self.ACTION_NAME, - name=i18n.translate(self.NAME), + name=i18n.translate(("viur.core.modules.user.auth." + self.NAME), add_url=self.add_url, otp_uri=AuthenticatorOTP.generate_otp_app_secret_uri(otp_app_secret)) # to add errors @@ -997,7 +997,7 @@ def add(self, otp=None): AuthenticatorOTP.set_otp_app_secret(otp_app_secret) return self._user_module.render.second_factor_add_success( action_name=self.ACTION_NAME, - name=i18n.translate(self.NAME), + name=i18n.translate(("viur.core.modules.user.auth." + self.NAME), ) def can_handle(self, skel: skeleton.SkeletonInstance) -> bool: @@ -1073,7 +1073,7 @@ def start(self): return self._user_module.render.edit( TimeBasedOTP.OtpSkel(), params={ - "name": i18n.translate(self.NAME), + "name": i18n.translate("viur.core.modules.user.auth." + self.NAME), "action_name": self.ACTION_NAME, "action_url": self.action_url, }, @@ -1112,7 +1112,7 @@ def authenticator_otp(self, **kwargs): skel.errors = [ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Wrong OTP Token", ["otptoken"])] return self._user_module.render.edit( skel, - name=i18n.translate(self.NAME), + name=i18n.translate("viur.core.modules.user.auth." + self.NAME), action_name=self.ACTION_NAME, action_url=self.action_url, tpl=self.second_factor_login_template, @@ -1188,7 +1188,7 @@ class User(List): "customActions": { "trigger_kick": { "name": i18n.translate( - key="viur.modules.user.customActions.kick", + key="viur.core.modules.user.customActions.kick", defaultText="Kick user", hint="Title of the kick user function" ), @@ -1197,17 +1197,17 @@ class User(List): "action": "fetch", "url": "/vi/{{module}}/trigger/kick/{{key}}?skey={{skey}}", "confirm": i18n.translate( - key="viur.modules.user.customActions.kick.confirm", + key="viur.core.modules.user.customActions.kick.confirm", defaultText="Do you really want to drop all sessions of the selected user from the system?", ), "success": i18n.translate( - key="viur.modules.user.customActions.kick.success", + key="viur.core.modules.user.customActions.kick.success", defaultText="Sessions of the user are being invalidated.", ), }, "trigger_takeover": { "name": i18n.translate( - key="viur.modules.user.customActions.takeover", + key="viur.core.modules.user.customActions.takeover", defaultText="Take-over user", hint="Title of the take user over function" ), @@ -1216,12 +1216,12 @@ class User(List): "action": "fetch", "url": "/vi/{{module}}/trigger/takeover/{{key}}?skey={{skey}}", "confirm": i18n.translate( - key="viur.modules.user.customActions.takeover.confirm", + key="viur.core.modules.user.customActions.takeover.confirm", defaultText="Do you really want to replace your current user session by a " "user session of the selected user?", ), "success": i18n.translate( - key="viur.modules.user.customActions.takeover.success", + key="viur.core.modules.user.customActions.takeover.success", defaultText="You're now know as the selected user!", ), "then": "reload-vi", From adf133091ae50b9cefaada1a4aef26013bf1d78d Mon Sep 17 00:00:00 2001 From: Jan Max Meyer Date: Thu, 9 Jan 2025 20:08:31 +0100 Subject: [PATCH 2/6] feat: Replace former `get_public()` by universal `dump()` --- src/viur/core/modules/translation.py | 64 ++++++++++++++++------------ src/viur/core/modules/user.py | 4 +- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/src/viur/core/modules/translation.py b/src/viur/core/modules/translation.py index 7257a3072..f7af0e384 100644 --- a/src/viur/core/modules/translation.py +++ b/src/viur/core/modules/translation.py @@ -195,56 +195,66 @@ def _reload_translations(self): _last_reload = None # Cut my strings into pieces, this is my last reload... @exposed - def get_public( + def dump( self, *, - languages: list[str] = [], - pattern: str = "*", + pattern: str, + language: list[str] = [], ) -> dict[str, str] | dict[str, dict[str, str]]: """ - Dumps public translations as JSON. + Dumps translations as JSON. - :param languages: Allows to request a specific language. - :param pattern: Provide an fnmatch-style key filter pattern + :param pattern: Required, provide an fnmatch-style key filter pattern for the translations keys to dump. + :param language: Allows to request a specific language. 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&pattern=bool.*` for english translations, - but only keys starting with "bool." - - `/json/_translation/get_public?languages=en&languages=de` for english and german translations - - `/json/_translation/get_public?languages=*` for all available languages + - `/json/_translation/dump?pattern=viur.*` get viur.*-translations for current language + - `/json/_translation/dump?pattern=viur.*&language=en` for english translations + - `/json/_translation/dump?pattern=viur.*&language=en&language=de` for english and german translations + - `/json/_translation/dump?pattern=viur.*&language=*` for all available language """ if not utils.string.is_prefix(self.render.kind, "json"): raise errors.BadRequest("Can only use this function on JSON-based renders") + # The pattern may not be a matcher for all! + if not pattern.strip("*?."): + raise errors.BadRequest("Pattern is too generic.") + + # Required to provide + cuser = current.user.get() + 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) + and any(os.getenv("HTTP_HOST", "") in l for l 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_") and fnmatch.fnmatch(tr_key, pattern) - } - for lang in languages - }) + if language: + if len(language) == 1 and language[0] == "*": + language = conf.i18n.available_dialects + + if len(language) > 1: + return json.dumps({ + lang: { + tr_key: str(translate(tr_key, force_lang=lang)) + for tr_key, values in systemTranslations.items() + if (cuser or values.get("_public_")) and fnmatch.fnmatch(tr_key, pattern) + } + for lang in language + }) + else: + language = language.pop() + else: + language = current.language.get() return json.dumps({ - tr_key: str(translate(tr_key)) + tr_key: str(translate(tr_key, force_lang=language)) for tr_key, values in systemTranslations.items() - if values.get("_public_") and fnmatch.fnmatch(tr_key, pattern) + if (cuser or values.get("_public_")) and fnmatch.fnmatch(tr_key, pattern) }) diff --git a/src/viur/core/modules/user.py b/src/viur/core/modules/user.py index 46e475c84..67ea8fb83 100644 --- a/src/viur/core/modules/user.py +++ b/src/viur/core/modules/user.py @@ -989,7 +989,7 @@ def add(self, otp=None): return self._user_module.render.second_factor_add( tpl=self.second_factor_add_template, action_name=self.ACTION_NAME, - name=i18n.translate(("viur.core.modules.user.auth." + self.NAME), + name=i18n.translate("viur.core.modules.user.auth." + self.NAME), add_url=self.add_url, otp_uri=AuthenticatorOTP.generate_otp_app_secret_uri(otp_app_secret)) # to add errors @@ -997,7 +997,7 @@ def add(self, otp=None): AuthenticatorOTP.set_otp_app_secret(otp_app_secret) return self._user_module.render.second_factor_add_success( action_name=self.ACTION_NAME, - name=i18n.translate(("viur.core.modules.user.auth." + self.NAME), + name=i18n.translate("viur.core.modules.user.auth." + self.NAME), ) def can_handle(self, skel: skeleton.SkeletonInstance) -> bool: From 9ce7c9464ab7c109423c7923e028d092a2cbd675 Mon Sep 17 00:00:00 2001 From: Jan Max Meyer Date: Thu, 9 Jan 2025 20:17:22 +0100 Subject: [PATCH 3/6] fix linter issues --- src/viur/core/bones/select.py | 3 ++- src/viur/core/modules/translation.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/viur/core/bones/select.py b/src/viur/core/bones/select.py index 0d3726571..f0abe04bd 100644 --- a/src/viur/core/bones/select.py +++ b/src/viur/core/bones/select.py @@ -100,7 +100,8 @@ def __getattribute__(self, item): values = { key: label if isinstance(label, translate) else translate( f"{prefix}{label}", str(label), - f"value {key} for {self.name}<{type(self).__name__}> in {self.skel_cls.__name__} in {self.skel_cls}" + f"value {key} for {self.name}<{type(self).__name__}> " + + f"in {self.skel_cls.__name__} in {self.skel_cls}" ) for key, label in values.items() } diff --git a/src/viur/core/modules/translation.py b/src/viur/core/modules/translation.py index f7af0e384..5a56a0926 100644 --- a/src/viur/core/modules/translation.py +++ b/src/viur/core/modules/translation.py @@ -228,7 +228,7 @@ def dump( if ( not (conf.debug.disable_cache and current.request.get().disableCache) - and any(os.getenv("HTTP_HOST", "") in l for l in conf.i18n.domain_language_mapping) + and any(os.getenv("HTTP_HOST", "") in dlm for dlm 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}" From 13c3efc22eb58c1fe8e86c3ddee7b6ce182ad5f7 Mon Sep 17 00:00:00 2001 From: Jan Max Meyer Date: Thu, 9 Jan 2025 21:13:39 +0100 Subject: [PATCH 4/6] fixed comment --- src/viur/core/modules/translation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/viur/core/modules/translation.py b/src/viur/core/modules/translation.py index 5a56a0926..93445d323 100644 --- a/src/viur/core/modules/translation.py +++ b/src/viur/core/modules/translation.py @@ -221,7 +221,7 @@ def dump( if not pattern.strip("*?."): raise errors.BadRequest("Pattern is too generic.") - # Required to provide + # Only authenticated users may see private translations cuser = current.user.get() current.request.get().response.headers["Content-Type"] = "application/json" From 16426e81b88633fd8bfe212486b988b89aaedc83 Mon Sep 17 00:00:00 2001 From: Jan Max Meyer Date: Tue, 14 Jan 2025 15:11:56 +0100 Subject: [PATCH 5/6] Improved add_missing_translations - SelectBone now allowes for a separate `add_missing_translations`-parameter - `conf.i18n.add_missing_translations` can now be also an iterable of fnmatch-patterns - `translate`-class allowes for individual `add_missing`-flag --- src/viur/core/bones/select.py | 28 ++++++------ src/viur/core/config.py | 8 +++- src/viur/core/i18n.py | 83 +++++++++++++++++++++-------------- 3 files changed, 70 insertions(+), 49 deletions(-) diff --git a/src/viur/core/bones/select.py b/src/viur/core/bones/select.py index f0abe04bd..c2e8eb63b 100644 --- a/src/viur/core/bones/select.py +++ b/src/viur/core/bones/select.py @@ -46,6 +46,7 @@ def __init__( ] = None, values: dict | list | tuple | t.Callable | enum.EnumMeta = (), translation_key_prefix: str | t.Callable[[t.Self], str] = "", + add_missing_translations: bool = False, **kwargs ): """ @@ -61,6 +62,7 @@ def __init__( """ super().__init__(defaultValue=defaultValue, **kwargs) self.translation_key_prefix = translation_key_prefix + self.add_missing_translations = add_missing_translations # handle list/tuple as dicts if isinstance(values, (list, tuple)): @@ -92,19 +94,19 @@ def __getattribute__(self, item): assert isinstance(values, (dict, OrderedDict)) - if self.languages: - prefix = self.translation_key_prefix - if callable(prefix): - prefix = prefix(self) - - values = { - key: label if isinstance(label, translate) else translate( - f"{prefix}{label}", str(label), - f"value {key} for {self.name}<{type(self).__name__}> " - + f"in {self.skel_cls.__name__} in {self.skel_cls}" - ) - for key, label in values.items() - } + prefix = self.translation_key_prefix + if callable(prefix): + prefix = prefix(self) + + values = { + key: label if isinstance(label, translate) else translate( + f"{prefix}{key}", str(label), + f"value {key} for {self.name}<{type(self).__name__}> " + + f"in {self.skel_cls.__name__} in {self.skel_cls}", + add_missing=self.add_missing_translations, + ) + for key, label in values.items() + } return values diff --git a/src/viur/core/config.py b/src/viur/core/config.py index e3f7d5f24..457d023b0 100644 --- a/src/viur/core/config.py +++ b/src/viur/core/config.py @@ -606,13 +606,17 @@ def available_dialects(self) -> list[str]: res |= self.language_alias_map return list(res.keys()) - add_missing_translations: bool = False - """Add missing translation into datastore. + add_missing_translations: bool | str | t.Iterable[str] = False + """Add missing translation into datastore, optionally with given fnmatch-patterns. If a key is not found in the translation table when a translation is rendered, a database entry is created with the key and hint and default value (if set) so that the translations can be entered in the administration. + + Instead of setting add_missing_translations to a boolean, it can also be set to + a pattern or iterable of fnmatch-patterns; Only translation keys matching these + patterns will be automatically added. """ diff --git a/src/viur/core/i18n.py b/src/viur/core/i18n.py index 31c8ecc51..7e111c005 100644 --- a/src/viur/core/i18n.py +++ b/src/viur/core/i18n.py @@ -74,6 +74,7 @@ on your own. Just use the TranslateSkel). """ # FIXME: grammar, rst syntax import datetime +import fnmatch import jinja2.ext as jinja2 import logging import traceback @@ -157,6 +158,7 @@ class translate: "translationCache", "force_lang", "public", + "add_missing", ) def __init__( @@ -166,6 +168,7 @@ def __init__( hint: str = None, force_lang: str = None, public: bool = False, + add_missing: bool = False, ): """ :param key: The unique key defining this text fragment. @@ -195,6 +198,7 @@ def __init__( self.force_lang = force_lang self.public = public + self.add_missing = add_missing def __repr__(self) -> str: return f"" @@ -205,48 +209,59 @@ def __str__(self) -> str: from viur.core.render.html.env.viur import translate as jinja_translate - if ( - not self.key.startswith("core.") - and not self.key.startswith("viur.") - and self.key not in systemTranslations - and conf.i18n.add_missing_translations - ): - # This translation seems to be new and should be added - filename = lineno = None - is_jinja = False - for frame, line in traceback.walk_stack(None): - if filename is None: - # Use the first frame as fallback. - # In case of calling this class directly, - # this is anyway the caller we're looking for. - filename = frame.f_code.co_filename - lineno = frame.f_lineno - if frame.f_code == jinja_translate.__code__: - # The call was caused by our jinja method - is_jinja = True - if is_jinja and not frame.f_code.co_filename.endswith(".py"): - # Look for the latest html, macro (not py) where the - # translate method has been used, that's our caller - filename = frame.f_code.co_filename - lineno = line - break - - add_missing_translation( - key=self.key, - hint=self.hint, - default_text=self.defaultText, - filename=filename, - lineno=lineno, - public=self.public, - ) + if self.key not in systemTranslations: + # either the translate()-object has add_missing set + if not (add_missing := self.add_missing): + # otherwise, use configuration flag + add_missing = conf.i18n.add_missing_translations + + # match against fnmatch pattern, when given + if isinstance(add_missing, str): + add_missing = fnmatch.fnmatch(self.key, add_missing) + elif isinstance(add_missing, t.Iterable): + add_missing = any(fnmatch.fnmatch(self.key, pat) for pat in add_missing) + else: + add_missing = bool(add_missing) + + if add_missing: + # This translation seems to be new and should be added + filename = lineno = None + is_jinja = False + for frame, line in traceback.walk_stack(None): + if filename is None: + # Use the first frame as fallback. + # In case of calling this class directly, + # this is anyway the caller we're looking for. + filename = frame.f_code.co_filename + lineno = frame.f_lineno + if frame.f_code == jinja_translate.__code__: + # The call was caused by our jinja method + is_jinja = True + if is_jinja and not frame.f_code.co_filename.endswith(".py"): + # Look for the latest html, macro (not py) where the + # translate method has been used, that's our caller + filename = frame.f_code.co_filename + lineno = line + break + + add_missing_translation( + key=self.key, + hint=self.hint, + default_text=self.defaultText, + filename=filename, + lineno=lineno, + public=self.public, + ) self.translationCache = self.merge_alias(systemTranslations.get(self.key, {})) if (lang := self.force_lang) is None: # The default case: use the request language lang = current.language.get() + if value := self.translationCache.get(lang): return value + # Use the default text from datastore or from the caller arguments return self.translationCache.get("_default_text_") or self.defaultText From 048ae0ee3109bd959e3a01f8e2a0fd2331ef07ec Mon Sep 17 00:00:00 2001 From: Sven Eberth Date: Sat, 25 Jan 2025 04:41:00 +0100 Subject: [PATCH 6/6] feat: Implement default_variables; Resolves #1379 --- src/viur/core/i18n.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/viur/core/i18n.py b/src/viur/core/i18n.py index b0f3974d8..e8bcbfc76 100644 --- a/src/viur/core/i18n.py +++ b/src/viur/core/i18n.py @@ -75,7 +75,6 @@ """ # FIXME: grammar, rst syntax import datetime import fnmatch -import jinja2.ext as jinja2 import logging import sys import traceback @@ -164,6 +163,7 @@ class translate: "filename", "lineno", "add_missing", + "default_variables", ) def __init__( @@ -174,6 +174,7 @@ def __init__( force_lang: str = None, public: bool = False, add_missing: bool = False, + default_variables: dict[str, t.Any] | None = None, caller_is_jinja: bool = False, ): """ @@ -187,6 +188,7 @@ def __init__( target language. :param force_lang: Use this language instead the one of the request. :param public: Flag for public translations, which can be obtained via /json/_translate/get_public. + :param default_variables: Default values for variable substitution. :param caller_is_jinja: Is the call caused by our jinja method? """ super().__init__() @@ -205,6 +207,7 @@ def __init__( self.force_lang = force_lang self.public = public self.add_missing = add_missing + self.default_variables = default_variables or {} self.filename, self.lineno = None, None if self.key not in systemTranslations and conf.i18n.add_missing_translations: @@ -232,7 +235,6 @@ def __str__(self) -> str: if self.translationCache is None: global systemTranslations - if self.key not in systemTranslations: # either the translate()-object has add_missing set if not (add_missing := self.add_missing): @@ -265,21 +267,24 @@ def __str__(self) -> str: lang = current.language.get() if value := self.translationCache.get(lang): - return value + return self.substitute_vars(value, **self.default_variables) # Use the default text from datastore or from the caller arguments - return self.translationCache.get("_default_text_") or self.defaultText + return self.substitute_vars( + self.translationCache.get("_default_text_") or self.defaultText, + **self.default_variables + ) def translate(self, **kwargs) -> str: """Substitute the given kwargs in the translated or default text.""" - return self.substitute_vars(str(self), **kwargs) + return self.substitute_vars(str(self), **(self.default_variables | kwargs)) - def __call__(self, **kwargs): + def __call__(self, **kwargs) -> str: """Just an alias for translate""" return self.translate(**kwargs) @staticmethod - def substitute_vars(value: str, **kwargs): + def substitute_vars(value: str, **kwargs) -> str: """Substitute vars in a translation Variables has to start with two braces (`{{`), followed by the variable