- Replaced `SUPPORTED_LOCALES` with `supported_locales` for naming consistency. - Applied formatting improvements to multiline statements for better readability. - Cleaned up redundant comments and streamlined test assertions.
133 lines
4.3 KiB
Python
133 lines
4.3 KiB
Python
# app/api/dependencies/locale.py
|
|
"""
|
|
Locale detection dependency for internationalization (i18n).
|
|
|
|
Implements a three-tier fallback system:
|
|
1. User's saved preference (if authenticated and user.locale is set)
|
|
2. Accept-Language header (for unauthenticated users or no saved preference)
|
|
3. Default to English ("en")
|
|
"""
|
|
|
|
from fastapi import Depends, Request
|
|
|
|
from app.api.dependencies.auth import get_optional_current_user
|
|
from app.models.user import User
|
|
|
|
# Supported locales (BCP 47 format)
|
|
# Template showcases English and Italian
|
|
# Users can extend by adding more locales here
|
|
# Note: Stored in lowercase for case-insensitive matching
|
|
SUPPORTED_LOCALES = {"en", "it", "en-us", "en-gb", "it-it"}
|
|
DEFAULT_LOCALE = "en"
|
|
|
|
|
|
def parse_accept_language(accept_language: str) -> str | None:
|
|
"""
|
|
Parse the Accept-Language header and return the best matching supported locale.
|
|
|
|
The Accept-Language header format is:
|
|
"it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7"
|
|
|
|
This function extracts locales in priority order (by quality value) and returns
|
|
the first one that matches our supported locales.
|
|
|
|
Args:
|
|
accept_language: The Accept-Language header value
|
|
|
|
Returns:
|
|
The best matching locale code, or None if no match found
|
|
|
|
Examples:
|
|
>>> parse_accept_language("it-IT,it;q=0.9,en;q=0.8")
|
|
"it-IT" # or "it" if it-IT is not supported
|
|
>>> parse_accept_language("fr-FR,fr;q=0.9")
|
|
None # French not supported
|
|
"""
|
|
if not accept_language:
|
|
return None
|
|
|
|
# Split by comma to get individual locale entries
|
|
# Format: "locale;q=weight" or just "locale"
|
|
locales = []
|
|
for entry in accept_language.split(","):
|
|
# Remove quality value (;q=0.9) if present
|
|
locale = entry.split(";")[0].strip()
|
|
if locale:
|
|
locales.append(locale)
|
|
|
|
# Check each locale in priority order
|
|
for locale in locales:
|
|
locale_lower = locale.lower()
|
|
|
|
# Try exact match first (e.g., "it-IT")
|
|
if locale_lower in SUPPORTED_LOCALES:
|
|
return locale_lower
|
|
|
|
# Try language code only (e.g., "it" from "it-IT")
|
|
lang_code = locale_lower.split("-")[0]
|
|
if lang_code in SUPPORTED_LOCALES:
|
|
return lang_code
|
|
|
|
return None
|
|
|
|
|
|
async def get_locale(
|
|
request: Request,
|
|
current_user: User | None = Depends(get_optional_current_user),
|
|
) -> str:
|
|
"""
|
|
Detect and return the appropriate locale for the current request.
|
|
|
|
Three-tier fallback system:
|
|
1. **User Preference** (highest priority)
|
|
- If user is authenticated and has a saved locale preference, use it
|
|
- This persists across sessions and devices
|
|
|
|
2. **Accept-Language Header** (second priority)
|
|
- Parse the Accept-Language header from the request
|
|
- Match against supported locales
|
|
- Common for browser requests
|
|
|
|
3. **Default Locale** (fallback)
|
|
- Return "en" (English) if no user preference and no header match
|
|
|
|
Args:
|
|
request: The FastAPI request object (for accessing headers)
|
|
current_user: The current authenticated user (optional)
|
|
|
|
Returns:
|
|
A valid locale code from SUPPORTED_LOCALES (guaranteed to be supported)
|
|
|
|
Examples:
|
|
>>> # Authenticated user with saved preference
|
|
>>> await get_locale(request, user_with_locale_it)
|
|
"it"
|
|
|
|
>>> # Unauthenticated user with Italian browser
|
|
>>> # (request has Accept-Language: it-IT,it;q=0.9)
|
|
>>> await get_locale(request, None)
|
|
"it"
|
|
|
|
>>> # Unauthenticated user with unsupported language
|
|
>>> # (request has Accept-Language: fr-FR,fr;q=0.9)
|
|
>>> await get_locale(request, None)
|
|
"en"
|
|
"""
|
|
# Priority 1: User's saved preference
|
|
if current_user and current_user.locale:
|
|
# Validate that saved locale is still supported
|
|
# (in case SUPPORTED_LOCALES changed after user set preference)
|
|
locale_value = str(current_user.locale)
|
|
if locale_value in SUPPORTED_LOCALES:
|
|
return locale_value
|
|
|
|
# Priority 2: Accept-Language header
|
|
accept_language = request.headers.get("accept-language", "")
|
|
if accept_language:
|
|
detected_locale = parse_accept_language(accept_language)
|
|
if detected_locale:
|
|
return detected_locale
|
|
|
|
# Priority 3: Default fallback
|
|
return DEFAULT_LOCALE
|