"""ORM tables for the account domain (data/accounts.sqlite). ``User`` / ``AccessToken`` are fastapi-users' base tables (UUID primary keys); the access-token table backs a database session strategy, so logins survive a process restart and are individually revocable. ``Subscription`` and ``Notification`` are our own, keyed to a user and cascading on delete. """ import time import uuid from fastapi_users_db_sqlalchemy import SQLAlchemyBaseUserTableUUID from fastapi_users_db_sqlalchemy.access_token import SQLAlchemyBaseAccessTokenTableUUID from fastapi_users_db_sqlalchemy.generics import GUID from sqlalchemy import ( JSON, Boolean, CheckConstraint, Float, ForeignKey, Index, Integer, String, Text, UniqueConstraint, false, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from accounts.db import Base class User(SQLAlchemyBaseUserTableUUID, Base): # Inherits id (UUID), email (unique), hashed_password, is_active, # is_superuser, is_verified. Optional extras: display_name: Mapped[str | None] = mapped_column(String(120), nullable=True) # Linked Discord account id (OAuth2 identify) — the key DM alerts reach the user # by, and the identity "Sign in with Discord" resolves an account from. Unique, # so one Discord account can never own two Thermograph accounts. discord_id: Mapped[str | None] = mapped_column(String(32), unique=True, nullable=True) # Whether to also deliver alerts as a Discord DM. Set True on linking (an active # opt-in); the user can mute it while staying linked. Same migration caveat. discord_dm: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=false()) # True when the account was created by signing in with an OAuth provider and so # has no password its owner has ever seen (hashed_password is NOT NULL, so the # row carries a generated one nobody knows). Load-bearing: unlinking the *last* # linked provider from such an account would remove its only way in, so # accounts/oauth.py refuses that. Was `discord_only` before Google was added. oauth_only: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=false()) # selectin, not the lazy default: these are read while serialising /users/me on # the async engine, where a lazy load raises MissingGreenlet rather than # quietly issuing a query. One extra SELECT per user load is the price. oauth_accounts: Mapped[list["OAuthAccount"]] = relationship( "OAuthAccount", lazy="selectin", cascade="all, delete-orphan", ) @property def oauth_providers(self) -> list[str]: """Linked provider names — read straight onto UserRead by from_attributes.""" return sorted(a.provider for a in self.oauth_accounts) class OAuthAccount(Base): """One external identity — a provider plus that provider's own user id — bound to a Thermograph account. This is what "sign in with X" resolves against. Keyed on ``subject``, never email: the provider's id for an account is stable, while an email address can be changed or reassigned. Email is stored only for support and debugging, and is deliberately not used for lookup. Note ``User.discord_id`` is *not* redundant with a discord row here. That column is a delivery address — notify.py DMs it — and survives on its own terms; this table is purely about authentication. """ __tablename__ = "oauth_account" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) user_id: Mapped[uuid.UUID] = mapped_column( GUID, ForeignKey("user.id", ondelete="CASCADE"), nullable=False ) provider: Mapped[str] = mapped_column(String(32), nullable=False) # "sub" for Google, "id" for Discord. subject: Mapped[str] = mapped_column(String(64), nullable=False) email: Mapped[str | None] = mapped_column(String(320), nullable=True) created_at: Mapped[float] = mapped_column(Float, nullable=False, default=time.time) __table_args__ = ( # A provider account signs into exactly one Thermograph account — this is # the constraint that stops one Google login resolving two ways. UniqueConstraint("provider", "subject", name="uq_oauth_provider_subject"), # And an account holds at most one identity per provider, so "connect # Google" is idempotent rather than accumulating rows. UniqueConstraint("user_id", "provider", name="uq_oauth_user_provider"), Index("idx_oauth_user", "user_id"), ) class AccessToken(SQLAlchemyBaseAccessTokenTableUUID, Base): # Inherits token (PK), user_id (FK -> user.id), created_at. Rows here ARE the # sessions: DatabaseStrategy looks a cookie's token up in this table. pass class Subscription(Base): __tablename__ = "subscription" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) user_id: Mapped[uuid.UUID] = mapped_column( GUID, ForeignKey("user.id", ondelete="CASCADE"), nullable=False ) # grid.snap(lat, lon)["id"] — the stable per-location key used across the app. cell_id: Mapped[str] = mapped_column(String(40), nullable=False) label: Mapped[str | None] = mapped_column(String(200), nullable=True) lat: Mapped[float] = mapped_column(Float, nullable=False) lon: Mapped[float] = mapped_column(Float, nullable=False) # Unusualness cutoff the user picked; the low tail mirrors it at 100-threshold. threshold: Mapped[int] = mapped_column(Integer, nullable=False) # Grading metric keys this subscription watches, e.g. ["tmax", "feels", "precip"]. metrics: Mapped[list] = mapped_column(JSON, nullable=False, default=list) # 'observed' (a recorded day crossed) or 'forecast' (an upcoming day is projected to). kind: Mapped[str] = mapped_column(String(16), nullable=False, default="observed") # Also alert the cold/low tail for temperature-like metrics (precip stays one-sided). two_sided: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) # Epoch seconds of the last notification emitted — enforces the weekly cap. last_notified_at: Mapped[float | None] = mapped_column(Float, nullable=True) created_at: Mapped[float] = mapped_column(Float, nullable=False, default=time.time) __table_args__ = ( CheckConstraint("threshold BETWEEN 95 AND 99", name="ck_sub_threshold"), CheckConstraint("kind IN ('observed','forecast')", name="ck_sub_kind"), # One observed + one forecast subscription per location per user. UniqueConstraint("user_id", "cell_id", "kind", name="uq_sub_user_cell_kind"), Index("idx_sub_user", "user_id"), Index("idx_sub_active", "active"), ) class Notification(Base): __tablename__ = "notification" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) user_id: Mapped[uuid.UUID] = mapped_column( GUID, ForeignKey("user.id", ondelete="CASCADE"), nullable=False ) subscription_id: Mapped[int] = mapped_column( Integer, ForeignKey("subscription.id", ondelete="CASCADE"), nullable=False ) event_date: Mapped[str] = mapped_column(String(10), nullable=False) # YYYY-MM-DD metric: Mapped[str] = mapped_column(String(16), nullable=False) direction: Mapped[str] = mapped_column(String(4), nullable=False) # 'high' | 'low' kind: Mapped[str] = mapped_column(String(16), nullable=False, default="observed") percentile: Mapped[float] = mapped_column(Float, nullable=False) value: Mapped[float | None] = mapped_column(Float, nullable=True) grade: Mapped[str | None] = mapped_column(String(40), nullable=True) title: Mapped[str] = mapped_column(String(200), nullable=False) body: Mapped[str | None] = mapped_column(Text, nullable=True) # 'inapp' today; the seam for future 'email' / 'push' delivery. channel: Mapped[str] = mapped_column(String(16), nullable=False, default="inapp") created_at: Mapped[float] = mapped_column(Float, nullable=False, default=time.time) read_at: Mapped[float | None] = mapped_column(Float, nullable=True) # NULL == unread __table_args__ = ( # The dedup key: a given event (day+metric+direction+kind) notifies a # subscription at most once, so re-running the evaluator never repeats it. UniqueConstraint( "subscription_id", "event_date", "metric", "direction", "kind", name="uq_notif_event", ), Index("idx_notif_user_created", "user_id", "created_at"), Index("idx_notif_user_read", "user_id", "read_at"), ) class PushSubscription(Base): """A single browser/device Web Push registration, owned by a user. One row per device (a user with a phone + a laptop has two). The `endpoint` is the push service URL the browser handed us; it's the natural identity, so re-subscribing from the same device updates the keys in place rather than duplicating. Rows are pruned when the push service reports the endpoint gone (404/410) — see notify.py / api_accounts.py. """ __tablename__ = "push_subscription" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) user_id: Mapped[uuid.UUID] = mapped_column( GUID, ForeignKey("user.id", ondelete="CASCADE"), nullable=False ) # The push service URL (per-device). Unique — it identifies the device. endpoint: Mapped[str] = mapped_column(Text, nullable=False) # The two client keys from PushSubscription.toJSON().keys, used to encrypt the # payload so only this device can read it. p256dh: Mapped[str] = mapped_column(String(200), nullable=False) auth: Mapped[str] = mapped_column(String(100), nullable=False) # Best-effort label for a future "manage devices" view. user_agent: Mapped[str | None] = mapped_column(String(300), nullable=True) created_at: Mapped[float] = mapped_column(Float, nullable=False, default=time.time) last_used_at: Mapped[float | None] = mapped_column(Float, nullable=True) __table_args__ = ( UniqueConstraint("endpoint", name="uq_push_endpoint"), Index("idx_push_user", "user_id"), ) class PendingDigest(Base): """A monthly-digest signup, collected before email delivery is wired up. The digest form ships ahead of SMTP on purpose: building the list is the slow part, and making people wait for the mailer would throw away every signup in the meantime. Rows land here unconfirmed; once delivery is live, a confirmation pass mails each address and stamps ``confirmed_at``. Deliberately NOT tied to ``user`` — signing up for the digest must not require an account, and most subscribers won't have one. """ __tablename__ = "pending_digest" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) # 320 = the practical maximum length of an email address (64 local + @ + 255 domain). email: Mapped[str] = mapped_column(String(320), nullable=False) # The place the digest should cover. Optional: an address with no place is # still a real signup, and the place can be asked for at confirmation time. place_label: Mapped[str | None] = mapped_column(String(200), nullable=True) lat: Mapped[float | None] = mapped_column(Float, nullable=True) lon: Mapped[float | None] = mapped_column(Float, nullable=True) cell_id: Mapped[str | None] = mapped_column(String(32), nullable=True) # Where the signup came from (bare referrer domain), for attribution only. source: Mapped[str | None] = mapped_column(String(64), nullable=True) created_at: Mapped[float] = mapped_column(Float, nullable=False, default=time.time) # Set when the address is verified. The double opt-in token is stored as a # sha256 hash, never in the clear, so a leaked database can't confirm addresses. token_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) confirmed_at: Mapped[float | None] = mapped_column(Float, nullable=True) last_sent_at: Mapped[float | None] = mapped_column(Float, nullable=True) unsubscribed_at: Mapped[float | None] = mapped_column(Float, nullable=True) __table_args__ = ( # One row per address: a re-submit updates in place rather than # duplicating, which is what makes the form idempotent. UniqueConstraint("email", name="uq_pending_digest_email"), ) class Bookmark(Base): """A saved location, owned by a user — the "bookmarked locations" feature. Keyed like ``Subscription`` on ``grid.snap(lat, lon)["id"]``: one bookmark per (user, cell), so re-bookmarking the same cell is an upsert of the label rather than a duplicate row (see ``create_bookmark`` / ``import_bookmarks`` in api_accounts.py, which enforce this at the application layer too). """ __tablename__ = "bookmark" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) user_id: Mapped[uuid.UUID] = mapped_column( GUID, ForeignKey("user.id", ondelete="CASCADE"), nullable=False ) # grid.snap(lat, lon)["id"] — the stable per-location key used across the app. cell_id: Mapped[str] = mapped_column(String(40), nullable=False) label: Mapped[str] = mapped_column(String(80), nullable=False) lat: Mapped[float] = mapped_column(Float, nullable=False) lon: Mapped[float] = mapped_column(Float, nullable=False) created_at: Mapped[float] = mapped_column(Float, nullable=False, default=time.time) __table_args__ = ( # One bookmark per location per user — re-bookmarking upserts the label. UniqueConstraint("user_id", "cell_id", name="uq_bookmark_user_cell"), Index("idx_bookmark_user", "user_id"), )