Account system with weather-notification subscriptions (#89)

* Add account system foundation: email/password auth with cookie sessions

Introduce the app's first authoritative, user-owned data in a separate
data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache
DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and
a session-check endpoint, backed by a database session strategy so logins survive
restarts and are revocable.

- db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL +
  foreign keys, create_db_and_tables().
- models.py: User, AccessToken, Subscription, Notification tables.
- users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax,
  Secure via env), DatabaseStrategy sessions, current-user dependencies.
- schemas.py: user + subscription + notification Pydantic models.
- app.py: mount auth/register/users routers on v2, create tables at startup.
- Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*.

* Add account header entry and auth modal (frontend)

account.js self-injects a header entry (following the units.js pattern) that
shows a Sign in button when logged out and an account menu when logged in, plus
an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password
sign-in and account creation. A shared apiFetch helper sends the same-origin
cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases.
Imported by every page entry module. On narrow screens the entry collapses to an
icon-only button so it doesn't crowd the title.

Enforce an 8-character minimum password in the user manager.

* Add subscription CRUD API and the alerts management page

Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to
create/list/update/delete subscriptions (and the notification reads used next):
POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate
location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch).
Mounted on the v2 prefix.

Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in
gate when logged out, an add flow that reuses the shared map picker and an editor
modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with
inline threshold/active edits and remove. Reachable from the account menu.

* Add background subscription evaluation engine

notify.py runs a daemon thread that periodically evaluates every active
subscription: it groups them by grid cell, reads history from the parquet cache
only (never spends archive quota) plus the hourly recent/forecast bundle, and
grades candidate days with the existing grading.grade_day. A watched metric that
lands at or beyond the threshold percentile fires a 'high' alert; a two-sided
subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays
one-directional). Observed subscriptions look at the last few recorded days,
forecast subscriptions at the coming week.

Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction,
kind) constraint dedups repeat events, and a per-subscription weekly cap
(last_notified_at) limits each alert to one notification per 7 days. The loop
tolerates a bad cell or an upstream rate limit without aborting the pass. Started
and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER.

* Add in-app notification center (header bell)

Extend account.js with a notification bell beside the account menu: an unread
badge, a dropdown listing recent notifications (title, body, relative time), a
per-item mark-read on click, and a Mark all read action, all through the
cookie-authed notifications API. Unread state refreshes on open and polls every
two minutes while signed in; polling stops on sign-out. Styled to match the app,
responsive down to mobile.

* Harden accounts: expired-session cleanup, engine tests, ops docs

- notify.py sweeps expired login sessions (access tokens past their lifetime)
  once per evaluation pass.
- Add hermetic unit tests for the evaluation engine's trigger detection (high/low
  tails, precip one-directional, normal = no trigger) and notification wording.
- Document accounts.sqlite (authoritative, back it up), the single-worker
  requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
This commit is contained in:
Emi Griffith 2026-07-15 11:46:46 -07:00 committed by GitHub
parent 576a239723
commit efddd15025
9 changed files with 948 additions and 1 deletions

196
api_accounts.py Normal file
View file

@ -0,0 +1,196 @@
"""Authenticated API for subscriptions and notifications.
All routes require a logged-in user (the fastapi-users cookie session) and are
scoped to that user a row that isn't theirs reads as 404, never 403, so the API
doesn't leak which ids exist. Mounted under {BASE}/api/v2 alongside the rest of v2.
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
import climate
import grid
from db import get_async_session
from models import Notification, Subscription
from schemas import (
NotificationList,
NotificationOut,
SubscriptionIn,
SubscriptionOut,
SubscriptionPatch,
)
from users import current_active_user
router = APIRouter(tags=["accounts"])
async def _owned_subscription(session: AsyncSession, sub_id: int, user) -> Subscription:
sub = (
await session.execute(
select(Subscription).where(
Subscription.id == sub_id, Subscription.user_id == user.id
)
)
).scalar_one_or_none()
if sub is None:
raise HTTPException(status_code=404, detail="Subscription not found.")
return sub
# --- subscriptions -----------------------------------------------------------
@router.get("/subscriptions", response_model=list[SubscriptionOut])
async def list_subscriptions(
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
rows = (
await session.execute(
select(Subscription)
.where(Subscription.user_id == user.id)
.order_by(Subscription.created_at)
)
).scalars().all()
return rows
@router.post("/subscriptions", response_model=SubscriptionOut, status_code=201)
async def create_subscription(
body: SubscriptionIn,
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
cell = grid.snap(body.lat, body.lon)
cell_id = cell["id"]
dup = (
await session.execute(
select(Subscription).where(
Subscription.user_id == user.id,
Subscription.cell_id == cell_id,
Subscription.kind == body.kind,
)
)
).scalar_one_or_none()
if dup is not None:
raise HTTPException(
status_code=409,
detail=f"You already have a {body.kind} alert for this location.",
)
label = body.label
if not label:
# Cache-only lookup — never block the event loop on Nominatim. The frontend
# normally supplies the label from /api/v2/place; this is just a fallback.
_, label = climate.reverse_geocode_cached(cell["center_lat"], cell["center_lon"])
sub = Subscription(
user_id=user.id,
cell_id=cell_id,
label=label,
lat=body.lat,
lon=body.lon,
threshold=body.threshold,
metrics=body.metrics,
kind=body.kind,
two_sided=body.two_sided,
)
session.add(sub)
await session.commit()
await session.refresh(sub)
return sub
@router.patch("/subscriptions/{sub_id}", response_model=SubscriptionOut)
async def update_subscription(
sub_id: int,
body: SubscriptionPatch,
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
sub = await _owned_subscription(session, sub_id, user)
for key, value in body.model_dump(exclude_unset=True).items():
setattr(sub, key, value)
await session.commit()
await session.refresh(sub)
return sub
@router.delete("/subscriptions/{sub_id}", status_code=204)
async def delete_subscription(
sub_id: int,
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
sub = await _owned_subscription(session, sub_id, user)
await session.delete(sub)
await session.commit()
# --- notifications -----------------------------------------------------------
@router.get("/notifications", response_model=NotificationList)
async def list_notifications(
unread: bool = Query(False, description="only return unread notifications"),
limit: int = Query(50, ge=1, le=200),
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
q = select(Notification).where(Notification.user_id == user.id)
if unread:
q = q.where(Notification.read_at.is_(None))
q = q.order_by(Notification.created_at.desc()).limit(limit)
rows = (await session.execute(q)).scalars().all()
unread_count = (
await session.execute(
select(func.count())
.select_from(Notification)
.where(Notification.user_id == user.id, Notification.read_at.is_(None))
)
).scalar_one()
return NotificationList(
notifications=[NotificationOut.model_validate(r) for r in rows],
unread_count=unread_count,
)
@router.post("/notifications/{notif_id}/read", status_code=204)
async def mark_notification_read(
notif_id: int,
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
import time
notif = (
await session.execute(
select(Notification).where(
Notification.id == notif_id, Notification.user_id == user.id
)
)
).scalar_one_or_none()
if notif is None:
raise HTTPException(status_code=404, detail="Notification not found.")
if notif.read_at is None:
notif.read_at = time.time()
await session.commit()
@router.post("/notifications/read-all", status_code=204)
async def mark_all_read(
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
import time
rows = (
await session.execute(
select(Notification).where(
Notification.user_id == user.id, Notification.read_at.is_(None)
)
)
).scalars().all()
now = time.time()
for n in rows:
n.read_at = now
if rows:
await session.commit()

33
app.py
View file

@ -15,12 +15,17 @@ from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
import api_accounts
import audit import audit
import climate import climate
import db
import grid import grid
import notify
import places import places
import store import store
import users
import views import views
from schemas import UserCreate, UserRead, UserUpdate
FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "..", "frontend") FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "..", "frontend")
@ -105,9 +110,15 @@ async def _lifespan(app):
# hook (not import time) so offline importers (tests, the migrate script's # hook (not import time) so offline importers (tests, the migrate script's
# dependencies) don't kick off a GeoNames download. The neighbor warmer is # dependencies) don't kick off a GeoNames download. The neighbor warmer is
# also a server concern: enqueues before startup just wait in the queue. # also a server concern: enqueues before startup just wait in the queue.
# Create the account DB tables (users/sessions/subscriptions/notifications)
# before serving — a fast no-op once they exist.
await db.create_db_and_tables()
places.start_loading() places.start_loading()
threading.Thread(target=_neighbor_warmer, name="neighbor-warmer", daemon=True).start() threading.Thread(target=_neighbor_warmer, name="neighbor-warmer", daemon=True).start()
# Background subscription evaluator (in-process; single-worker deploy assumed).
notify.start()
yield yield
notify.stop()
app = FastAPI(title="Thermograph", version="0.2.0", lifespan=_lifespan) app = FastAPI(title="Thermograph", version="0.2.0", lifespan=_lifespan)
@ -127,7 +138,7 @@ async def revalidate_static(request, call_next):
deploy immediately (no "my change isn't showing" bugs).""" deploy immediately (no "my change isn't showing" bugs)."""
response = await call_next(request) response = await call_next(request)
path = request.url.path path = request.url.path
pages = (BASE, f"{BASE}/", f"{BASE}/calendar", f"{BASE}/day", f"{BASE}/compare", f"{BASE}/legend") pages = (BASE, f"{BASE}/", f"{BASE}/calendar", f"{BASE}/day", f"{BASE}/compare", f"{BASE}/legend", f"{BASE}/alerts")
if path.endswith((".js", ".css", ".html")) or path in pages: if path.endswith((".js", ".css", ".html")) or path in pages:
response.headers["Cache-Control"] = "no-cache" response.headers["Cache-Control"] = "no-cache"
return response return response
@ -542,6 +553,25 @@ app.include_router(v1, prefix=f"{BASE}/api") # legacy unversioned == v1 (
app.include_router(v1, prefix=f"{BASE}/api/v1") app.include_router(v1, prefix=f"{BASE}/api/v1")
app.include_router(v2, prefix=f"{BASE}/api/v2") app.include_router(v2, prefix=f"{BASE}/api/v2")
# --- accounts (fastapi-users) ----------------------------------------------
# Library-provided routers: /auth/login + /auth/logout (cookie session),
# /auth/register (signup), and /users/me (session check / profile). All under the
# same v2 prefix as the rest of the API, so the frontend calls them base-relative.
app.include_router(
users.fastapi_users.get_auth_router(users.auth_backend),
prefix=f"{BASE}/api/v2/auth", tags=["auth"],
)
app.include_router(
users.fastapi_users.get_register_router(UserRead, UserCreate),
prefix=f"{BASE}/api/v2/auth", tags=["auth"],
)
app.include_router(
users.fastapi_users.get_users_router(UserRead, UserUpdate),
prefix=f"{BASE}/api/v2/users", tags=["users"],
)
# Subscriptions + notifications (authed, user-scoped).
app.include_router(api_accounts.router, prefix=f"{BASE}/api/v2")
# --- static frontend (served under BASE) ----------------------------------- # --- static frontend (served under BASE) -----------------------------------
def _page(name): def _page(name):
@ -576,6 +606,7 @@ app.add_api_route(f"{BASE}/calendar", _page("calendar.html"), methods=["GET", "H
app.add_api_route(f"{BASE}/day", _page("day.html"), methods=["GET", "HEAD"], include_in_schema=False) app.add_api_route(f"{BASE}/day", _page("day.html"), methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/compare", _page("compare.html"), methods=["GET", "HEAD"], include_in_schema=False) app.add_api_route(f"{BASE}/compare", _page("compare.html"), methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/legend", _page("legend.html"), methods=["GET", "HEAD"], include_in_schema=False) app.add_api_route(f"{BASE}/legend", _page("legend.html"), methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/alerts", _page("subscriptions.html"), methods=["GET", "HEAD"], include_in_schema=False)
# Everything else under BASE (app.js, style.css, nav.js, …) is a static asset. # Everything else under BASE (app.js, style.css, nav.js, …) is a static asset.
# Registered last so the explicit page routes above win. # Registered last so the explicit page routes above win.

75
db.py Normal file
View file

@ -0,0 +1,75 @@
"""Authoritative account database — SQLAlchemy over data/accounts.sqlite (WAL).
This is deliberately a *separate* database from data/thermograph.sqlite (store.py).
That file is a disposable accelerator "deleting it is a safe reset" because
everything in it recomputes from the parquet source of truth. Accounts,
subscriptions and notifications have no source to recompute from, so they live in
their own file with their own (stricter) rules: foreign keys on, errors surface
rather than get swallowed, and it should be backed up (it is not regenerable).
Two engines share the one file:
* an **async** engine (``sqlite+aiosqlite``) drives the request/auth path, because
fastapi-users is async; and
* a **sync** engine drives the background notifier thread (notify.py), which is a
plain daemon thread with no event loop.
WAL mode lets the async readers/writers and the sync notifier coexist on the same
file without blocking each other.
"""
import os
from sqlalchemy import create_engine, event
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
DB_PATH = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "data", "accounts.sqlite")
)
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
class Base(DeclarativeBase):
"""Declarative base shared by every account-domain table (models.py)."""
def _apply_pragmas(dbapi_conn, _rec):
# Per-connection SQLite setup: WAL for concurrent async/sync access, NORMAL
# sync for a good durability/speed tradeoff, and foreign_keys ON so the
# ON DELETE CASCADE relationships (user -> subscriptions -> notifications)
# are actually enforced (SQLite defaults them off).
cur = dbapi_conn.cursor()
cur.execute("PRAGMA journal_mode=WAL")
cur.execute("PRAGMA synchronous=NORMAL")
cur.execute("PRAGMA foreign_keys=ON")
cur.close()
# --- async engine (web / auth path) -----------------------------------------
async_engine = create_async_engine(f"sqlite+aiosqlite:///{DB_PATH}", future=True)
event.listen(async_engine.sync_engine, "connect", _apply_pragmas)
async_session_maker = async_sessionmaker(async_engine, expire_on_commit=False)
async def get_async_session() -> AsyncSession:
"""FastAPI dependency: one AsyncSession per request."""
async with async_session_maker() as session:
yield session
# --- sync engine (background notifier thread) -------------------------------
sync_engine = create_engine(f"sqlite:///{DB_PATH}", future=True)
event.listen(sync_engine, "connect", _apply_pragmas)
sync_session_maker = sessionmaker(sync_engine, expire_on_commit=False)
async def create_db_and_tables() -> None:
"""Create any missing tables. Called once at startup (app lifespan).
Imported for its side effect of registering the mapped classes on Base.metadata
before create_all runs.
"""
import models # noqa: F401 (registers tables on Base.metadata)
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

111
models.py Normal file
View file

@ -0,0 +1,111 @@
"""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"),
)

251
notify.py Normal file
View file

@ -0,0 +1,251 @@
"""Subscription evaluation engine — the background worker that turns unusual
weather into notifications.
A daemon thread (modeled on app.py's neighbor warmer) wakes on an interval and, for
every active subscription, checks whether the subscribed cell's recent/forecast
weather crossed the user's percentile threshold on any watched metric. Crossings
become rows in the notifications table.
Two guards keep it quiet and cheap:
* **Per-event dedup** the notifications table has a UNIQUE(subscription_id,
event_date, metric, direction, kind); an INSERT OR IGNORE means re-running the
pass never re-notifies the same event.
* **Weekly cap** a subscription that has notified within the last 7 days is
skipped entirely (Subscription.last_notified_at), so one alert = at most one
notification per week, as specified.
Quota safety: the loop reads history from the parquet cache only
(climate.load_cached_history) and treats a forecast-fetch failure as "skip this
cell", so it never spends archive quota or dies on an upstream rate limit.
"""
import datetime
import os
import threading
import time
import pandas as pd
from sqlalchemy import delete, select
import climate
import grading
import grid
from db import sync_session_maker
from models import AccessToken, Notification, Subscription
from views import OBS_COLS
WEEK_SECONDS = 7 * 86400
INTERVAL = int(os.environ.get("THERMOGRAPH_NOTIFY_INTERVAL", "900")) # 15 min default
SESSION_TTL_SECONDS = int(os.environ.get("THERMOGRAPH_SESSION_TTL_DAYS", "30")) * 86400
LOOKBACK_DAYS = 3 # re-check the last few observed days (dedup makes it safe)
FORECAST_HORIZON_DAYS = 7
# Metrics whose low tail also counts as unusual when a subscription is two-sided
# (cold snaps, unusually calm/dry). Precipitation is one-directional (high only).
TWO_SIDED_METRICS = set(grading.TEMP_METRICS)
METRIC_NOUN = {
"tmax": "daytime high",
"tmin": "overnight low",
"feels": "feels-like temperature",
"humid": "humidity",
"wind": "wind speed",
"gust": "wind gusts",
"precip": "rainfall",
}
METRIC_UNIT = {
"tmax": "°F", "tmin": "°F", "feels": "°F",
"humid": " g/m³", "wind": " mph", "gust": " mph", "precip": " in",
}
# A readable descriptor per (metric, direction) so titles don't read like
# "unusually high high temperature". Precipitation is high-only.
METRIC_PHRASE = {
("tmax", "high"): "unusually hot day", ("tmax", "low"): "unusually cool day",
("tmin", "high"): "unusually warm night", ("tmin", "low"): "unusually cold night",
("feels", "high"): "unusually hot conditions", ("feels", "low"): "unusually cold conditions",
("humid", "high"): "unusually humid air", ("humid", "low"): "unusually dry air",
("wind", "high"): "unusually strong wind", ("wind", "low"): "unusually calm wind",
("gust", "high"): "unusually strong gusts", ("gust", "low"): "unusually light gusts",
("precip", "high"): "unusually heavy rain",
}
_STOP = threading.Event()
_thread = None
# --- message building --------------------------------------------------------
def _place(sub: Subscription) -> str:
return sub.label or f"{sub.lat:.2f}, {sub.lon:.2f}"
def _compose(sub, event_date, metric, direction, graded):
noun = METRIC_NOUN.get(metric, metric)
pct = graded["percentile"]
grade = graded.get("grade")
value = graded.get("value")
unit = METRIC_UNIT.get(metric, "")
phrase = METRIC_PHRASE.get((metric, direction), f"unusual {noun}")
title = f"{_place(sub)}: {phrase}"
val_txt = f" ({value}{unit})" if value is not None else ""
if sub.kind == "forecast":
body = (f"Forecast for {event_date}: the {noun} is projected at the "
f"{pct:g}th percentile{val_txt}{grade}.")
else:
body = (f"On {event_date}, the {noun} hit the {pct:g}th "
f"percentile{val_txt}{grade}.")
return title, body
# --- per-cell evaluation -----------------------------------------------------
def _obs_from_row(row) -> dict:
return {k: row[k] for k in OBS_COLS if k in row}
def _candidate_rows(recent: pd.DataFrame, kind: str, today: pd.Timestamp):
"""Rows to grade for a subscription of the given kind, most-relevant first."""
if recent is None or recent.empty or "date" not in recent.columns:
return []
dates = pd.to_datetime(recent["date"]).dt.normalize()
if kind == "forecast":
mask = (dates > today) & (dates <= today + pd.Timedelta(days=FORECAST_HORIZON_DAYS))
ordered = recent[mask].assign(_d=dates[mask]).sort_values("_d") # soonest first
else:
mask = (dates <= today) & (dates >= today - pd.Timedelta(days=LOOKBACK_DAYS))
ordered = recent[mask].assign(_d=dates[mask]).sort_values("_d", ascending=False) # newest first
return [row for _, row in ordered.iterrows()]
def _first_trigger(sub: Subscription, rows, history, grade_cache):
"""The first (event_date, metric, direction, graded) that crosses, or None."""
for row in rows:
date_key = pd.Timestamp(row["date"]).date().isoformat()
graded = grade_cache.get(date_key)
if graded is None:
graded = grading.grade_day(history, row["date"], _obs_from_row(row))
grade_cache[date_key] = graded
for metric in sub.metrics:
g = graded.get(metric)
if not g or g.get("percentile") is None:
continue
pct = g["percentile"]
if pct >= sub.threshold:
return (date_key, metric, "high", g)
if sub.two_sided and metric in TWO_SIDED_METRICS and pct <= 100 - sub.threshold:
return (date_key, metric, "low", g)
return None
def _process_cell(session, cell_id, subs, today, now):
"""Evaluate every subscription on one cell, inserting any new notifications."""
try:
cell = grid.from_id(cell_id)
except Exception: # noqa: BLE001 - a malformed cell_id shouldn't kill the pass
return 0
history = climate.load_cached_history(cell)
if history is None or history.empty:
return 0 # nothing cached yet — never spend archive quota from here
try:
recent = climate.get_recent_forecast(cell)
except climate.WeatherUnavailable:
return 0 # upstream rate-limited — skip this cell this pass
except Exception: # noqa: BLE001
return 0
grade_cache = {}
created = 0
for sub in subs:
# Weekly cap: one notification per subscription per 7 days.
if sub.last_notified_at and now - sub.last_notified_at < WEEK_SECONDS:
continue
rows = _candidate_rows(recent, sub.kind, today)
hit = _first_trigger(sub, rows, history, grade_cache)
if hit is None:
continue
event_date, metric, direction, graded = hit
title, body = _compose(sub, event_date, metric, direction, graded)
notif = Notification(
user_id=sub.user_id,
subscription_id=sub.id,
event_date=event_date,
metric=metric,
direction=direction,
kind=sub.kind,
percentile=graded["percentile"],
value=graded.get("value"),
grade=graded.get("grade"),
title=title,
body=body,
channel="inapp",
created_at=now,
)
session.add(notif)
try:
session.flush() # trips the UNIQUE dedup constraint if already seen
except Exception: # noqa: BLE001 - already notified for this exact event
session.rollback()
continue
sub.last_notified_at = now
created += 1
session.commit()
return created
# --- housekeeping ------------------------------------------------------------
def _cleanup_sessions(session) -> None:
"""Drop expired login sessions (access tokens past their lifetime)."""
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
seconds=SESSION_TTL_SECONDS
)
session.execute(delete(AccessToken).where(AccessToken.created_at < cutoff))
session.commit()
# --- pass + loop -------------------------------------------------------------
def run_pass() -> int:
"""One full evaluation sweep over all active subscriptions. Returns the number
of notifications created."""
now = time.time()
today = pd.Timestamp(datetime.date.today())
created = 0
with sync_session_maker() as session:
subs = session.execute(
select(Subscription).where(Subscription.active.is_(True))
).scalars().all()
by_cell: dict[str, list] = {}
for sub in subs:
by_cell.setdefault(sub.cell_id, []).append(sub)
for cell_id, cell_subs in by_cell.items():
try:
created += _process_cell(session, cell_id, cell_subs, today, now)
except Exception: # noqa: BLE001 - one bad cell must not abort the pass
session.rollback()
try:
_cleanup_sessions(session) # opportunistic expired-token sweep
except Exception: # noqa: BLE001
session.rollback()
return created
def run_loop():
# Wait first, then run — gives the app a moment to finish booting.
while not _STOP.wait(INTERVAL):
try:
run_pass()
except Exception: # noqa: BLE001 - a bad pass must never kill the loop
pass
def start():
"""Start the notifier daemon thread (unless disabled via env)."""
global _thread
if os.environ.get("THERMOGRAPH_ENABLE_NOTIFIER", "1") == "0":
return
if _thread is not None:
return
_STOP.clear()
_thread = threading.Thread(target=run_loop, name="subscription-notifier", daemon=True)
_thread.start()
def stop():
_STOP.set()

View file

@ -4,3 +4,6 @@ httpx==0.28.1
pandas==2.2.3 pandas==2.2.3
pyarrow==18.1.0 pyarrow==18.1.0
numpy==2.2.1 numpy==2.2.1
# Accounts + notification subscriptions (see backend/db.py, users.py, notify.py).
fastapi-users[sqlalchemy]==15.0.5
aiosqlite==0.22.1

114
schemas.py Normal file
View file

@ -0,0 +1,114 @@
"""Pydantic request/response models for accounts, subscriptions, notifications.
The User* schemas are fastapi-users' base schemas extended with display_name; the
Subscription*/Notification* models are the app's first request bodies and shape the
JSON the frontend sends and receives.
"""
import uuid
from typing import Literal
from fastapi_users import schemas
from pydantic import BaseModel, Field, field_validator
import grading
# Canonical metric keys a subscription may watch (kept in lockstep with grading).
ALLOWED_METRICS = tuple(grading.CLIMO_METRICS)
DEFAULT_METRICS = ["tmax", "feels", "precip"]
# --- users (fastapi-users) ---------------------------------------------------
class UserRead(schemas.BaseUser[uuid.UUID]):
display_name: str | None = None
class UserCreate(schemas.BaseUserCreate):
display_name: str | None = None
class UserUpdate(schemas.BaseUserUpdate):
display_name: str | None = None
# --- subscriptions -----------------------------------------------------------
def _check_metrics(v: list[str]) -> list[str]:
if not v:
raise ValueError("pick at least one metric")
bad = [m for m in v if m not in ALLOWED_METRICS]
if bad:
raise ValueError(f"unknown metric(s): {', '.join(bad)}")
# de-dupe, preserve order
seen, out = set(), []
for m in v:
if m not in seen:
seen.add(m)
out.append(m)
return out
class SubscriptionIn(BaseModel):
lat: float = Field(ge=-90, le=90)
lon: float = Field(ge=-180, le=180)
label: str | None = None
threshold: int = Field(ge=95, le=99)
metrics: list[str] = Field(default_factory=lambda: list(DEFAULT_METRICS))
kind: Literal["observed", "forecast"] = "observed"
two_sided: bool = True
@field_validator("metrics")
@classmethod
def _metrics(cls, v):
return _check_metrics(v)
class SubscriptionPatch(BaseModel):
threshold: int | None = Field(default=None, ge=95, le=99)
metrics: list[str] | None = None
two_sided: bool | None = None
active: bool | None = None
@field_validator("metrics")
@classmethod
def _metrics(cls, v):
return _check_metrics(v) if v is not None else v
class SubscriptionOut(BaseModel):
id: int
cell_id: str
label: str | None
lat: float
lon: float
threshold: int
metrics: list[str]
kind: str
two_sided: bool
active: bool
last_notified_at: float | None
created_at: float
model_config = {"from_attributes": True}
# --- notifications -----------------------------------------------------------
class NotificationOut(BaseModel):
id: int
subscription_id: int
event_date: str
metric: str
direction: str
kind: str
percentile: float
value: float | None
grade: str | None
title: str
body: str | None
created_at: float
read_at: float | None
model_config = {"from_attributes": True}
class NotificationList(BaseModel):
notifications: list[NotificationOut]
unread_count: int

80
tests/test_notify.py Normal file
View file

@ -0,0 +1,80 @@
"""Unit tests for the subscription evaluation engine's pure logic (no DB, no
network): trigger detection against a synthetic climatology and message wording."""
import types
import numpy as np
import pandas as pd
import notify
def _history() -> pd.DataFrame:
dates = pd.date_range("1990-01-01", "2020-12-31", freq="D")
rng = np.random.default_rng(1)
n = len(dates)
df = pd.DataFrame({"date": dates})
df["doy"] = df["date"].dt.dayofyear
df["tmax"] = 70 + rng.normal(0, 8, n)
df["tmin"] = 48 + rng.normal(0, 7, n)
df["precip"] = rng.exponential(0.05, n)
return df
def _sub(**kw):
d = dict(metrics=["tmax"], threshold=95, two_sided=False, kind="observed",
label="Testville", lat=1.0, lon=2.0)
d.update(kw)
return types.SimpleNamespace(**d)
def _row(date, **vals):
r = {"date": pd.Timestamp(date)}
r.update(vals)
return r
def test_high_trigger():
row = _row("2021-07-15", tmax=115.0, tmin=50.0, precip=0.0)
hit = notify._first_trigger(_sub(metrics=["tmax"], threshold=95), [row], _history(), {})
assert hit is not None
_, metric, direction, g = hit
assert metric == "tmax" and direction == "high" and g["percentile"] >= 95
def test_no_trigger_when_normal():
row = _row("2021-07-15", tmax=70.0, tmin=48.0, precip=0.0)
assert notify._first_trigger(_sub(metrics=["tmax"], threshold=95), [row], _history(), {}) is None
def test_low_trigger_only_when_two_sided():
row = _row("2021-07-15", tmax=70.0, tmin=10.0, precip=0.0) # extreme low overnight
assert notify._first_trigger(
_sub(metrics=["tmin"], threshold=95, two_sided=False), [row], _history(), {}) is None
hit = notify._first_trigger(
_sub(metrics=["tmin"], threshold=95, two_sided=True), [row], _history(), {})
assert hit is not None and hit[1] == "tmin" and hit[2] == "low"
def test_precip_never_low_side():
# A dry day has no rain-percentile, so a precip subscription never fires low
# even when two-sided (rain is one-directional).
row = _row("2021-07-15", tmax=70.0, precip=0.0)
assert notify._first_trigger(
_sub(metrics=["precip"], threshold=95, two_sided=True), [row], _history(), {}) is None
def test_compose_observed_wording():
g = {"percentile": 99.0, "value": 115.0, "grade": "Near Record"}
title, body = notify._compose(_sub(kind="observed", label="Phoenix"),
"2026-07-14", "tmax", "high", g)
assert title == "Phoenix: unusually hot day"
assert body.startswith("On 2026-07-14")
assert "99th percentile" in body and "Near Record" in body
def test_compose_forecast_wording():
g = {"percentile": 2.0, "value": 5.0, "grade": "Near Record"}
title, body = notify._compose(_sub(kind="forecast", label="Nome"),
"2026-01-02", "tmin", "low", g)
assert title == "Nome: unusually cold night"
assert body.startswith("Forecast for 2026-01-02")

86
users.py Normal file
View file

@ -0,0 +1,86 @@
"""fastapi-users wiring: password hashing, cookie sessions, the auth dependency.
We use the library end-to-end (no hand-rolled crypto): pwdlib hashing under the
hood, an HttpOnly cookie transport, and a *database* session strategy (tokens live
in the access_token table) so logins survive a restart and can be revoked.
"""
import os
import secrets
import uuid
from fastapi import Depends
from fastapi_users import BaseUserManager, FastAPIUsers, InvalidPasswordException, UUIDIDMixin
from fastapi_users.authentication import AuthenticationBackend, CookieTransport
from fastapi_users.authentication.strategy.db import DatabaseStrategy
from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase
from fastapi_users_db_sqlalchemy.access_token import SQLAlchemyAccessTokenDatabase
from sqlalchemy.ext.asyncio import AsyncSession
from db import get_async_session
from models import AccessToken, User
BASE = "/" + os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
# Only used to sign reset/verification tokens — features that stay dormant until
# email is wired up. A per-boot random secret is fine while they're unused.
SECRET = os.environ.get("THERMOGRAPH_AUTH_SECRET") or secrets.token_urlsafe(32)
SESSION_TTL_SECONDS = int(os.environ.get("THERMOGRAPH_SESSION_TTL_DAYS", "30")) * 86400
def _cookie_secure() -> bool:
# The app serves plain HTTP on the LAN by default; a Secure cookie would never
# be sent over HTTP, silently breaking auth. So default OFF and let the TLS/VPS
# deploy opt in with THERMOGRAPH_COOKIE_SECURE=1. Accepted tradeoff: LAN traffic
# is unencrypted on a trusted network.
v = os.environ.get("THERMOGRAPH_COOKIE_SECURE", "").strip().lower()
return v in ("1", "true", "yes", "always", "on")
async def get_user_db(session: AsyncSession = Depends(get_async_session)):
yield SQLAlchemyUserDatabase(session, User)
async def get_access_token_db(session: AsyncSession = Depends(get_async_session)):
yield SQLAlchemyAccessTokenDatabase(session, AccessToken)
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
reset_password_token_secret = SECRET
verification_token_secret = SECRET
async def validate_password(self, password: str, user) -> None:
if len(password) < 8:
raise InvalidPasswordException(reason="Password must be at least 8 characters.")
if len(password) > 1024:
# Guard against a scrypt/argon DoS from a megabyte "password".
raise InvalidPasswordException(reason="Password is too long.")
async def get_user_manager(user_db=Depends(get_user_db)):
yield UserManager(user_db)
cookie_transport = CookieTransport(
cookie_name="tg_session",
cookie_max_age=SESSION_TTL_SECONDS,
cookie_path=BASE, # scope the cookie to /thermograph, not the whole domain
cookie_secure=_cookie_secure(),
cookie_httponly=True, # unreadable by JS -> XSS can't steal the session
cookie_samesite="lax", # blocks cross-site form posts (CSRF), allows top-level nav
)
def get_database_strategy(access_token_db=Depends(get_access_token_db)) -> DatabaseStrategy:
return DatabaseStrategy(access_token_db, lifetime_seconds=SESSION_TTL_SECONDS)
auth_backend = AuthenticationBackend(
name="cookie",
transport=cookie_transport,
get_strategy=get_database_strategy,
)
fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend])
# Dependencies handlers use to require / peek at the logged-in user.
current_active_user = fastapi_users.current_user(active=True)
current_user_optional = fastapi_users.current_user(active=True, optional=True)