thermograph/backend/accounts/models.py
Emi Griffith c17a4c3dd7
All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 5s
shell-lint / shellcheck (pull_request) Successful in 8s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 1m1s
PR build (required check) / build-backend (pull_request) Successful in 1m29s
PR build (required check) / gate (pull_request) Successful in 1s
accounts: sign in with Google, on a shared provider-agnostic OAuth engine
Adds Google as a second identity provider. Rather than a second copy of the
Discord flow, the flow itself moves to accounts/oauth.py and both providers
become configurations of it — account resolution is the security-critical
part, and a parallel hand-rolled copy is where a subtle divergence becomes a
takeover. A third provider is now a PROVIDERS entry and nothing else.

Identity moves to an oauth_account table keyed (provider, subject), so a
login resolves on the provider's own stable id rather than an email that can
be changed or reassigned. user.discord_id deliberately stays: it is the DM
delivery address notify.py reads, not merely an identity, and the Discord
link keeps writing it. discord_only becomes oauth_only, and the lockout guard
now refuses to unlink the *last* provider from a password-less account rather
than singling out Discord — with two linked, either may go.

Migration 0005 renames the flag and backfills an oauth_account row for every
existing discord_id. Without that backfill an already-linked user would stop
being recognised at login and would silently get a second account on their
next sign-in. It also drops a vestigial discord_only that 0003 re-adds on a
database whose 0001 already built oauth_only from current metadata, which
otherwise left fresh and upgraded databases with different schemas.

Compatibility, since the frontend deploys independently of this service:
/discord/link/callback keeps answering because that URL is registered in
Discord's developer portal, the other /discord/* routes stay because the
deployed frontend calls them, the callback still emits ?discord= alongside
?oauth=, and UserRead still carries discord_only as a computed alias.

Google needs THERMOGRAPH_GOOGLE_CLIENT_ID/_CLIENT_SECRET and its own
registered redirect URI; unset, it reports disabled and shows no UI.
2026-07-26 12:06:56 -07:00

273 lines
14 KiB
Python

"""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"),
)