Merge pull request #645 from christianlouis/copilot/fix-language-selection-bug
fix(i18n): persist language preference server-side, fix dropdown rendering and avatar 404
This commit is contained in:
+22
-2
@@ -18,7 +18,7 @@ import logging
|
||||
from hashlib import md5
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -156,7 +156,9 @@ async def get_profile(request: Request, db: DbSession) -> ProfileResponse:
|
||||
|
||||
@router.patch("", response_model=ProfileResponse)
|
||||
@require_login
|
||||
async def update_profile(body: ProfileUpdateRequest, request: Request, db: DbSession) -> ProfileResponse:
|
||||
async def update_profile(
|
||||
body: ProfileUpdateRequest, request: Request, response: Response, db: DbSession
|
||||
) -> ProfileResponse:
|
||||
"""Update the current user's editable profile settings."""
|
||||
user_id = _get_user_id(request)
|
||||
profile = _get_or_create_profile(db, user_id)
|
||||
@@ -171,6 +173,24 @@ async def update_profile(body: ProfileUpdateRequest, request: Request, db: DbSes
|
||||
)
|
||||
profile.preferred_language = lang or None # type: ignore[assignment]
|
||||
|
||||
# Keep session and cookie in sync so detect_language() picks up
|
||||
# the new preference immediately (without a DB round-trip).
|
||||
if hasattr(request, "session"):
|
||||
if lang:
|
||||
request.session["preferred_language"] = lang
|
||||
else:
|
||||
request.session.pop("preferred_language", None)
|
||||
if lang:
|
||||
response.set_cookie(
|
||||
key="docuelevate_lang",
|
||||
value=lang,
|
||||
max_age=30 * 24 * 60 * 60,
|
||||
httponly=False,
|
||||
samesite="lax",
|
||||
)
|
||||
else:
|
||||
response.delete_cookie(key="docuelevate_lang")
|
||||
|
||||
# Validate theme
|
||||
if body.preferred_theme is not None:
|
||||
theme = body.preferred_theme.lower().strip()
|
||||
|
||||
+45
-1
@@ -11,7 +11,8 @@ from sqlalchemy.orm import Session # noqa: F401
|
||||
|
||||
from app.auth import require_login # noqa: F401
|
||||
from app.config import settings
|
||||
from app.database import get_db # noqa: F401
|
||||
from app.database import SessionLocal, get_db # noqa: F401
|
||||
from app.models import UserProfile
|
||||
from app.utils.i18n import (
|
||||
SUPPORTED_LANGUAGES,
|
||||
detect_language,
|
||||
@@ -47,6 +48,41 @@ templates.env.globals["_"] = lambda key, **kwargs: translate(key, "en", **kwargs
|
||||
original_template_response = templates.TemplateResponse
|
||||
|
||||
|
||||
def _hydrate_language_from_db(request: Request, session_user: object) -> None:
|
||||
"""Load the user's preferred language from the DB into the session.
|
||||
|
||||
Called once per session when ``preferred_language`` is not yet in the
|
||||
session. A lightweight DB query fetches the stored preference so that
|
||||
:func:`detect_language` picks it up from the session on all subsequent
|
||||
requests without further DB access.
|
||||
"""
|
||||
from app.utils.i18n import SUPPORTED_LANGUAGE_CODES
|
||||
|
||||
user_id: str | None = None
|
||||
if isinstance(session_user, dict):
|
||||
user_id = (
|
||||
session_user.get("sub")
|
||||
or session_user.get("preferred_username")
|
||||
or session_user.get("email")
|
||||
or session_user.get("id")
|
||||
)
|
||||
elif isinstance(session_user, str):
|
||||
user_id = session_user
|
||||
|
||||
if not user_id:
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
|
||||
if profile and profile.preferred_language and profile.preferred_language in SUPPORTED_LANGUAGE_CODES:
|
||||
request.session["preferred_language"] = profile.preferred_language
|
||||
except Exception: # noqa: BLE001 — intentionally broad; DB may be temporarily unavailable
|
||||
logger.debug("Could not hydrate language preference for user_id=%s", user_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _inject_global_context(ctx: dict) -> None:
|
||||
"""Inject shared global variables into every template context dict."""
|
||||
ctx.setdefault("version", settings.version)
|
||||
@@ -71,6 +107,14 @@ def _inject_global_context(ctx: dict) -> None:
|
||||
# When auth is disabled every visitor is effectively "logged in"
|
||||
ctx.setdefault("is_logged_in", not getattr(settings, "auth_enabled", True) or session_user is not None)
|
||||
|
||||
# --- Hydrate session language from DB (once per session) ---
|
||||
# If the session doesn't have a preferred_language yet but the user
|
||||
# is logged in, load the stored preference from the database so that
|
||||
# detect_language() picks it up from the session on this and all
|
||||
# subsequent requests.
|
||||
if hasattr(req, "session") and "preferred_language" not in req.session and session_user is not None:
|
||||
_hydrate_language_from_db(req, session_user)
|
||||
|
||||
# --- i18n: detect language and register template helpers ---
|
||||
current_locale = detect_language(req)
|
||||
ctx.setdefault("current_locale", current_locale)
|
||||
|
||||
@@ -216,12 +216,18 @@
|
||||
aria-live="polite"></span>
|
||||
</a>
|
||||
|
||||
<!-- Language selector data (kept outside the HTML attribute to avoid quote conflicts with tojson) -->
|
||||
<script>
|
||||
window.__langSuggested = {{ suggested_languages | tojson }};
|
||||
window.__langAll = {{ supported_languages | tojson }};
|
||||
</script>
|
||||
|
||||
<!-- Language selector dropdown -->
|
||||
<div x-data="{
|
||||
langOpen: false,
|
||||
search: '',
|
||||
suggested: {{ suggested_languages | tojson }},
|
||||
all: {{ supported_languages | tojson }},
|
||||
suggested: window.__langSuggested,
|
||||
all: window.__langAll,
|
||||
get filtered() {
|
||||
if (!this.search.trim()) return this.suggested;
|
||||
const q = this.search.trim().toLowerCase();
|
||||
|
||||
@@ -327,7 +327,7 @@
|
||||
function profileSettings() {
|
||||
return {
|
||||
// ── State ──────────────────────────────────────────────────────────────
|
||||
avatarUrl: '/static/images/avatar-placeholder.svg',
|
||||
avatarUrl: '/static/images/default-avatar.svg',
|
||||
hasCustomAvatar: false,
|
||||
isLocalUser: false,
|
||||
saving: false,
|
||||
@@ -349,6 +349,8 @@ function profileSettings() {
|
||||
},
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────────
|
||||
_initialLanguage: '',
|
||||
|
||||
async init() {
|
||||
try {
|
||||
const res = await fetch('/api/profile');
|
||||
@@ -361,6 +363,7 @@ function profileSettings() {
|
||||
this.form.contact_email = data.contact_email || '';
|
||||
this.form.preferred_language = data.preferred_language || '';
|
||||
this.form.preferred_theme = data.preferred_theme || 'system';
|
||||
this._initialLanguage = this.form.preferred_language;
|
||||
} catch (_e) {
|
||||
// Silently ignore — user might not be logged in (rare for this page)
|
||||
}
|
||||
@@ -378,6 +381,7 @@ function profileSettings() {
|
||||
async saveProfile() {
|
||||
this.saving = true;
|
||||
this._hideBanner();
|
||||
const previousLang = this._initialLanguage || '';
|
||||
try {
|
||||
const csrfToken = this._getCSRFToken();
|
||||
|
||||
@@ -394,6 +398,11 @@ function profileSettings() {
|
||||
this._showBanner(data.detail || 'Failed to save settings.', true);
|
||||
return;
|
||||
}
|
||||
// If the UI language changed, reload so the page renders in the new locale
|
||||
if (this.form.preferred_language !== previousLang) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
this._showBanner('Profile settings saved successfully.', false);
|
||||
// Update display name across the nav (refresh the avatar/name)
|
||||
if (data.avatar_url) {
|
||||
|
||||
@@ -224,9 +224,10 @@ class TestUpdateProfileHandler:
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "carol", "email": "carol@example.com"}}
|
||||
resp = MagicMock()
|
||||
|
||||
body = ProfileUpdateRequest(display_name="Carol Smith")
|
||||
result = await update_profile(body, req, prof_session)
|
||||
result = await update_profile(body, req, resp, prof_session)
|
||||
assert result.display_name == "Carol Smith"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -236,11 +237,31 @@ class TestUpdateProfileHandler:
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "dave", "email": "dave@example.com"}}
|
||||
resp = MagicMock()
|
||||
|
||||
body = ProfileUpdateRequest(preferred_language="de")
|
||||
result = await update_profile(body, req, prof_session)
|
||||
result = await update_profile(body, req, resp, prof_session)
|
||||
assert result.preferred_language == "de"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updates_language_syncs_session_and_cookie(self, prof_session):
|
||||
"""update_profile syncs language to session and cookie."""
|
||||
from app.api.profile import ProfileUpdateRequest, update_profile
|
||||
|
||||
session = {"user": {"preferred_username": "dave2", "email": "dave2@example.com"}}
|
||||
req = MagicMock()
|
||||
req.session = session
|
||||
resp = MagicMock()
|
||||
|
||||
body = ProfileUpdateRequest(preferred_language="fr")
|
||||
await update_profile(body, req, resp, prof_session)
|
||||
|
||||
assert session["preferred_language"] == "fr"
|
||||
resp.set_cookie.assert_called_once()
|
||||
cookie_kwargs = resp.set_cookie.call_args
|
||||
assert cookie_kwargs.kwargs["key"] == "docuelevate_lang"
|
||||
assert cookie_kwargs.kwargs["value"] == "fr"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updates_theme(self, prof_session):
|
||||
"""update_profile updates preferred_theme."""
|
||||
@@ -248,9 +269,10 @@ class TestUpdateProfileHandler:
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "eve", "email": "eve@example.com"}}
|
||||
resp = MagicMock()
|
||||
|
||||
body = ProfileUpdateRequest(preferred_theme="light")
|
||||
result = await update_profile(body, req, prof_session)
|
||||
result = await update_profile(body, req, resp, prof_session)
|
||||
assert result.preferred_theme == "light"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -260,10 +282,11 @@ class TestUpdateProfileHandler:
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "frank", "email": "frank@example.com"}}
|
||||
resp = MagicMock()
|
||||
|
||||
body = ProfileUpdateRequest(preferred_language="xx")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_profile(body, req, prof_session)
|
||||
await update_profile(body, req, resp, prof_session)
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -273,13 +296,78 @@ class TestUpdateProfileHandler:
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "grace", "email": "grace@example.com"}}
|
||||
resp = MagicMock()
|
||||
|
||||
body = ProfileUpdateRequest(preferred_theme="rainbow")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_profile(body, req, prof_session)
|
||||
await update_profile(body, req, resp, prof_session)
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests — _hydrate_language_from_db (views/base.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestHydrateLanguageFromDb:
|
||||
"""Tests for session hydration of preferred_language from DB."""
|
||||
|
||||
def test_hydrates_language_into_session(self, prof_session):
|
||||
"""When DB has a preferred_language, it should be set in the session."""
|
||||
from app.views.base import _hydrate_language_from_db
|
||||
|
||||
# Create a profile with a preferred_language
|
||||
profile = UserProfile(user_id="hydrate_user", preferred_language="fr")
|
||||
prof_session.add(profile)
|
||||
prof_session.commit()
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {}
|
||||
session_user = {"preferred_username": "hydrate_user"}
|
||||
|
||||
# Patch SessionLocal to return our test session
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch("app.views.base.SessionLocal", return_value=prof_session):
|
||||
_hydrate_language_from_db(req, session_user)
|
||||
|
||||
assert req.session.get("preferred_language") == "fr"
|
||||
|
||||
def test_no_hydration_when_no_db_preference(self, prof_session):
|
||||
"""When DB has no preferred_language, session should remain empty."""
|
||||
from app.views.base import _hydrate_language_from_db
|
||||
|
||||
profile = UserProfile(user_id="no_pref_user")
|
||||
prof_session.add(profile)
|
||||
prof_session.commit()
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {}
|
||||
session_user = {"preferred_username": "no_pref_user"}
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch("app.views.base.SessionLocal", return_value=prof_session):
|
||||
_hydrate_language_from_db(req, session_user)
|
||||
|
||||
assert "preferred_language" not in req.session
|
||||
|
||||
def test_no_hydration_when_no_user_id(self, prof_session):
|
||||
"""When session_user has no identifiable user_id, do nothing."""
|
||||
from app.views.base import _hydrate_language_from_db
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {}
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch("app.views.base.SessionLocal", return_value=prof_session):
|
||||
_hydrate_language_from_db(req, {})
|
||||
|
||||
assert "preferred_language" not in req.session
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests — POST /api/profile/avatar handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user