Two unrelated features bundled because they ship together and share
migration 0028.
Strategic-log feedback (thumb up/down):
- New strategic_log_feedback table with UNIQUE(log_id, user_id) so each
user has one vote per log, flippable in place (up -> down -> clear).
UI shows aggregate counts only.
- app/services/log_feedback.py: set_vote, get_counts, sign/verify
feedback tokens (same itsdangerous pattern as auth.sign_pending,
30-day TTL for email links).
- POST /api/log/{id}/feedback: web vote, auth required, returns counts
+ the requesting user's own vote.
- GET /feedback?token=...&vote=...: email-link target, no auth, signed
token encodes (user, log, vote), renders feedback_thanks.html.
- partials/log.html: thumbs row below content, JS-driven swap via the
POST endpoint. Dashboard latest-log card and /log page both render
this partial via htmx, so the buttons appear in all three surfaces.
- digest emails: a "How was today's read?" row above the unsub footer,
with signed-token URLs against the latest StrategicLog at send time.
Plain-text fallback included.
Reviewer self-score (0-10):
- _SYSTEM_PROMPT asks for an integer score with anchors (10 exemplary,
5 borderline, 0 unfit). Verdict gains score: int | None.
- Deterministic-layer hits get score=0 (hard rule, no nuance);
error rows get None; LLM rows get the model's score clamped 0..10.
- ReviewerVerdict.score, StrategicLog.reviewer_score, and
IndicatorSummary.reviewer_score all new SMALLINT NULL columns.
- ai_log_job + indicator_summary_job persist verdict.score onto their
content rows when committing the row alongside content.
Tests:
- tests/test_strategic_log_feedback.py: vote, flip, clear, aggregate
across users, invalid vote, token round-trip + tamper + garbage +
'clear' not signable for email path.
- tests/test_output_review.py: score parsing, clamping (>10, <0),
missing/non-numeric -> None, deterministic-layer score=0.
Full suite: 427 passed (was 412), 5 skipped, no regressions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
332 lines
12 KiB
Python
332 lines
12 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("/feedback", response_class=HTMLResponse)
|
|
async def log_feedback_via_token(
|
|
request: Request,
|
|
token: str,
|
|
vote: str | None = None,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Email-link target for thumb up/down votes on a strategic log.
|
|
|
|
The signed token encodes (user_id, log_id, intended_vote). The query
|
|
param ``vote`` is informational (lets the URL be self-describing in
|
|
the inbox); the canonical vote is what's in the token. If the two
|
|
disagree the token wins.
|
|
|
|
Renders a small thank-you confirmation. No auth required — the token
|
|
is the auth-equivalent for this single side-effecting action."""
|
|
from app.services.log_feedback import (
|
|
FeedbackError, set_vote, verify_feedback_token,
|
|
)
|
|
|
|
payload = verify_feedback_token(token)
|
|
if payload is None:
|
|
return templates.TemplateResponse(
|
|
request, "feedback_thanks.html",
|
|
{"ok": False, "message": "This link has expired or is invalid.",
|
|
"log_id": None, "vote": None},
|
|
status_code=400,
|
|
)
|
|
|
|
try:
|
|
counts = await set_vote(
|
|
session,
|
|
log_id=payload["log_id"],
|
|
user_id=payload["user_id"],
|
|
vote=payload["vote"],
|
|
)
|
|
except FeedbackError as e:
|
|
return templates.TemplateResponse(
|
|
request, "feedback_thanks.html",
|
|
{"ok": False, "message": str(e), "log_id": payload["log_id"],
|
|
"vote": payload["vote"]},
|
|
status_code=400,
|
|
)
|
|
|
|
return templates.TemplateResponse(
|
|
request, "feedback_thanks.html",
|
|
{"ok": True,
|
|
"vote": payload["vote"],
|
|
"log_id": payload["log_id"],
|
|
"counts": counts,
|
|
"message": None},
|
|
)
|
|
|
|
|
|
@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,
|
|
},
|
|
)
|