diff --git a/backend/app/alembic/versions/c8e9f3a2d1b4_add_user_locale_preference_column.py b/backend/app/alembic/versions/c8e9f3a2d1b4_add_user_locale_preference_column.py new file mode 100644 index 0000000..1b83358 --- /dev/null +++ b/backend/app/alembic/versions/c8e9f3a2d1b4_add_user_locale_preference_column.py @@ -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") diff --git a/backend/app/api/dependencies/locale.py b/backend/app/api/dependencies/locale.py new file mode 100644 index 0000000..7c3d25e --- /dev/null +++ b/backend/app/api/dependencies/locale.py @@ -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 diff --git a/backend/app/models/user.py b/backend/app/models/user.py index c6604e5..d6d6965 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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 diff --git a/backend/app/schemas/users.py b/backend/app/schemas/users.py index ee5994e..729c22d 100755 --- a/backend/app/schemas/users.py +++ b/backend/app/schemas/users.py @@ -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) diff --git a/backend/tests/api/dependencies/test_locale_dependencies.py b/backend/tests/api/dependencies/test_locale_dependencies.py new file mode 100644 index 0000000..fa8da4c --- /dev/null +++ b/backend/tests/api/dependencies/test_locale_dependencies.py @@ -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 diff --git a/backend/tests/schemas/test_user_schemas.py b/backend/tests/schemas/test_user_schemas.py index df7561e..a04dd33 100755 --- a/backend/tests/schemas/test_user_schemas.py +++ b/backend/tests/schemas/test_user_schemas.py @@ -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