All checks were successful
- Fix platform-grouped merchant sidebar menu with core items at root level - Add merchant store management (detail page, create store, team page) - Fix store settings 500 error by removing dead stripe/API tab - Move onboarding translations to module-owned locale files - Fix onboarding banner i18n with server-side rendering + context inheritance - Refactor login language selectors to use languageSelector() function (LANG-002) - Move HTTPException handling to global exception handler in merchant routes (API-003) - Add language selector to all login pages and portal headers - Fix customer module: drop order stats from customer model, add to orders module - Fix admin menu config visibility for super admin platform context - Fix storefront auth and layout issues - Add missing i18n translations for onboarding steps (en/fr/de/lb) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
512 lines
15 KiB
Python
512 lines
15 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_store_dashboard_language(
|
|
user_preferred: str | None,
|
|
store_dashboard: str | None,
|
|
cookie_language: str | None = None,
|
|
) -> str:
|
|
"""
|
|
Resolve language for store dashboard.
|
|
|
|
Priority:
|
|
1. Cookie (explicit UI action via language switcher)
|
|
2. User's preferred_language (DB preference from profile settings)
|
|
3. Store's dashboard_language
|
|
4. System default (fr)
|
|
"""
|
|
if cookie_language and cookie_language in SUPPORTED_LANGUAGES:
|
|
return cookie_language
|
|
|
|
if user_preferred and user_preferred in SUPPORTED_LANGUAGES:
|
|
return user_preferred
|
|
|
|
if store_dashboard and store_dashboard in SUPPORTED_LANGUAGES:
|
|
return store_dashboard
|
|
|
|
return DEFAULT_LANGUAGE
|
|
|
|
|
|
def resolve_storefront_language(
|
|
customer_preferred: str | None,
|
|
session_language: str | None,
|
|
store_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. Store'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
|
|
store_storefront: Store's default storefront language
|
|
browser_language: Primary language from Accept-Language header
|
|
enabled_languages: List of languages enabled for this storefront
|
|
"""
|
|
# Explicit user choices (customer preference, session/cookie) should be
|
|
# respected if they are valid platform languages, regardless of
|
|
# store's enabled_languages. The store setting only constrains automatic
|
|
# fallback (browser language, store default).
|
|
explicit_choices = [customer_preferred, session_language]
|
|
for lang in explicit_choices:
|
|
if lang and lang in SUPPORTED_LANGUAGES:
|
|
return lang
|
|
|
|
# For automatic fallback, filter by store's enabled languages
|
|
fallback_candidates = [store_storefront, browser_language, DEFAULT_LANGUAGE]
|
|
if enabled_languages:
|
|
enabled_set = set(enabled_languages)
|
|
for lang in fallback_candidates:
|
|
if lang and lang in SUPPORTED_LANGUAGES and lang in enabled_set:
|
|
return lang
|
|
else:
|
|
for lang in fallback_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
|