Files
orion/app/utils/i18n.py
Samir Boulahtit 8ff9c39845 feat: add module-specific locale support for i18n
Enhance the self-contained module architecture with locale/translation support:

ModuleDefinition changes:
- Add locales_path attribute for module-specific translations
- Add get_locales_dir() helper method
- Include locales in validate_structure() check

i18n module changes (app/utils/i18n.py):
- Add get_module_locale_dirs() to discover module locales
- Update load_translations() to merge module translations with core
- Module translations namespaced under module code (e.g., cms.title)
- Add _deep_merge() helper for nested dictionary merging
- Add _load_json_file() helper for cleaner JSON loading

CMS module locales:
- Add app/modules/cms/locales/ with translations for all 4 languages
- en.json, fr.json, de.json, lb.json with CMS-specific strings
- Covers: pages, page editing, SEO, navigation, publishing, homepage
  sections, media library, themes, actions, and messages

Usage in templates:
  {{ _("cms.title") }}           -> "Content Management" (en)
  {{ _("cms.pages.create") }}    -> "Créer une page" (fr)
  {{ _("cms.publishing.draft") }} -> "Entwurf" (de)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 21:44:28 +01:00

505 lines
14 KiB
Python

# app/utils/i18n.py
"""
Internationalization (i18n) utilities for multi-language support.
This module provides:
- Language constants and configuration
- JSON-based translation loading
- Translation helper functions
- Jinja2 template integration
Supported languages:
- en: English
- fr: French (default for Luxembourg)
- de: German
- lb: Luxembourgish
"""
import json
import logging
from functools import lru_cache
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# ============================================================================
# Language Configuration
# ============================================================================
# Supported language codes
SUPPORTED_LANGUAGES = ["en", "fr", "de", "lb"]
# Default language (Luxembourg context)
DEFAULT_LANGUAGE = "fr"
# Language display names (in their native language)
LANGUAGE_NAMES = {
"en": "English",
"fr": "Français",
"de": "Deutsch",
"lb": "Lëtzebuergesch",
}
# Language display names in English (for admin)
LANGUAGE_NAMES_EN = {
"en": "English",
"fr": "French",
"de": "German",
"lb": "Luxembourgish",
}
# Flag emoji for language selector (optional)
LANGUAGE_FLAGS = {
"en": "🇬🇧",
"fr": "🇫🇷",
"de": "🇩🇪",
"lb": "🇱🇺",
}
# ============================================================================
# Translation Storage
# ============================================================================
# Path to core locale files
LOCALES_PATH = Path(__file__).parent.parent.parent / "static" / "locales"
# Path to modules directory (for module-specific locales)
MODULES_PATH = Path(__file__).parent.parent / "modules"
# In-memory cache for loaded translations
_translations: dict[str, dict] = {}
# Cache for module locale directories
_module_locale_dirs: list[tuple[str, Path]] | None = None
def get_locales_path() -> Path:
"""Get the path to core locale files."""
return LOCALES_PATH
def get_module_locale_dirs() -> list[tuple[str, Path]]:
"""
Discover module locale directories.
Returns:
List of (module_code, locales_path) tuples for modules with locales
"""
global _module_locale_dirs
if _module_locale_dirs is not None:
return _module_locale_dirs
_module_locale_dirs = []
if not MODULES_PATH.exists():
return _module_locale_dirs
for module_dir in sorted(MODULES_PATH.iterdir()):
if module_dir.is_dir():
locales_dir = module_dir / "locales"
if locales_dir.exists() and locales_dir.is_dir():
module_code = module_dir.name
_module_locale_dirs.append((module_code, locales_dir))
logger.debug(f"[i18n] Found module locales: {module_code} at {locales_dir}")
return _module_locale_dirs
def _load_json_file(file_path: Path) -> dict:
"""Load a JSON file and return its contents."""
try:
with open(file_path, encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON in translation file {file_path}: {e}")
return {}
except Exception as e:
logger.error(f"Error loading translation file {file_path}: {e}")
return {}
def _deep_merge(base: dict, override: dict) -> dict:
"""
Deep merge two dictionaries. Override values take precedence.
Args:
base: Base dictionary
override: Dictionary with values to merge/override
Returns:
Merged dictionary
"""
result = base.copy()
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = _deep_merge(result[key], value)
else:
result[key] = value
return result
@lru_cache(maxsize=10)
def load_translations(language: str) -> dict:
"""
Load translations for a specific language from JSON files.
Loads core translations first, then merges module-specific translations.
Module translations are namespaced under their module code.
Args:
language: Language code (en, fr, de, lb)
Returns:
Dictionary of translations, empty dict if file not found
Example:
Core: {"common": {"save": "Save"}}
CMS module: {"pages": {"title": "Page Title"}}
Result: {"common": {"save": "Save"}, "cms": {"pages": {"title": "Page Title"}}}
"""
if language not in SUPPORTED_LANGUAGES:
logger.warning(f"Unsupported language requested: {language}")
language = DEFAULT_LANGUAGE
# Load core translations
core_file = LOCALES_PATH / f"{language}.json"
if core_file.exists():
translations = _load_json_file(core_file)
else:
logger.warning(f"Core translation file not found: {core_file}")
if language != DEFAULT_LANGUAGE:
return load_translations(DEFAULT_LANGUAGE)
translations = {}
# Load and merge module translations
for module_code, locales_dir in get_module_locale_dirs():
module_file = locales_dir / f"{language}.json"
if module_file.exists():
module_translations = _load_json_file(module_file)
if module_translations:
# Namespace module translations under module code
if module_code in translations:
# Merge with existing (e.g., if core has some cms.* keys)
translations[module_code] = _deep_merge(
translations[module_code], module_translations
)
else:
translations[module_code] = module_translations
logger.debug(f"[i18n] Loaded {language} translations for module: {module_code}")
return translations
def clear_translation_cache():
"""Clear the translation cache (useful for development/testing)."""
global _module_locale_dirs
load_translations.cache_clear()
_translations.clear()
_module_locale_dirs = None
# ============================================================================
# Translation Functions
# ============================================================================
def get_nested_value(data: dict, key_path: str, default: str = None) -> str:
"""
Get a nested value from a dictionary using dot notation.
Args:
data: Dictionary to search
key_path: Dot-separated path (e.g., "common.save")
default: Default value if key not found
Returns:
Value at the key path, or default if not found
"""
keys = key_path.split(".")
value = data
for key in keys:
if isinstance(value, dict) and key in value:
value = value[key]
else:
return default if default is not None else key_path
return (
value
if isinstance(value, str)
else (default if default is not None else key_path)
)
def translate(
key: str,
language: str = None,
default: str = None,
**kwargs: Any,
) -> str:
"""
Translate a key to the specified language.
Args:
key: Translation key (dot notation, e.g., "common.save")
language: Target language code (defaults to DEFAULT_LANGUAGE)
default: Default value if translation not found
**kwargs: Variables for string interpolation
Returns:
Translated string with variables interpolated
Example:
translate("common.welcome", language="fr", name="John")
# Returns: "Bienvenue, John!" (if translation is "Bienvenue, {name}!")
"""
if language is None:
language = DEFAULT_LANGUAGE
translations = load_translations(language)
text = get_nested_value(translations, key, default)
# If translation not found and we're not already in default language, try default
if text == key and language != DEFAULT_LANGUAGE:
translations = load_translations(DEFAULT_LANGUAGE)
text = get_nested_value(translations, key, default)
# Interpolate variables
if kwargs and isinstance(text, str):
try:
text = text.format(**kwargs)
except KeyError as e:
logger.warning(
f"Missing interpolation variable in translation '{key}': {e}"
)
return text
def t(key: str, language: str = None, **kwargs: Any) -> str:
"""
Shorthand alias for translate().
Example:
t("common.save", "fr") # Returns French translation
"""
return translate(key, language, **kwargs)
# ============================================================================
# Jinja2 Integration
# ============================================================================
class TranslationContext:
"""
Translation context for use in Jinja2 templates.
Provides a callable interface for the _() function in templates.
"""
def __init__(self, language: str = None):
"""Initialize with a specific language."""
self.language = language or DEFAULT_LANGUAGE
def __call__(self, key: str, **kwargs: Any) -> str:
"""Translate a key."""
return translate(key, self.language, **kwargs)
def set_language(self, language: str):
"""Change the current language."""
if language in SUPPORTED_LANGUAGES:
self.language = language
else:
logger.warning(f"Attempted to set unsupported language: {language}")
def create_translation_context(language: str = None) -> TranslationContext:
"""Create a translation context for templates."""
return TranslationContext(language)
def get_jinja2_globals(language: str = None) -> dict:
"""
Get globals to add to Jinja2 environment for i18n support.
Returns:
Dictionary of globals for Jinja2 templates
"""
ctx = create_translation_context(language)
return {
"_": ctx, # Main translation function: {{ _("common.save") }}
"t": ctx, # Alias: {{ t("common.save") }}
"SUPPORTED_LANGUAGES": SUPPORTED_LANGUAGES,
"DEFAULT_LANGUAGE": DEFAULT_LANGUAGE,
"LANGUAGE_NAMES": LANGUAGE_NAMES,
"LANGUAGE_FLAGS": LANGUAGE_FLAGS,
"current_language": language or DEFAULT_LANGUAGE,
}
# ============================================================================
# Language Resolution Helpers
# ============================================================================
def resolve_vendor_dashboard_language(
user_preferred: str | None,
vendor_dashboard: str | None,
) -> str:
"""
Resolve language for vendor dashboard.
Priority:
1. User's preferred_language (if set)
2. Vendor's dashboard_language
3. System default (fr)
"""
if user_preferred and user_preferred in SUPPORTED_LANGUAGES:
return user_preferred
if vendor_dashboard and vendor_dashboard in SUPPORTED_LANGUAGES:
return vendor_dashboard
return DEFAULT_LANGUAGE
def resolve_storefront_language(
customer_preferred: str | None,
session_language: str | None,
vendor_storefront: str | None,
browser_language: str | None,
enabled_languages: list[str] | None = None,
) -> str:
"""
Resolve language for storefront.
Priority:
1. Customer's preferred_language (if logged in and set)
2. Session/cookie language
3. Vendor's storefront_language
4. Browser Accept-Language header
5. System default (fr)
Args:
customer_preferred: Customer's saved preference
session_language: Language from session/cookie
vendor_storefront: Vendor's default storefront language
browser_language: Primary language from Accept-Language header
enabled_languages: List of languages enabled for this storefront
"""
candidates = [
customer_preferred,
session_language,
vendor_storefront,
browser_language,
DEFAULT_LANGUAGE,
]
# Filter to enabled languages if specified
if enabled_languages:
enabled_set = set(enabled_languages)
for lang in candidates:
if lang and lang in SUPPORTED_LANGUAGES and lang in enabled_set:
return lang
else:
for lang in candidates:
if lang and lang in SUPPORTED_LANGUAGES:
return lang
return DEFAULT_LANGUAGE
def parse_accept_language(accept_language: str | None) -> str | None:
"""
Parse Accept-Language header and return the best supported language.
Args:
accept_language: Accept-Language header value
Returns:
Best matching supported language code, or None
"""
if not accept_language:
return None
# Parse header: "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7"
languages = []
for part in accept_language.split(","):
part = part.strip()
if ";q=" in part:
lang, q = part.split(";q=")
try:
quality = float(q)
except ValueError:
quality = 0.0
else:
lang = part
quality = 1.0
# Extract base language code (e.g., "fr-FR" -> "fr")
lang_code = lang.split("-")[0].lower()
languages.append((lang_code, quality))
# Sort by quality (highest first)
languages.sort(key=lambda x: x[1], reverse=True)
# Return first supported language
for lang_code, _ in languages:
if lang_code in SUPPORTED_LANGUAGES:
return lang_code
return None
# ============================================================================
# Utility Functions
# ============================================================================
def get_language_choices() -> list[tuple[str, str]]:
"""
Get language choices for form select fields.
Returns:
List of (code, name) tuples
"""
return [(code, LANGUAGE_NAMES[code]) for code in SUPPORTED_LANGUAGES]
def get_language_info(language: str) -> dict:
"""
Get full information about a language.
Args:
language: Language code
Returns:
Dictionary with code, name, name_en, flag
"""
if language not in SUPPORTED_LANGUAGES:
language = DEFAULT_LANGUAGE
return {
"code": language,
"name": LANGUAGE_NAMES.get(language, language),
"name_en": LANGUAGE_NAMES_EN.get(language, language),
"flag": LANGUAGE_FLAGS.get(language, "🌐"),
}
def is_rtl_language(language: str) -> bool:
"""
Check if a language is right-to-left.
Currently all supported languages are LTR.
"""
# RTL languages (not currently supported, but ready for future)
rtl_languages = ["ar", "he", "fa"]
return language in rtl_languages