diff --git a/app/models.py b/app/models.py index 665a8cd..13ba1ab 100644 --- a/app/models.py +++ b/app/models.py @@ -120,6 +120,37 @@ class StrategicLog(Base): 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): """Short AI-generated read for one indicator group, regenerated hourly. 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 # dashboard tone, decoupled because the dashboard pref is localStorage. 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 # first time we see a subscription/order event for the user. The # customer id is the stable join key; the subscription id is what diff --git a/tests/test_localization_integration.py b/tests/test_localization_integration.py new file mode 100644 index 0000000..1079d66 --- /dev/null +++ b/tests/test_localization_integration.py @@ -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