Replaces the passive "by signing in you agree…" paragraph on /login with
an active un-pre-ticked checkbox over three short statements that the
user actively accepts. Each acceptance is recorded against a code-level
version pointer, so a future wording revision bumps the version and
prompts every user again.
- New app/legal.py::ACKNOWLEDGEMENT_VERSION (currently 1).
- New UserAcknowledgement model + migration 0027 (user_id FK CASCADE,
version, lang, accepted_at, composite index on user_id+version).
- auth_service: has_acknowledged_current() and record_acknowledgement()
helpers; POST /login validates the checkbox, falls through to a 400
with the localised error otherwise, and writes a row iff the user has
no current-version row (so existing-already-accepted users don't
produce duplicates).
- GET /login: language detection mirrors the landing's
detect_public_lang(); ?lang=en|it overrides; stamps the rtm.lang
cookie; passes the locale dict + version into the template.
- login.html: EN/IT pill, localised lede/banner/legal footer, required
checkbox in an acknowledgement block, hidden lang+ack_version fields.
Submit disabled until the box is ticked (UX polish; the server check
is what carries weight).
- locales/{en,it}.yaml: new auth.ack.* section with TODO(legal) marker.
Wording matches the brief's substance pending solicitor sign-off.
- tests/test_signup_acknowledgement.py: 10 tests (EN + IT rejection,
one row per acceptance, displayed-lang recorded, idempotent on
current version, version-bump writes new row, helper unit tests).
The acknowledgement strengthens the user-civil-claim vector — combined
with the liability cap and the Ltd, it makes "I was misled into thinking
this was advice" much harder to argue. It does NOT move the regulatory
perimeter, which is governed by the content discipline shipped in
47dce1a. Belt-and-braces, not a substitute.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
600 lines
29 KiB
Python
600 lines
29 KiB
Python
"""SQLAlchemy models for Cassandra.
|
|
|
|
Schema rationale lives in /home/gg/.claude/plans/ok-i-think-this-tidy-lake.md.
|
|
All datetimes are tz-aware UTC (see app.db.utcnow).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, date
|
|
|
|
from sqlalchemy import (
|
|
JSON,
|
|
BigInteger,
|
|
Boolean,
|
|
Date,
|
|
DateTime,
|
|
Float,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
LargeBinary,
|
|
SmallInteger,
|
|
String,
|
|
Text,
|
|
UniqueConstraint,
|
|
text,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from app.db import Base, utcnow
|
|
|
|
|
|
# Portable autoincrement primary-key type. SQLite only treats `INTEGER
|
|
# PRIMARY KEY` as a ROWID alias (the bit that auto-fills); plain BIGINT
|
|
# requires explicit values, which breaks our async tests. `with_variant`
|
|
# emits INTEGER on SQLite and keeps BIGINT everywhere else.
|
|
_PK = BigInteger().with_variant(Integer(), "sqlite")
|
|
|
|
|
|
class Quote(Base):
|
|
__tablename__ = "quotes"
|
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
|
symbol: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
source: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
label: Mapped[str] = mapped_column(String(128), default="")
|
|
group_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
price: Mapped[float | None] = mapped_column(Float)
|
|
currency: Mapped[str | None] = mapped_column(String(8))
|
|
as_of: Mapped[str | None] = mapped_column(String(16)) # provider date string
|
|
changes: Mapped[dict | None] = mapped_column(JSON) # {"1d": x, "1m": y, ...}
|
|
error: Mapped[str | None] = mapped_column(String(255))
|
|
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
|
|
__table_args__ = (
|
|
Index("ix_quotes_symbol_fetched", "symbol", "fetched_at"),
|
|
Index("ix_quotes_group", "group_name"),
|
|
)
|
|
|
|
|
|
class QuoteDaily(Base):
|
|
"""Daily rollup — sparkline source. PK on (symbol, date)."""
|
|
__tablename__ = "quotes_daily"
|
|
symbol: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
date: Mapped[date] = mapped_column(Date, primary_key=True)
|
|
close: Mapped[float | None] = mapped_column(Float)
|
|
high: Mapped[float | None] = mapped_column(Float)
|
|
low: Mapped[float | None] = mapped_column(Float)
|
|
source: Mapped[str] = mapped_column(String(32))
|
|
|
|
|
|
class Headline(Base):
|
|
__tablename__ = "headlines"
|
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
|
source: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
category: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
title: Mapped[str] = mapped_column(String(512), nullable=False)
|
|
url: Mapped[str] = mapped_column(String(1024), nullable=False)
|
|
published_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
fingerprint: Mapped[str] = mapped_column(String(40), nullable=False) # sha1 of normalised title
|
|
# Semantic content tags from app.services.news_tagging. NULL = not yet
|
|
# tagged; the next news_job run picks it up. Each entry is one of the
|
|
# values in news_tagging.TAG_VOCABULARY.
|
|
tags: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("fingerprint", name="uq_headlines_fingerprint"),
|
|
Index("ix_headlines_published", "published_at"),
|
|
Index("ix_headlines_category_published", "category", "published_at"),
|
|
)
|
|
|
|
|
|
class Feed(Base):
|
|
"""Persisted feed state; bootstrapped from default.toml on first startup."""
|
|
__tablename__ = "feeds"
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
category: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
url: Mapped[str] = mapped_column(String(1024), nullable=False)
|
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
consecutive_failures: Mapped[int] = mapped_column(Integer, default=0)
|
|
last_success_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("category", "name", name="uq_feeds_cat_name"),
|
|
)
|
|
|
|
|
|
class StrategicLog(Base):
|
|
__tablename__ = "strategic_logs"
|
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
|
generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
|
model: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
anchor_date: Mapped[str | None] = mapped_column(String(16))
|
|
prompt_version: Mapped[int] = mapped_column(Integer, default=1)
|
|
tone: Mapped[str | None] = mapped_column(String(16)) # NOVICE|INTERMEDIATE|PRO
|
|
analysis: Mapped[str | None] = mapped_column(String(16)) # DRY|SPECULATIVE
|
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
|
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
|
|
completion_tokens: Mapped[int | None] = mapped_column(Integer)
|
|
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(
|
|
BigInteger().with_variant(Integer(), "sqlite"),
|
|
ForeignKey("strategic_logs.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
lang: Mapped[str] = mapped_column(String(8), nullable=False)
|
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
|
generated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, default=utcnow,
|
|
)
|
|
model: Mapped[str | None] = mapped_column(String(64))
|
|
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
|
|
completion_tokens: Mapped[int | None] = mapped_column(Integer)
|
|
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."""
|
|
__tablename__ = "indicator_summaries"
|
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
|
group_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
model: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
tone: Mapped[str | None] = mapped_column(String(16))
|
|
analysis: Mapped[str | None] = mapped_column(String(16))
|
|
prompt_version: Mapped[int] = mapped_column(Integer, default=1)
|
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
|
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
|
|
completion_tokens: Mapped[int | None] = mapped_column(Integer)
|
|
cost_usd: Mapped[float | None] = mapped_column(Float)
|
|
|
|
__table_args__ = (Index("ix_indsumm_group_generated", "group_name", "generated_at"),)
|
|
|
|
|
|
class IndicatorSummaryTranslation(Base):
|
|
"""Cached translation of a single IndicatorSummary row.
|
|
|
|
Same pattern as StrategicLogTranslation: one row per
|
|
(summary_id, lang). Populated by indicator_summary_job after the
|
|
English row is committed. The dashboard / indicators endpoints
|
|
swap in the matching translation when a user with a non-en
|
|
lang preference loads them, falling back silently to the English
|
|
source when no row exists yet.
|
|
"""
|
|
__tablename__ = "indicator_summary_translations"
|
|
|
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
|
summary_id: Mapped[int] = mapped_column(
|
|
BigInteger().with_variant(Integer(), "sqlite"),
|
|
ForeignKey("indicator_summaries.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
lang: Mapped[str] = mapped_column(String(8), nullable=False)
|
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
|
generated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, default=utcnow,
|
|
)
|
|
model: Mapped[str | None] = mapped_column(String(64))
|
|
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
|
|
completion_tokens: Mapped[int | None] = mapped_column(Integer)
|
|
cost_usd: Mapped[float | None] = mapped_column(Float)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("summary_id", "lang", name="uq_ist_summary_lang"),
|
|
)
|
|
|
|
|
|
class AICall(Base):
|
|
"""Cost ledger for OpenRouter calls. Feeds the monthly cap check."""
|
|
__tablename__ = "ai_calls"
|
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
|
called_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
|
model: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
|
|
completion_tokens: Mapped[int | None] = mapped_column(Integer)
|
|
cost_usd: Mapped[float | None] = mapped_column(Float)
|
|
status: Mapped[str] = mapped_column(String(16), default="ok")
|
|
error: Mapped[str | None] = mapped_column(String(512))
|
|
|
|
|
|
class ReviewerVerdict(Base):
|
|
"""Append-only audit log of every output-reviewer verdict.
|
|
|
|
Both layers (deterministic lexicon + LLM) write here, pass and fail.
|
|
'Here is a complete log showing fail-closed automated review on every
|
|
published item' is the regulator-facing answer; this table is what backs
|
|
that claim. See app/services/output_review.py."""
|
|
__tablename__ = "reviewer_verdicts"
|
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=utcnow, index=True,
|
|
)
|
|
# Caller-supplied tag — "log", "indicator", "indicator_aggregate", "chat",
|
|
# "portfolio", "digest", or None for unattributed.
|
|
surface: Mapped[str | None] = mapped_column(String(32), index=True)
|
|
# The candidate text being reviewed. Truncated upstream to avoid pathological
|
|
# writes; Text type accepts whatever fits.
|
|
candidate_text: Mapped[str] = mapped_column(Text, nullable=False)
|
|
clean: Mapped[bool] = mapped_column(Boolean, nullable=False, index=True)
|
|
reason: Mapped[str | None] = mapped_column(String(255))
|
|
# Which layer fired: "deterministic" | "llm" | "error".
|
|
layer: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
# LLM-layer model id, nullable for deterministic / error rows.
|
|
model: Mapped[str | None] = mapped_column(String(64))
|
|
|
|
|
|
class UserAcknowledgement(Base):
|
|
"""Append-only record of a user actively accepting the sign-up
|
|
acknowledgement at ``/login``. See app/legal.py for the version
|
|
constant and app/locales/{en,it}.yaml::auth.ack for the canonical text.
|
|
|
|
One row per (user, version) acceptance. The value of this table is
|
|
evidentiary: 'user X accepted the version-N text on date D, in the
|
|
language they were shown'. A future wording revision bumps
|
|
ACKNOWLEDGEMENT_VERSION; existing users see the prompt again on next
|
|
sign-in and a new row is written at the new version."""
|
|
__tablename__ = "user_acknowledgements"
|
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
|
user_id: Mapped[int] = mapped_column(
|
|
Integer,
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
version: Mapped[int] = mapped_column(SmallInteger, nullable=False)
|
|
# Language the user actually saw — what they accepted is keyed to it.
|
|
lang: Mapped[str] = mapped_column(String(8), nullable=False)
|
|
accepted_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=utcnow, nullable=False, index=True,
|
|
)
|
|
|
|
__table_args__ = (
|
|
Index("ix_user_acknowledgements_user_version", "user_id", "version"),
|
|
)
|
|
|
|
|
|
# Portfolio / PortfolioSnapshot / Position removed in Phase G —
|
|
# holdings live in the browser, the server stores only the anonymous
|
|
# ticker universe + public market data.
|
|
|
|
|
|
class User(Base):
|
|
"""A user account. Authentication is e-mail-only via one-time codes
|
|
(see EmailOTP) — no passwords. Possessing an active session cookie
|
|
means the user proved control of `email` at session creation time, so
|
|
a separate `email_verified` flag would be redundant."""
|
|
__tablename__ = "users"
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
tier: Mapped[str] = mapped_column(String(16), default="free") # free | paid | enterprise
|
|
settings_json: Mapped[dict | None] = mapped_column(JSON)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
# Referral code is unique + URL-safe; generated on first need rather
|
|
# than at row creation so existing accounts get one the next time
|
|
# they hit /settings.
|
|
referral_code: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
|
referred_by_user_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
|
|
)
|
|
# Paid-tier credit window. Null = no credit. When set and > now(),
|
|
# the user gets paid-tier features regardless of `tier`. Populated
|
|
# by admin CLI (manual grants) and by referral conversion (45 days
|
|
# per converted referral, both parties).
|
|
credit_until: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True,
|
|
)
|
|
email_digest_opt_in: Mapped[bool] = mapped_column(
|
|
Boolean, nullable=False, default=True, server_default=text("1"),
|
|
)
|
|
# 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",
|
|
index=True,
|
|
)
|
|
# 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
|
|
# we cancel against from /settings.
|
|
polar_customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
polar_subscription_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
# Stripe (merchant-on-record for read.markets). Populated on the
|
|
# first checkout.session.completed event via client_reference_id;
|
|
# used thereafter to match incoming subscription/invoice events
|
|
# back to this row.
|
|
stripe_customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
stripe_subscription_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
# Set when a subscription is in `trialing` state — drives the
|
|
# "Free trial — N days remaining" hint on /settings. Cleared on
|
|
# subscription.revoked or when status transitions out of trialing.
|
|
stripe_trial_end_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True,
|
|
)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("email", name="uq_users_email"),
|
|
UniqueConstraint("referral_code", name="uq_users_referral_code"),
|
|
UniqueConstraint("polar_customer_id", name="uq_users_polar_customer"),
|
|
UniqueConstraint("stripe_customer_id", name="uq_users_stripe_customer"),
|
|
)
|
|
|
|
|
|
class PortfolioSync(Base):
|
|
"""Opt-in encrypted backup of a user's pie. Stored as opaque bytes:
|
|
the client encrypts the pie with a PIN-derived key (AES-GCM), and the
|
|
server wraps that ciphertext again with a per-user key derived from
|
|
PORTFOLIO_SYNC_PEPPER + user_id (also AES-GCM). A DB-only leak yields
|
|
nothing usable without the env-only pepper; a pepper-only leak still
|
|
leaves the attacker brute-forcing the PIN through PBKDF2(600k).
|
|
|
|
One row per user. Absent row = sync disabled for that user. The
|
|
fetch_window_* fields drive a sliding-window rate limit on GET so the
|
|
pepper-leak threat model can't degenerate into an unthrottled brute
|
|
force against the inner PBKDF2."""
|
|
__tablename__ = "portfolio_sync"
|
|
user_id: Mapped[int] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True,
|
|
)
|
|
outer_ciphertext: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
|
outer_nonce: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
|
version: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
fetch_window_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
fetch_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
# 8-byte HKDF fingerprint of the pepper that wrapped this row. A
|
|
# mismatch against the current pepper means the row is orphaned
|
|
# (pepper was rotated) — distinct from genuine GCM corruption.
|
|
pepper_fp: Mapped[bytes | None] = mapped_column(LargeBinary(length=8))
|
|
|
|
|
|
class Referral(Base):
|
|
"""One row per captured (referrer, referred) pair. Created at signup
|
|
when the new user supplied a valid `?ref=<code>`. The conversion
|
|
fields (`converted_at`, `credited_at`) stay null until the referred
|
|
user makes their first paid subscription — the Stripe webhook calls
|
|
``referral_service.convert_referral`` to fill them in and extend
|
|
both parties' ``credit_until``."""
|
|
__tablename__ = "referrals"
|
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
|
referrer_user_id: Mapped[int] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
|
|
)
|
|
referred_user_id: Mapped[int] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
|
|
)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
converted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
credited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("referred_user_id", name="uq_referrals_referred"),
|
|
Index("ix_referrals_referrer", "referrer_user_id"),
|
|
)
|
|
|
|
|
|
class EmailOTP(Base):
|
|
"""One-time codes for email verification. The plaintext 6-digit code is
|
|
sent in the email; we store an argon2 hash, expiry, attempt count, and
|
|
a used_at timestamp so a single code can't be reused or brute-forced."""
|
|
__tablename__ = "email_otps"
|
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
|
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
code_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
|
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
purpose: Mapped[str] = mapped_column(String(16), default="signup")
|
|
|
|
__table_args__ = (Index("ix_otps_email_created", "email", "created_at"),)
|
|
|
|
|
|
class InstrumentMap(Base):
|
|
"""Maps T212's tickers/shortnames to Yahoo Finance tickers so we can
|
|
refresh prices via Yahoo after a user uploads a T212 pie CSV.
|
|
|
|
Synced periodically from T212's /equity/metadata/instruments endpoint
|
|
via the admin's read-only API key. Each row is one T212 listing.
|
|
Multiple rows can share a shortName (e.g. SHEL on LSE in GBX vs
|
|
SHEL on NYSE in USD); the resolver picks the right one per user."""
|
|
__tablename__ = "instrument_map"
|
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
|
t212_ticker: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
t212_shortname: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
yahoo_ticker: Mapped[str | None] = mapped_column(String(32))
|
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
currency: Mapped[str | None] = mapped_column(String(8))
|
|
isin: Mapped[str | None] = mapped_column(String(16))
|
|
instrument_type: Mapped[str | None] = mapped_column(String(16))
|
|
manual: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
last_verified_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("t212_ticker", name="uq_imap_t212_ticker"),
|
|
Index("ix_imap_shortname", "t212_shortname"),
|
|
Index("ix_imap_isin", "isin"),
|
|
)
|
|
|
|
|
|
class TickerUniverse(Base):
|
|
"""The set of public tickers Cassandra is currently tracking. Populated
|
|
as the union of all users' holdings, *without user attribution* — once
|
|
a ticker is in the universe, the row carries no signal as to who put
|
|
it there. The /api/universe endpoint returns the entire set (gzipped)
|
|
to every authenticated client, so the request body itself doesn't leak
|
|
which tickers belong to which user.
|
|
|
|
Eviction policy: passive aging. last_referenced_at is bumped whenever
|
|
the ticker appears in /api/portfolio/parse or /api/analyze. A nightly
|
|
cron prunes rows older than UNIVERSE_EVICTION_TTL (60 days).
|
|
"""
|
|
__tablename__ = "ticker_universe"
|
|
yahoo_ticker: Mapped[str] = mapped_column(String(32), primary_key=True)
|
|
currency: Mapped[str | None] = mapped_column(String(8))
|
|
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
last_referenced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
|
|
__table_args__ = (Index("ix_universe_last_ref", "last_referenced_at"),)
|
|
|
|
|
|
class JobRun(Base):
|
|
"""One row per scheduled-job invocation; powers /api/health + the ops footer."""
|
|
__tablename__ = "job_runs"
|
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
|
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
status: Mapped[str] = mapped_column(String(16), default="running") # running|success|failed
|
|
error: Mapped[str | None] = mapped_column(Text)
|
|
items_written: Mapped[int | None] = mapped_column(Integer)
|
|
|
|
__table_args__ = (Index("ix_jobruns_name_started", "name", "started_at"),)
|
|
|
|
|
|
class EmailSend(Base):
|
|
"""Audit row per digest email send. Used for idempotency (don't send
|
|
twice on the same UTC day) and for surfacing 'last delivery' on the
|
|
Settings page."""
|
|
__tablename__ = "email_sends"
|
|
|
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
|
user_id: Mapped[int] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
|
|
)
|
|
kind: Mapped[str] = mapped_column(String(16), nullable=False) # "daily" | "weekly"
|
|
sent_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=utcnow, nullable=False,
|
|
)
|
|
status: Mapped[str] = mapped_column(String(16), nullable=False) # "sent" | "skipped" | "error"
|
|
error: Mapped[str | None] = mapped_column(String(255))
|
|
|
|
__table_args__ = (
|
|
Index("ix_email_sends_user_kind_sent", "user_id", "kind", "sent_at"),
|
|
)
|
|
|
|
|
|
class PolarEvent(Base):
|
|
"""Audit + idempotency table for inbound Polar (MoR) webhook deliveries.
|
|
|
|
Polar uses the Standard Webhooks spec, which guarantees each delivery
|
|
carries a unique `webhook-id` header. We store that ID under a UNIQUE
|
|
constraint so a replay of the same event is a no-op (the INSERT fails
|
|
and the handler returns the prior result).
|
|
|
|
`processed_at` distinguishes "delivered and handled" from "delivered
|
|
but the handler crashed mid-flight" — the latter rows are what an
|
|
operator looks at when investigating a stuck subscription."""
|
|
__tablename__ = "polar_events"
|
|
|
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
|
event_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
received_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=utcnow, nullable=False,
|
|
)
|
|
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
error: Mapped[str | None] = mapped_column(Text)
|
|
# Raw JSON body, kept for forensics. Truncated to 16 KiB to keep
|
|
# one bad request from blowing up the row.
|
|
payload: Mapped[str] = mapped_column(Text, nullable=False)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("event_id", name="uq_polar_events_event_id"),
|
|
Index("ix_polar_events_type_received", "event_type", "received_at"),
|
|
)
|
|
|
|
|
|
class StripeEvent(Base):
|
|
"""Audit + idempotency table for inbound Stripe webhook deliveries.
|
|
|
|
Same shape and purpose as PolarEvent — Stripe's `event.id` plays the
|
|
same role as Standard Webhooks' `webhook-id`. We keep the tables
|
|
distinct (rather than a single 'webhook_events' table) so an
|
|
operator can look at the audit trail per processor without filtering
|
|
on a `source` column."""
|
|
__tablename__ = "stripe_events"
|
|
|
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
|
event_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
received_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=utcnow, nullable=False,
|
|
)
|
|
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
error: Mapped[str | None] = mapped_column(Text)
|
|
payload: Mapped[str] = mapped_column(Text, nullable=False)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("event_id", name="uq_stripe_events_event_id"),
|
|
Index("ix_stripe_events_type_received", "event_type", "received_at"),
|
|
)
|
|
|
|
|
|
class CsvFormatTemplate(Base):
|
|
"""Cached column-mapping for a single broker CSV format.
|
|
|
|
Populated on the first upload of a previously-unseen format via the
|
|
LLM-fallback parser. Subsequent uploads of the same format
|
|
(identified by ``fingerprint``, a sha256 of the normalised header
|
|
row) replay ``mapping`` deterministically with no LLM call.
|
|
|
|
The table holds the actual ``headers`` and one anonymous ``sample_row``
|
|
from the originating upload — there is no ``user_id`` column, no link
|
|
back to the uploader. The sample exists so the operator has concrete
|
|
material to look at when hand-writing future native parsers; the
|
|
system never auto-generates or modifies parser code from this data.
|
|
"""
|
|
__tablename__ = "csv_format_templates"
|
|
|
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
|
fingerprint: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
|
headers: Mapped[list[str]] = mapped_column(JSON, nullable=False)
|
|
sample_row: Mapped[list[str]] = mapped_column(JSON, nullable=False)
|
|
mapping: Mapped[dict[str, str | None]] = mapped_column(JSON, nullable=False)
|
|
preamble_rows: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
delimiter: Mapped[str] = mapped_column(String(1), nullable=False, default=",")
|
|
broker_label: Mapped[str | None] = mapped_column(String(128))
|
|
first_seen_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, default=utcnow,
|
|
)
|
|
# use_count and last_used_at are application-managed: parse_with_llm
|
|
# increments use_count and sets last_used_at = utcnow() on every cache hit.
|
|
# No onupdate hook — we don't want unrelated writes (e.g. broker_label edits)
|
|
# to re-stamp last_used_at.
|
|
use_count: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
|
last_used_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, default=utcnow,
|
|
)
|
|
model: Mapped[str | None] = mapped_column(String(64))
|
|
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
|
|
completion_tokens: Mapped[int | None] = mapped_column(Integer)
|
|
cost_usd: Mapped[float | None] = mapped_column(Float)
|