* 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.
75 lines
3 KiB
Python
75 lines
3 KiB
Python
"""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)
|