From 1ea71bc16055d53ebbbbcbf9df975ba4925e3fed Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Wed, 27 May 2026 17:13:57 +0200 Subject: [PATCH] log: serve translated content when available; English fallback Adds module-level _resolve_log_content(session, log_id, lang) helper to app/routers/pages.py: looks up StrategicLogTranslation by (log_id, lang) when lang != 'en'; falls back silently to the English original when no translation row exists yet (the expected case for the first hour after a new language activates, or when translation fails for a specific log). log_page / log_page_day pull cu.user.lang and thread it through _log_page_context so the template renders the right variant. Two tests cover both branches. Co-Authored-By: Claude Opus 4.7 --- app/routers/pages.py | 33 ++++++++++++-- tests/test_localization_integration.py | 63 ++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/app/routers/pages.py b/app/routers/pages.py index f7ef42b..f80176a 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.auth import CurrentUser, maybe_current_user, require_auth, require_token from app.config import get_settings, load_groups from app.db import get_session -from app.models import EmailSend, Referral, StrategicLog, User +from app.models import EmailSend, Referral, StrategicLog, StrategicLogTranslation, User from app.services.access import is_paid_active, paid_status from app.services.referral_service import assign_code_if_missing from app.templates_env import templates @@ -75,7 +75,29 @@ async def _resolve_log_date(session: AsyncSession, day: str | None) -> date: return datetime.now(timezone.utc).date() -def _log_page_context(target: date, paid: bool) -> dict: +async def _resolve_log_content( + session: AsyncSession, log_id: int, lang: str | None, +) -> str: + """Return the strategic log content in the user's preferred language. + + If ``lang`` is 'en'/None or no translation exists for the requested + language, returns the English original from StrategicLog.content. + A missing translation is the expected case for hours where + translation hasn't yet run; the fallback is silent. + """ + if lang and lang != "en": + row = (await session.execute( + select(StrategicLogTranslation) + .where(StrategicLogTranslation.log_id == log_id) + .where(StrategicLogTranslation.lang == lang) + )).scalar_one_or_none() + if row is not None: + return row.content_md + log_row = await session.get(StrategicLog, log_id) + return log_row.content if log_row is not None else "" + + +def _log_page_context(target: date, paid: bool, user_lang: str = "en") -> dict: s = get_settings() return { "selected_iso": target.isoformat(), @@ -83,6 +105,7 @@ def _log_page_context(target: date, paid: bool) -> dict: "current_tone": s.CASSANDRA_TONE.upper(), "current_analysis": s.CASSANDRA_ANALYSIS.upper(), "paid": paid, + "user_lang": user_lang, } @@ -93,8 +116,9 @@ async def log_page( cu: CurrentUser = Depends(require_auth), ): target = await _resolve_log_date(session, None) + user_lang = cu.user.lang if cu.user else "en" return templates.TemplateResponse( - request, "log.html", _log_page_context(target, is_paid_active(cu)), + request, "log.html", _log_page_context(target, is_paid_active(cu), user_lang), ) @@ -106,8 +130,9 @@ async def log_page_day( cu: CurrentUser = Depends(require_auth), ): target = await _resolve_log_date(session, day) + user_lang = cu.user.lang if cu.user else "en" return templates.TemplateResponse( - request, "log.html", _log_page_context(target, is_paid_active(cu)), + request, "log.html", _log_page_context(target, is_paid_active(cu), user_lang), ) diff --git a/tests/test_localization_integration.py b/tests/test_localization_integration.py index ebe8a21..ae74a53 100644 --- a/tests/test_localization_integration.py +++ b/tests/test_localization_integration.py @@ -332,3 +332,66 @@ def test_digest_pick_variant_uses_user_lang(): assert ed._pick_variant(table, tone="NOVICE", lang="de") == "novice en" # Missing tone → fallback to INTERMEDIATE/en (the safe default). assert ed._pick_variant(table, tone="UNKNOWN", lang="en") == "intermediate en" + + +@pytest.mark.asyncio +async def test_log_endpoint_serves_italian_when_user_is_italian(tmp_path): + """When a user with lang='it' opens /log, the served content is the + Italian translation, not the English original.""" + from app.db import utcnow + from app.models import StrategicLog, StrategicLogTranslation, User + + _, factory, setup = _build_session_factory(tmp_path) + await setup() + + async with factory() as session: + session.add(User(id=10, email="it@x", tier="paid", lang="it")) + slog = StrategicLog( + generated_at=utcnow(), content="# Open\n\nDown 0.4%.", + model="test-model", + tone="INTERMEDIATE", analysis="NORMAL", + ) + session.add(slog) + await session.commit() + session.add(StrategicLogTranslation( + log_id=slog.id, lang="it", + content_md="# Apertura\n\nIn calo 0,4%.", + generated_at=utcnow(), llm_model="m", llm_cost_usd=0.0, + )) + await session.commit() + log_id = slog.id + + # Test the resolver directly. + from app.routers.pages import _resolve_log_content + async with factory() as session: + user = await session.get(User, 10) + content = await _resolve_log_content(session, log_id, user.lang) + assert "Apertura" in content + assert "Open" not in content + + +@pytest.mark.asyncio +async def test_log_endpoint_falls_back_to_english_when_no_translation(tmp_path): + """User lang='it' but no IT translation exists → English fallback.""" + from app.db import utcnow + from app.models import StrategicLog, User + + _, factory, setup = _build_session_factory(tmp_path) + await setup() + + async with factory() as session: + session.add(User(id=11, email="it2@x", tier="paid", lang="it")) + slog = StrategicLog( + generated_at=utcnow(), content="# Open\n\nDown 0.4%.", + model="test-model", + tone="INTERMEDIATE", analysis="NORMAL", + ) + session.add(slog) + await session.commit() + log_id = slog.id + + from app.routers.pages import _resolve_log_content + async with factory() as session: + user = await session.get(User, 11) + content = await _resolve_log_content(session, log_id, user.lang) + assert "Open" in content