fix(i18n): persist language preference server-side, fix dropdown rendering and avatar 404
- Fix language dropdown in base.html by moving tojson data out of HTML attribute into a script tag (prevents quote conflicts breaking Alpine.js) - Fix avatar placeholder 404 by correcting filename reference from avatar-placeholder.svg to default-avatar.svg - Add session hydration from DB in _inject_global_context() so detect_language() uses the stored preference on every request - Sync session and cookie in PATCH /api/profile when language changes - Reload page after language change in profile to reflect new locale - Add tests for session/cookie sync and DB hydration Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
+22
-2
@@ -18,7 +18,7 @@ import logging
|
|||||||
from hashlib import md5
|
from hashlib import md5
|
||||||
from typing import Annotated
|
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 pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -156,7 +156,9 @@ async def get_profile(request: Request, db: DbSession) -> ProfileResponse:
|
|||||||
|
|
||||||
@router.patch("", response_model=ProfileResponse)
|
@router.patch("", response_model=ProfileResponse)
|
||||||
@require_login
|
@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."""
|
"""Update the current user's editable profile settings."""
|
||||||
user_id = _get_user_id(request)
|
user_id = _get_user_id(request)
|
||||||
profile = _get_or_create_profile(db, user_id)
|
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]
|
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
|
# Validate theme
|
||||||
if body.preferred_theme is not None:
|
if body.preferred_theme is not None:
|
||||||
theme = body.preferred_theme.lower().strip()
|
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.auth import require_login # noqa: F401
|
||||||
from app.config import settings
|
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 (
|
from app.utils.i18n import (
|
||||||
SUPPORTED_LANGUAGES,
|
SUPPORTED_LANGUAGES,
|
||||||
detect_language,
|
detect_language,
|
||||||
@@ -47,6 +48,41 @@ templates.env.globals["_"] = lambda key, **kwargs: translate(key, "en", **kwargs
|
|||||||
original_template_response = templates.TemplateResponse
|
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:
|
||||||
|
logger.debug("Could not hydrate language preference for user_id=%s", user_id)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _inject_global_context(ctx: dict) -> None:
|
def _inject_global_context(ctx: dict) -> None:
|
||||||
"""Inject shared global variables into every template context dict."""
|
"""Inject shared global variables into every template context dict."""
|
||||||
ctx.setdefault("version", settings.version)
|
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"
|
# 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)
|
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 ---
|
# --- i18n: detect language and register template helpers ---
|
||||||
current_locale = detect_language(req)
|
current_locale = detect_language(req)
|
||||||
ctx.setdefault("current_locale", current_locale)
|
ctx.setdefault("current_locale", current_locale)
|
||||||
|
|||||||
@@ -216,12 +216,18 @@
|
|||||||
aria-live="polite"></span>
|
aria-live="polite"></span>
|
||||||
</a>
|
</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 -->
|
<!-- Language selector dropdown -->
|
||||||
<div x-data="{
|
<div x-data="{
|
||||||
langOpen: false,
|
langOpen: false,
|
||||||
search: '',
|
search: '',
|
||||||
suggested: {{ suggested_languages | tojson }},
|
suggested: window.__langSuggested,
|
||||||
all: {{ supported_languages | tojson }},
|
all: window.__langAll,
|
||||||
get filtered() {
|
get filtered() {
|
||||||
if (!this.search.trim()) return this.suggested;
|
if (!this.search.trim()) return this.suggested;
|
||||||
const q = this.search.trim().toLowerCase();
|
const q = this.search.trim().toLowerCase();
|
||||||
|
|||||||
@@ -327,7 +327,7 @@
|
|||||||
function profileSettings() {
|
function profileSettings() {
|
||||||
return {
|
return {
|
||||||
// ── State ──────────────────────────────────────────────────────────────
|
// ── State ──────────────────────────────────────────────────────────────
|
||||||
avatarUrl: '/static/images/avatar-placeholder.svg',
|
avatarUrl: '/static/images/default-avatar.svg',
|
||||||
hasCustomAvatar: false,
|
hasCustomAvatar: false,
|
||||||
isLocalUser: false,
|
isLocalUser: false,
|
||||||
saving: false,
|
saving: false,
|
||||||
@@ -349,6 +349,8 @@ function profileSettings() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Init ───────────────────────────────────────────────────────────────
|
// ── Init ───────────────────────────────────────────────────────────────
|
||||||
|
_initialLanguage: '',
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/profile');
|
const res = await fetch('/api/profile');
|
||||||
@@ -361,6 +363,7 @@ function profileSettings() {
|
|||||||
this.form.contact_email = data.contact_email || '';
|
this.form.contact_email = data.contact_email || '';
|
||||||
this.form.preferred_language = data.preferred_language || '';
|
this.form.preferred_language = data.preferred_language || '';
|
||||||
this.form.preferred_theme = data.preferred_theme || 'system';
|
this.form.preferred_theme = data.preferred_theme || 'system';
|
||||||
|
this._initialLanguage = this.form.preferred_language;
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
// Silently ignore — user might not be logged in (rare for this page)
|
// Silently ignore — user might not be logged in (rare for this page)
|
||||||
}
|
}
|
||||||
@@ -378,6 +381,7 @@ function profileSettings() {
|
|||||||
async saveProfile() {
|
async saveProfile() {
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
this._hideBanner();
|
this._hideBanner();
|
||||||
|
const previousLang = this._initialLanguage || '';
|
||||||
try {
|
try {
|
||||||
const csrfToken = this._getCSRFToken();
|
const csrfToken = this._getCSRFToken();
|
||||||
|
|
||||||
@@ -394,6 +398,11 @@ function profileSettings() {
|
|||||||
this._showBanner(data.detail || 'Failed to save settings.', true);
|
this._showBanner(data.detail || 'Failed to save settings.', true);
|
||||||
return;
|
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);
|
this._showBanner('Profile settings saved successfully.', false);
|
||||||
// Update display name across the nav (refresh the avatar/name)
|
// Update display name across the nav (refresh the avatar/name)
|
||||||
if (data.avatar_url) {
|
if (data.avatar_url) {
|
||||||
|
|||||||
@@ -224,9 +224,10 @@ class TestUpdateProfileHandler:
|
|||||||
|
|
||||||
req = MagicMock()
|
req = MagicMock()
|
||||||
req.session = {"user": {"preferred_username": "carol", "email": "carol@example.com"}}
|
req.session = {"user": {"preferred_username": "carol", "email": "carol@example.com"}}
|
||||||
|
resp = MagicMock()
|
||||||
|
|
||||||
body = ProfileUpdateRequest(display_name="Carol Smith")
|
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"
|
assert result.display_name == "Carol Smith"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -236,11 +237,31 @@ class TestUpdateProfileHandler:
|
|||||||
|
|
||||||
req = MagicMock()
|
req = MagicMock()
|
||||||
req.session = {"user": {"preferred_username": "dave", "email": "dave@example.com"}}
|
req.session = {"user": {"preferred_username": "dave", "email": "dave@example.com"}}
|
||||||
|
resp = MagicMock()
|
||||||
|
|
||||||
body = ProfileUpdateRequest(preferred_language="de")
|
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"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_updates_theme(self, prof_session):
|
async def test_updates_theme(self, prof_session):
|
||||||
"""update_profile updates preferred_theme."""
|
"""update_profile updates preferred_theme."""
|
||||||
@@ -248,9 +269,10 @@ class TestUpdateProfileHandler:
|
|||||||
|
|
||||||
req = MagicMock()
|
req = MagicMock()
|
||||||
req.session = {"user": {"preferred_username": "eve", "email": "eve@example.com"}}
|
req.session = {"user": {"preferred_username": "eve", "email": "eve@example.com"}}
|
||||||
|
resp = MagicMock()
|
||||||
|
|
||||||
body = ProfileUpdateRequest(preferred_theme="light")
|
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"
|
assert result.preferred_theme == "light"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -260,10 +282,11 @@ class TestUpdateProfileHandler:
|
|||||||
|
|
||||||
req = MagicMock()
|
req = MagicMock()
|
||||||
req.session = {"user": {"preferred_username": "frank", "email": "frank@example.com"}}
|
req.session = {"user": {"preferred_username": "frank", "email": "frank@example.com"}}
|
||||||
|
resp = MagicMock()
|
||||||
|
|
||||||
body = ProfileUpdateRequest(preferred_language="xx")
|
body = ProfileUpdateRequest(preferred_language="xx")
|
||||||
with pytest.raises(HTTPException) as exc:
|
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
|
assert exc.value.status_code == 422
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -273,13 +296,78 @@ class TestUpdateProfileHandler:
|
|||||||
|
|
||||||
req = MagicMock()
|
req = MagicMock()
|
||||||
req.session = {"user": {"preferred_username": "grace", "email": "grace@example.com"}}
|
req.session = {"user": {"preferred_username": "grace", "email": "grace@example.com"}}
|
||||||
|
resp = MagicMock()
|
||||||
|
|
||||||
body = ProfileUpdateRequest(preferred_theme="rainbow")
|
body = ProfileUpdateRequest(preferred_theme="rainbow")
|
||||||
with pytest.raises(HTTPException) as exc:
|
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
|
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
|
# Unit tests — POST /api/profile/avatar handler
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user