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:
Felipe Cardoso
2025-11-17 19:47:50 +01:00
parent 3001484948
commit 68e04a911a
6 changed files with 665 additions and 1 deletions

View File

@@ -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")

View 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

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1,317 @@
# tests/api/dependencies/test_locale_dependencies.py
import uuid
from unittest.mock import MagicMock
import pytest
import pytest_asyncio
from app.api.dependencies.locale import (
DEFAULT_LOCALE,
SUPPORTED_LOCALES,
get_locale,
parse_accept_language,
)
from app.core.auth import get_password_hash
from app.models.user import User
class TestParseAcceptLanguage:
"""Tests for parse_accept_language helper function"""
def test_parse_empty_header(self):
"""Test with empty Accept-Language header"""
result = parse_accept_language("")
assert result is None
def test_parse_none_header(self):
"""Test with None Accept-Language header"""
result = parse_accept_language(None)
assert result is None
def test_parse_italian_exact_match(self):
"""Test parsing Italian with exact match"""
result = parse_accept_language("it-IT,it;q=0.9,en;q=0.8")
assert result == "it-it"
def test_parse_italian_language_code_only(self):
"""Test parsing Italian with only language code"""
result = parse_accept_language("it,en;q=0.8")
assert result == "it"
def test_parse_english_us(self):
"""Test parsing English (US)"""
result = parse_accept_language("en-US,en;q=0.9")
assert result == "en-us"
def test_parse_english_language_code(self):
"""Test parsing English with only language code"""
result = parse_accept_language("en")
assert result == "en"
def test_parse_unsupported_language(self):
"""Test parsing unsupported language (French)"""
result = parse_accept_language("fr-FR,fr;q=0.9,de;q=0.8")
assert result is None
def test_parse_mixed_supported_unsupported(self):
"""Test with mix of supported and unsupported, should pick first supported"""
# French first (unsupported), then Italian (supported)
result = parse_accept_language("fr-FR,fr;q=0.9,it;q=0.8")
assert result == "it"
def test_parse_quality_values(self):
"""Test that quality values are respected (first = highest priority)"""
# English has higher implicit priority (no q value means q=1.0)
result = parse_accept_language("en,it;q=0.9")
assert result == "en"
def test_parse_complex_header(self):
"""Test complex Accept-Language header with multiple locales"""
result = parse_accept_language(
"it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7,fr;q=0.6"
)
assert result == "it-it"
def test_parse_whitespace_handling(self):
"""Test that whitespace is handled correctly"""
result = parse_accept_language(" it-IT , it ; q=0.9 , en ; q=0.8 ")
assert result == "it-it"
def test_parse_case_insensitive(self):
"""Test that locale matching is case-insensitive"""
result = parse_accept_language("IT-it,EN-us;q=0.9")
# Should normalize to lowercase
assert result == "it-it"
def test_parse_fallback_to_language_code(self):
"""Test fallback from region-specific to language code"""
# it-CH (Switzerland) not supported, but "it" is
result = parse_accept_language("it-CH,en;q=0.8")
assert result == "it"
@pytest_asyncio.fixture
async def async_user_with_locale_en(async_test_db):
"""Async fixture to create a user with 'en' locale preference"""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
user = User(
id=uuid.uuid4(),
email="user_en@example.com",
password_hash=get_password_hash("password123"),
first_name="English",
last_name="User",
is_active=True,
is_superuser=False,
locale="en",
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
@pytest_asyncio.fixture
async def async_user_with_locale_it(async_test_db):
"""Async fixture to create a user with 'it' locale preference"""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
user = User(
id=uuid.uuid4(),
email="user_it@example.com",
password_hash=get_password_hash("password123"),
first_name="Italian",
last_name="User",
is_active=True,
is_superuser=False,
locale="it",
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
@pytest_asyncio.fixture
async def async_user_without_locale(async_test_db):
"""Async fixture to create a user without locale preference"""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
user = User(
id=uuid.uuid4(),
email="user_no_locale@example.com",
password_hash=get_password_hash("password123"),
first_name="No",
last_name="Locale",
is_active=True,
is_superuser=False,
locale=None,
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
class TestGetLocale:
"""Tests for get_locale dependency"""
@pytest.mark.asyncio
async def test_locale_from_user_preference_en(self, async_user_with_locale_en):
"""Test locale detection from authenticated user's saved preference (en)"""
# Mock request with no Accept-Language header
mock_request = MagicMock()
mock_request.headers = {}
result = await get_locale(
request=mock_request, current_user=async_user_with_locale_en
)
assert result == "en"
@pytest.mark.asyncio
async def test_locale_from_user_preference_it(self, async_user_with_locale_it):
"""Test locale detection from authenticated user's saved preference (it)"""
# Mock request with no Accept-Language header
mock_request = MagicMock()
mock_request.headers = {}
result = await get_locale(
request=mock_request, current_user=async_user_with_locale_it
)
assert result == "it"
@pytest.mark.asyncio
async def test_user_preference_overrides_accept_language(
self, async_user_with_locale_en
):
"""Test that user preference takes precedence over Accept-Language header"""
# Mock request with Italian Accept-Language, but user has English preference
mock_request = MagicMock()
mock_request.headers = {"accept-language": "it-IT,it;q=0.9"}
result = await get_locale(
request=mock_request, current_user=async_user_with_locale_en
)
# Should return user preference, not Accept-Language
assert result == "en"
@pytest.mark.asyncio
async def test_locale_from_accept_language_header(
self, async_user_without_locale
):
"""Test locale detection from Accept-Language header when user has no preference"""
# Mock request with Italian Accept-Language (it-IT has highest priority)
mock_request = MagicMock()
mock_request.headers = {"accept-language": "it-IT,it;q=0.9,en;q=0.8"}
result = await get_locale(
request=mock_request, current_user=async_user_without_locale
)
# Should return "it-it" (normalized from "it-IT", the first/highest priority locale)
assert result == "it-it"
@pytest.mark.asyncio
async def test_locale_from_accept_language_unauthenticated(self):
"""Test locale detection from Accept-Language header for unauthenticated user"""
# Mock request with Italian Accept-Language (it-IT has highest priority)
mock_request = MagicMock()
mock_request.headers = {"accept-language": "it-IT,it;q=0.9,en;q=0.8"}
result = await get_locale(request=mock_request, current_user=None)
# Should return "it-it" (normalized from "it-IT", the first/highest priority locale)
assert result == "it-it"
@pytest.mark.asyncio
async def test_default_locale_no_user_no_header(self):
"""Test fallback to default locale when no user and no Accept-Language header"""
# Mock request with no Accept-Language header
mock_request = MagicMock()
mock_request.headers = {}
result = await get_locale(request=mock_request, current_user=None)
assert result == DEFAULT_LOCALE
assert result == "en"
@pytest.mark.asyncio
async def test_default_locale_unsupported_language(self):
"""Test fallback to default when Accept-Language has only unsupported languages"""
# Mock request with French (unsupported)
mock_request = MagicMock()
mock_request.headers = {"accept-language": "fr-FR,fr;q=0.9,de;q=0.8"}
result = await get_locale(request=mock_request, current_user=None)
assert result == DEFAULT_LOCALE
assert result == "en"
@pytest.mark.asyncio
async def test_validate_supported_locale_in_db(self, async_user_with_locale_it):
"""Test that saved locale is validated against SUPPORTED_LOCALES"""
# This test verifies the locale in DB is actually supported
assert async_user_with_locale_it.locale in SUPPORTED_LOCALES
mock_request = MagicMock()
mock_request.headers = {}
result = await get_locale(
request=mock_request, current_user=async_user_with_locale_it
)
assert result == "it"
assert result in SUPPORTED_LOCALES
@pytest.mark.asyncio
async def test_accept_language_case_variations(self):
"""Test different case variations in Accept-Language header"""
# All return values are lowercase for consistency
test_cases = [
("it-IT,en;q=0.8", "it-it"),
("IT-it,en;q=0.8", "it-it"),
("en-US,it;q=0.8", "en-us"),
("EN,it;q=0.8", "en"),
]
for accept_lang, expected in test_cases:
mock_request = MagicMock()
mock_request.headers = {"accept-language": accept_lang}
result = await get_locale(request=mock_request, current_user=None)
assert result == expected
@pytest.mark.asyncio
async def test_accept_language_with_quality_values(self):
"""Test Accept-Language parsing respects quality values (priority)"""
# English has implicit q=1.0, Italian has q=0.9
mock_request = MagicMock()
mock_request.headers = {"accept-language": "en,it;q=0.9"}
result = await get_locale(request=mock_request, current_user=None)
# Should return English (higher priority)
assert result == "en"
@pytest.mark.asyncio
async def test_supported_locales_constant(self):
"""Test that SUPPORTED_LOCALES contains expected locales"""
# Note: SUPPORTED_LOCALES uses lowercase for case-insensitive matching
assert "en" in SUPPORTED_LOCALES
assert "it" in SUPPORTED_LOCALES
assert "en-us" in SUPPORTED_LOCALES
assert "en-gb" in SUPPORTED_LOCALES
assert "it-it" in SUPPORTED_LOCALES
# Verify total count matches implementation plan (5 locales for EN/IT showcase)
assert len(SUPPORTED_LOCALES) == 5
@pytest.mark.asyncio
async def test_default_locale_constant(self):
"""Test that DEFAULT_LOCALE is English"""
assert DEFAULT_LOCALE == "en"
assert DEFAULT_LOCALE in SUPPORTED_LOCALES

View File

@@ -4,7 +4,7 @@ import re
import pytest
from pydantic import ValidationError
from app.schemas.users import UserBase, UserCreate
from app.schemas.users import UserBase, UserCreate, UserUpdate
class TestPhoneNumberValidation:
@@ -224,3 +224,149 @@ class TestPhoneNumberValidation:
password="Password123!",
phone_number="invalid-number",
)
class TestLocaleValidation:
"""Tests for locale validation in user schemas"""
def test_valid_locale_en(self):
"""Test that 'en' locale is accepted"""
user = UserUpdate(locale="en")
assert user.locale == "en"
def test_valid_locale_it(self):
"""Test that 'it' locale is accepted"""
user = UserUpdate(locale="it")
assert user.locale == "it"
def test_valid_locale_en_us(self):
"""Test that 'en-US' locale is accepted and normalized to lowercase"""
user = UserUpdate(locale="en-US")
assert user.locale == "en-us" # Normalized to lowercase
def test_valid_locale_en_gb(self):
"""Test that 'en-GB' locale is accepted and normalized to lowercase"""
user = UserUpdate(locale="en-GB")
assert user.locale == "en-gb" # Normalized to lowercase
def test_valid_locale_it_it(self):
"""Test that 'it-IT' locale is accepted and normalized to lowercase"""
user = UserUpdate(locale="it-IT")
assert user.locale == "it-it" # Normalized to lowercase
def test_none_locale(self):
"""Test that None is accepted as a valid value (optional locale)"""
user = UserUpdate(locale=None)
assert user.locale is None
def test_locale_not_provided(self):
"""Test that locale can be omitted entirely"""
user = UserUpdate(first_name="Test")
assert user.locale is None
def test_unsupported_locale_french(self):
"""Test that unsupported locale 'fr' is rejected"""
with pytest.raises(ValidationError) as exc_info:
UserUpdate(locale="fr")
# Verify error message mentions unsupported locale
assert "Unsupported locale" in str(exc_info.value)
def test_unsupported_locale_german(self):
"""Test that unsupported locale 'de' is rejected"""
with pytest.raises(ValidationError) as exc_info:
UserUpdate(locale="de")
assert "Unsupported locale" in str(exc_info.value)
def test_unsupported_locale_spanish(self):
"""Test that unsupported locale 'es' is rejected"""
with pytest.raises(ValidationError) as exc_info:
UserUpdate(locale="es")
assert "Unsupported locale" in str(exc_info.value)
def test_unsupported_locale_region_specific(self):
"""Test that unsupported region-specific locale is rejected"""
with pytest.raises(ValidationError) as exc_info:
UserUpdate(locale="it-CH") # Italian (Switzerland) - not in supported list
assert "Unsupported locale" in str(exc_info.value)
def test_invalid_locale_format_no_dash(self):
"""Test that invalid format (no dash between components) is rejected"""
with pytest.raises(ValidationError):
UserUpdate(locale="enus") # Should be "en-US"
def test_invalid_locale_format_invalid_pattern(self):
"""Test that completely invalid format is rejected"""
invalid_locales = [
"en-us", # Region code must be uppercase
"EN", # Language code must be lowercase
"en-", # Incomplete
"-US", # Incomplete
"e", # Too short
"eng", # Language code too long
"en-USA", # Region code too long
"123", # Numbers not allowed
"en_US", # Underscore not allowed (must be dash)
]
for invalid_locale in invalid_locales:
with pytest.raises(ValidationError):
UserUpdate(locale=invalid_locale)
def test_empty_string_locale(self):
"""Test that empty string is rejected"""
with pytest.raises(ValidationError):
UserUpdate(locale="")
def test_locale_with_whitespace(self):
"""Test that locale with whitespace is rejected"""
with pytest.raises(ValidationError):
UserUpdate(locale=" en ")
def test_locale_max_length(self):
"""Test that locale exceeding max length (10 chars) is rejected"""
with pytest.raises(ValidationError):
UserUpdate(locale="en-US-extra") # 12 characters, exceeds max_length=10
def test_locale_in_user_update_with_other_fields(self):
"""Test locale validation works when combined with other fields"""
# Valid locale with other fields
user = UserUpdate(
first_name="Mario",
last_name="Rossi",
locale="it"
)
assert user.locale == "it"
assert user.first_name == "Mario"
# Invalid locale with other valid fields should still fail
with pytest.raises(ValidationError):
UserUpdate(
first_name="Pierre",
last_name="Dupont",
locale="fr" # Unsupported
)
def test_supported_locales_list(self):
"""Test all supported locales are accepted and normalized"""
# Input locales (mixed case)
input_locales = ["en", "it", "en-US", "en-GB", "it-IT"]
# Expected output (normalized to lowercase)
expected_outputs = ["en", "it", "en-us", "en-gb", "it-it"]
for input_locale, expected_output in zip(input_locales, expected_outputs):
user = UserUpdate(locale=input_locale)
assert user.locale == expected_output
def test_locale_error_message_shows_supported_locales(self):
"""Test that error message lists supported locales"""
with pytest.raises(ValidationError) as exc_info:
UserUpdate(locale="fr")
error_str = str(exc_info.value)
# Should mention supported locales in error
assert "en" in error_str or "it" in error_str
assert "Supported locales" in error_str