models: add User.lang + StrategicLogTranslation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-27 16:52:04 +02:00
parent 7683f82820
commit 9423fa81b7
2 changed files with 93 additions and 0 deletions

View file

@ -120,6 +120,37 @@ class StrategicLog(Base):
cost_usd: Mapped[float | None] = mapped_column(Float) cost_usd: Mapped[float | None] = mapped_column(Float)
class StrategicLogTranslation(Base):
"""Cached translation of a single StrategicLog row.
Populated by ai_log_job after the English row is committed: one
row per (log_id, lang) combination. The /log endpoint serves the
matching row when available and falls back to the English source
when no row exists yet (e.g. translation failed or the language
was added after the log was generated).
No user attribution the cache is shared. Setting `lang` on a
user just selects which (already-translated) variant they see.
"""
__tablename__ = "strategic_log_translations"
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
log_id: Mapped[int] = mapped_column(
_PK, ForeignKey("strategic_logs.id", ondelete="CASCADE"), nullable=False,
)
lang: Mapped[str] = mapped_column(String(8), nullable=False)
content_md: Mapped[str] = mapped_column(Text, nullable=False)
generated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow,
)
llm_model: Mapped[str | None] = mapped_column(String(64))
llm_cost_usd: Mapped[float | None] = mapped_column(Float)
__table_args__ = (
UniqueConstraint("log_id", "lang", name="uq_slt_log_lang"),
)
class IndicatorSummary(Base): class IndicatorSummary(Base):
"""Short AI-generated read for one indicator group, regenerated hourly. """Short AI-generated read for one indicator group, regenerated hourly.
The latest row per group_name is what the dashboard renders.""" The latest row per group_name is what the dashboard renders."""
@ -189,6 +220,13 @@ class User(Base):
# NULL = use INTERMEDIATE at render time. Server-side mirror of the # NULL = use INTERMEDIATE at render time. Server-side mirror of the
# dashboard tone, decoupled because the dashboard pref is localStorage. # dashboard tone, decoupled because the dashboard pref is localStorage.
digest_tone: Mapped[str | None] = mapped_column(String(16)) digest_tone: Mapped[str | None] = mapped_column(String(16))
# Preferred language for AI-generated content (strategic log,
# digest emails, portfolio commentary). Default 'en'. The settings
# PATCH endpoint validates against ACTIVE_LANGUAGES in
# app/services/i18n.py before writing.
lang: Mapped[str] = mapped_column(
String(8), nullable=False, default="en", server_default="en",
)
# Polar (MoR) linkage — populated by the polar_webhook handler the # Polar (MoR) linkage — populated by the polar_webhook handler the
# first time we see a subscription/order event for the user. The # first time we see a subscription/order event for the user. The
# customer id is the stable join key; the subscription id is what # customer id is the stable join key; the subscription id is what

View file

@ -0,0 +1,55 @@
"""Integration tests: model surface, ai_log_job translation fan-out,
route-level localized fetch, settings PATCH validation."""
from __future__ import annotations
import pytest
def _build_session_factory(tmp_path):
"""Per-test sqlite engine + factory. Mirrors test_referral_conversion.py."""
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.db import Base
import app.models # noqa: F401 — registers models on Base.metadata
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/loc.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async def _setup():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
return engine, factory, _setup
def test_user_has_lang_column_with_default_en():
from sqlalchemy import inspect
from app.models import User
cols = {c.name: c for c in inspect(User).columns}
assert "lang" in cols
assert cols["lang"].nullable is False
# SQLAlchemy default may be a callable or a literal — check both.
default = cols["lang"].default
assert default is not None
if hasattr(default, "arg"):
assert default.arg == "en"
def test_strategic_log_translation_model_columns():
from sqlalchemy import inspect
from app.models import StrategicLogTranslation
cols = {c.name: c for c in inspect(StrategicLogTranslation).columns}
assert "log_id" in cols
assert "lang" in cols
assert "content_md" in cols
assert "generated_at" in cols
assert "llm_model" in cols
assert "llm_cost_usd" in cols
assert cols["log_id"].nullable is False
assert cols["lang"].nullable is False
assert cols["content_md"].nullable is False