Add user locale preference support and locale detection logic
- Introduced `locale` field in user model and schemas with BCP 47 format validation. - Created Alembic migration to add `locale` column to the `users` table with indexing for better query performance. - Implemented `get_locale` dependency to detect locale using user preference, `Accept-Language` header, or default to English. - Added extensive tests for locale validation, dependency logic, and fallback handling. - Enhanced documentation and comments detailing the locale detection workflow and SUPPORTED_LOCALES configuration.
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
"""add user locale preference column
|
||||
|
||||
Revision ID: c8e9f3a2d1b4
|
||||
Revises: b76c725fc3cf
|
||||
Create Date: 2025-11-17 18:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c8e9f3a2d1b4"
|
||||
down_revision: str | None = "b76c725fc3cf"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add locale column to users table
|
||||
# VARCHAR(10) supports BCP 47 format (e.g., "en", "it", "en-US", "it-IT")
|
||||
# Nullable: NULL means "not set yet", will use Accept-Language header fallback
|
||||
# Indexed: For analytics queries and filtering by locale
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("locale", sa.String(length=10), nullable=True)
|
||||
)
|
||||
|
||||
# Create index on locale column for performance
|
||||
op.create_index(
|
||||
"ix_users_locale",
|
||||
"users",
|
||||
["locale"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove locale index and column
|
||||
op.drop_index("ix_users_locale", table_name="users")
|
||||
op.drop_column("users", "locale")
|
||||
131
backend/app/api/dependencies/locale.py
Normal file
131
backend/app/api/dependencies/locale.py
Normal file
@@ -0,0 +1,131 @@
|
||||
# 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)
|
||||
if current_user.locale in SUPPORTED_LOCALES:
|
||||
return current_user.locale
|
||||
|
||||
# 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
|
||||
@@ -16,6 +16,7 @@ class User(Base, UUIDMixin, TimestampMixin):
|
||||
is_active = Column(Boolean, default=True, nullable=False, index=True)
|
||||
is_superuser = Column(Boolean, default=False, nullable=False, index=True)
|
||||
preferences = Column(JSONB)
|
||||
locale = Column(String(10), nullable=True, index=True)
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
# Relationships
|
||||
|
||||
@@ -37,6 +37,13 @@ class UserUpdate(BaseModel):
|
||||
phone_number: str | None = None
|
||||
password: str | None = None
|
||||
preferences: dict[str, Any] | None = None
|
||||
locale: str | None = Field(
|
||||
None,
|
||||
max_length=10,
|
||||
pattern=r'^[a-z]{2}(-[A-Z]{2})?$',
|
||||
description="User's preferred locale (BCP 47 format: en, it, en-US, it-IT)",
|
||||
examples=["en", "it", "en-US", "it-IT"]
|
||||
)
|
||||
is_active: bool | None = (
|
||||
None # Changed default from True to None to avoid unintended updates
|
||||
)
|
||||
@@ -55,6 +62,24 @@ class UserUpdate(BaseModel):
|
||||
return v
|
||||
return validate_password_strength(v)
|
||||
|
||||
@field_validator("locale")
|
||||
@classmethod
|
||||
def validate_locale(cls, v: str | None) -> str | None:
|
||||
"""Validate locale against supported locales."""
|
||||
if v is None:
|
||||
return v
|
||||
# Only support English and Italian for template showcase
|
||||
# Note: Locales stored in lowercase for case-insensitive matching
|
||||
SUPPORTED_LOCALES = {"en", "it", "en-us", "en-gb", "it-it"}
|
||||
# Normalize to lowercase for comparison and storage
|
||||
v_lower = v.lower()
|
||||
if v_lower not in SUPPORTED_LOCALES:
|
||||
raise ValueError(
|
||||
f"Unsupported locale '{v}'. Supported locales: {sorted(SUPPORTED_LOCALES)}"
|
||||
)
|
||||
# Return normalized lowercase version for consistency
|
||||
return v_lower
|
||||
|
||||
@field_validator("is_superuser")
|
||||
@classmethod
|
||||
def prevent_superuser_modification(cls, v: bool | None) -> bool | None:
|
||||
@@ -70,6 +95,7 @@ class UserInDB(UserBase):
|
||||
is_superuser: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
locale: str | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -80,6 +106,7 @@ class UserResponse(UserBase):
|
||||
is_superuser: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
locale: str | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user