feat: add multi-language (i18n) support for vendor dashboard and storefront
- Add database fields for language preferences: - Vendor: dashboard_language, storefront_language, storefront_languages - User: preferred_language - Customer: preferred_language - Add language middleware for request-level language detection: - Cookie-based persistence - Browser Accept-Language fallback - Vendor storefront language constraints - Add language API endpoints (/api/v1/language/*): - POST /set - Set language preference - GET /current - Get current language info - GET /list - List available languages - DELETE /clear - Clear preference - Add i18n utilities (app/utils/i18n.py): - JSON-based translation loading - Jinja2 template integration - Language resolution helpers - Add reusable language selector macros for templates - Add languageSelector() Alpine.js component - Add translation files (en, fr, de, lb) in static/locales/ - Add architecture rules documentation for language implementation - Update marketplace-product-detail.js to use native language names 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
409
app/utils/i18n.py
Normal file
409
app/utils/i18n.py
Normal file
@@ -0,0 +1,409 @@
|
||||
# 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 locale files
|
||||
LOCALES_PATH = Path(__file__).parent.parent.parent / "static" / "locales"
|
||||
|
||||
# In-memory cache for loaded translations
|
||||
_translations: dict[str, dict] = {}
|
||||
|
||||
|
||||
def get_locales_path() -> Path:
|
||||
"""Get the path to locale files."""
|
||||
return LOCALES_PATH
|
||||
|
||||
|
||||
@lru_cache(maxsize=10)
|
||||
def load_translations(language: str) -> dict:
|
||||
"""
|
||||
Load translations for a specific language from JSON file.
|
||||
|
||||
Args:
|
||||
language: Language code (en, fr, de, lb)
|
||||
|
||||
Returns:
|
||||
Dictionary of translations, empty dict if file not found
|
||||
"""
|
||||
if language not in SUPPORTED_LANGUAGES:
|
||||
logger.warning(f"Unsupported language requested: {language}")
|
||||
language = DEFAULT_LANGUAGE
|
||||
|
||||
locale_file = LOCALES_PATH / f"{language}.json"
|
||||
|
||||
if not locale_file.exists():
|
||||
logger.warning(f"Translation file not found: {locale_file}")
|
||||
# Fall back to default language
|
||||
if language != DEFAULT_LANGUAGE:
|
||||
return load_translations(DEFAULT_LANGUAGE)
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(locale_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Invalid JSON in translation file {locale_file}: {e}")
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading translation file {locale_file}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def clear_translation_cache():
|
||||
"""Clear the translation cache (useful for development/testing)."""
|
||||
load_translations.cache_clear()
|
||||
_translations.clear()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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
|
||||
Reference in New Issue
Block a user