read.markets/app/routers/pages.py
Giorgio Gilestro ee8384f1ba i18n: bilingual landing page (EN / IT) with auto-detect routing
Public landing page is now served in English and Italian via path-
prefixed URLs (/en/ and /it/), with the bare / detecting the
visitor's language and 302-ing to the right one.

Routing
-------
* GET / : if authed → dashboard (unchanged). Otherwise the visitor's
  language is resolved from (in priority order) the rtm.lang cookie,
  the Accept-Language header, the cf-ipcountry geolocation header,
  and finally DEFAULT_LANG, then a 302 redirects to /<lang>/.
* GET /en/ + /it/ : render the localised landing template, set the
  rtm.lang cookie (1-year, SameSite=Lax) so the next /-visit goes
  straight to the same translation. Logged-in users with user.lang
  set bypass detection for / (same priority chain — user.lang wins
  on every public surface they touch).

Storage
-------
* app/locales/<lang>.yaml — flat-ish nested copy files. YAML chosen
  so a future translator can edit without touching Python. Strings
  containing inline HTML (<strong>, <em>, <a>) are rendered with the
  Jinja `safe` filter in the template.
* app/services/locales.py — loads at startup, exposes get_locale()
  and detect_public_lang(). Wraps each YAML tree in a small _Dotted
  view so templates can write {{ t.hero.subhead }} (deliberately NOT
  a dict subclass — dict's built-in method names would shadow YAML
  keys like `items`).

Template + chrome
-----------------
* landing.html ported in full to {{ t.<key> }} references.
* public_base.html gets <html lang="…"> + hreflang link tags (en/it/
  x-default) when a route opts in via lang_switch=true. A tiny
  EN | IT link group lands in the public header, only visible on
  surfaces that opt in.
* public.css picks up the small lang-switch widget styles.

Scope (intentionally narrow)
----------------------------
* Only the landing page is localised. Pricing, terms, privacy,
  disclaimer, login, verify all stay English-only for now; their
  header chrome stays English too because localising labels there
  while the linked content is still EN would be a worse mismatch.
* When other public pages get translated, lang_switch=true on those
  routes will surface the same widget there with no template changes.

Tests
-----
* tests/test_locales.py covers YAML load parity (every active
  language has a file), dotted access through the tree, the
  detection precedence chain, and the unknown-locale fallback.

Deps
----
* pyyaml was already in requirements.lock as a transitive but not
  declared. Added to pyproject so it stays pinned as an explicit
  direct dependency.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 17:10:45 +02:00

278 lines
10 KiB
Python

"""HTML page routes — server-rendered Jinja2 with HTMX-driven partial refresh."""
from __future__ import annotations
from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import desc, func, select
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.services.access import is_paid_active, paid_status
from app.services.locales import (
ACTIVE_PUBLIC_LANGS,
DEFAULT_LANG,
detect_public_lang,
get_locale,
)
from app.services.referral_service import assign_code_if_missing
from app.templates_env import templates
# Cookie used to remember an explicit language toggle on public pages.
# Distinct from the in-app user.lang preference (which lives in the
# DB for authenticated users).
_LANG_COOKIE = "rtm.lang"
_LANG_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 # 1 year
def _render_landing(
request: Request, cu: CurrentUser | None, lang: str,
) -> HTMLResponse:
"""Render the localised landing page and stamp the language
cookie so a return visitor lands on the same translation without
another detection pass."""
t = get_locale(lang)
response = templates.TemplateResponse(
request,
"landing.html",
{"cu": cu, "t": t, "lang": lang, "lang_switch": True},
)
# `secure` would block the cookie in local-dev HTTP; rely on the
# reverse proxy to upgrade everything to HTTPS in prod. samesite=Lax
# is the cookie we want for first-party navigation.
response.set_cookie(
_LANG_COOKIE, lang,
max_age=_LANG_COOKIE_MAX_AGE, samesite="lax",
httponly=False,
)
return response
# Router-level auth removed in favour of per-route deps so that `/` can be
# dual-purpose: logged-in users see the dashboard, logged-out visitors see
# the landing page.
router = APIRouter()
@router.get("/", response_class=HTMLResponse)
async def root_page(
request: Request,
cu: CurrentUser | None = Depends(maybe_current_user),
):
"""Dual-purpose root: dashboard when authenticated, otherwise
detect the visitor's language and redirect to the localised
landing URL. Detection considers (in order) the rtm.lang cookie,
the Accept-Language header, the cf-ipcountry geolocation header,
and finally DEFAULT_LANG."""
if cu is None:
lang = detect_public_lang(
cookie_lang=request.cookies.get(_LANG_COOKIE),
accept_language=request.headers.get("accept-language"),
cf_country=request.headers.get("cf-ipcountry"),
user_lang=None,
)
return RedirectResponse(url=f"/{lang}/", status_code=302)
s = get_settings()
groups = load_groups(s.BASELINE_TOML, s.PORTFOLIO_TOML)
return templates.TemplateResponse(
request,
"dashboard.html",
{"groups": list(groups.keys()), "anchor": s.CASSANDRA_ANCHOR_DATE,
"cu": cu, "paid": is_paid_active(cu)},
)
@router.get("/en/", response_class=HTMLResponse)
async def landing_en(
request: Request,
cu: CurrentUser | None = Depends(maybe_current_user),
):
"""English landing. For logged-in users with a non-en `user.lang`
we still serve EN content here because the URL is an explicit
request — same shape as a manual toggle click. The cookie gets
set to en so subsequent /-visits keep them in English."""
return _render_landing(request, cu, lang="en")
@router.get("/it/", response_class=HTMLResponse)
async def landing_it(
request: Request,
cu: CurrentUser | None = Depends(maybe_current_user),
):
"""Italian landing. Same explicit-URL contract as landing_en."""
return _render_landing(request, cu, lang="it")
@router.get(
"/news",
response_class=HTMLResponse,
dependencies=[Depends(require_token)],
)
async def news_page(request: Request):
return templates.TemplateResponse(request, "news.html", {})
@router.get("/upload", dependencies=[Depends(require_token)])
async def upload_page(request: Request):
"""Legacy bookmark — the import widget now lives in /settings."""
return RedirectResponse(url="/settings#import", status_code=302)
async def _resolve_log_date(session: AsyncSession, day: str | None) -> date:
"""If `day` is YYYY-MM-DD use it; else fall back to the date of the most
recent generated log; else today."""
if day:
try:
return datetime.strptime(day, "%Y-%m-%d").date()
except ValueError:
pass
latest = (await session.execute(
select(StrategicLog.generated_at)
.order_by(desc(StrategicLog.generated_at))
.limit(1)
)).scalar_one_or_none()
if latest is not None:
return latest.date() if hasattr(latest, "date") else latest
return datetime.now(timezone.utc).date()
def _log_page_context(target: date, paid: bool, user_lang: str = "en") -> dict:
return {
"selected_iso": target.isoformat(),
"selected_month": target.strftime("%Y-%m"),
"paid": paid,
"user_lang": user_lang,
}
@router.get("/log", response_class=HTMLResponse)
async def log_page(
request: Request,
session: AsyncSession = Depends(get_session),
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), user_lang),
)
@router.get("/log/{day}", response_class=HTMLResponse)
async def log_page_day(
request: Request,
day: str,
session: AsyncSession = Depends(get_session),
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), user_lang),
)
@router.get("/settings", response_class=HTMLResponse)
async def settings_page(
request: Request,
session: AsyncSession = Depends(get_session),
principal: CurrentUser = Depends(require_auth),
):
"""Per-user settings. Shows email, tier, Stripe subscription
management, email-digest preferences, cloud-sync status, portfolio
import, and the referral block (own code + invite link + counts of
pending / converted / actively-credited referrals)."""
user = principal.user
if user is None:
# Bearer-token admin path — no per-user settings to show.
return templates.TemplateResponse(
request, "settings.html",
{"user": None, "invite_url": None,
"pending_count": 0, "converted_count": 0},
)
# Lazily assign a referral code on first visit.
user = await assign_code_if_missing(session, user)
# Stats: how many people have signed up with their code so far, how
# many converted (paid), and how many of those credit grants are
# still live (referrer-side bonus runway not yet expired).
pending_count = (await session.execute(
select(func.count(Referral.id))
.where(Referral.referrer_user_id == user.id)
.where(Referral.converted_at.is_(None))
)).scalar() or 0
converted_count = (await session.execute(
select(func.count(Referral.id))
.where(Referral.referrer_user_id == user.id)
.where(Referral.converted_at.is_not(None))
)).scalar() or 0
# An "active credit" is a conversion whose credit window hasn't yet
# expired for the REFERRED user. We approximate by counting
# conversions in the last REFERRAL_CREDIT_DAYS days — simpler than
# joining against the referred user's credit_until, and matches the
# marketing copy ("45 days of paid access each").
from datetime import timedelta
from app.services.referral_service import REFERRAL_CREDIT_DAYS
credit_horizon = datetime.now(timezone.utc) - timedelta(days=REFERRAL_CREDIT_DAYS)
active_credit_count = (await session.execute(
select(func.count(Referral.id))
.where(Referral.referrer_user_id == user.id)
.where(Referral.credited_at.is_not(None))
.where(Referral.credited_at >= credit_horizon)
)).scalar() or 0
# Days of credit the user themselves has on their own account (from
# any source: referrer bonus, admin grant, refund-as-credit). None
# if no credit or it has already expired.
own_credit_days: int | None = None
if user.credit_until is not None:
cu = user.credit_until
if cu.tzinfo is None:
cu = cu.replace(tzinfo=timezone.utc)
delta = cu - datetime.now(timezone.utc)
if delta.total_seconds() > 0:
own_credit_days = max(1, -(-int(delta.total_seconds()) // 86400))
invite_url = str(request.url_for("login_page")) + f"?ref={user.referral_code}"
last_email_send = (await session.execute(
select(EmailSend)
.where(EmailSend.user_id == user.id)
.order_by(desc(EmailSend.sent_at))
.limit(1)
)).scalar_one_or_none()
# Trial countdown — when the Stripe subscription is in its 14-day
# trial, show "N days remaining" on the tier row. Computed here
# rather than in the template because Jinja's date arithmetic is
# painful, and we already have to handle MariaDB's tz-naive
# round-trip via _aware-style normalisation.
trial_days_remaining: int | None = None
if user.stripe_trial_end_at is not None:
end = user.stripe_trial_end_at
if end.tzinfo is None:
end = end.replace(tzinfo=timezone.utc)
delta = end - datetime.now(timezone.utc)
if delta.total_seconds() > 0:
# Round up so the last hours of the trial still read "1 day".
trial_days_remaining = max(1, -(-int(delta.total_seconds()) // 86400))
return templates.TemplateResponse(
request, "settings.html",
{
"user": user,
"invite_url": invite_url,
"pending_count": int(pending_count),
"converted_count": int(converted_count),
"active_credit_count": int(active_credit_count),
"own_credit_days": own_credit_days,
"paid": paid_status(user),
"last_email_send": last_email_send,
"trial_days_remaining": trial_days_remaining,
},
)