All checks were successful
Sync infra to hosts / sync-beta (push) Has been skipped
Sync infra to hosts / sync-prod (push) Has been skipped
Sync infra to hosts / sync-dev (push) Successful in 11s
secrets-guard / encrypted (push) Successful in 17s
shell-lint / shellcheck (push) Successful in 15s
Build + push images (Forgejo registry) / build-push (frontend) (push) Successful in 1m5s
Build + push images (Forgejo registry) / build-push (backend) (push) Successful in 1m8s
Deploy / deploy (backend) (push) Successful in 1m29s
Deploy / deploy (frontend) (push) Successful in 1m38s
secrets-guard / encrypted (pull_request) Successful in 8s
PR build (required check) / changes (pull_request) Successful in 10s
shell-lint / shellcheck (pull_request) Successful in 11s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 59s
PR build (required check) / build-backend (pull_request) Successful in 1m22s
PR build (required check) / gate (pull_request) Successful in 1s
273 lines
14 KiB
Python
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"),
|
|
)
|