"""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, ) from sqlalchemy.orm import Mapped, mapped_column from db import Base class User(SQLAlchemyBaseUserTableUUID, Base): # Inherits id (UUID), email (unique), hashed_password, is_active, # is_superuser, is_verified. One optional extra: display_name: Mapped[str | None] = mapped_column(String(120), nullable=True) 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"), )