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:
Christian Krakau-Louis
2026-03-14 11:35:43 +01:00
committed by GitHub
5 changed files with 178 additions and 11 deletions
+22 -2
View File
@@ -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
View File
@@ -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)
+8 -2
View File
@@ -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();
+10 -1
View File
@@ -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) {
+93 -5
View File
@@ -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
# ---------------------------------------------------------------------------