thermograph/db.py
Emi Griffith d426fadad1 Fix mobile alerts dropdown overflow; add account API tests (#93)
The notification dropdown is anchored to the bell button, which on mobile sits
left of the account button rather than at the screen edge, so the wide panel
overflowed off the left of the viewport (title/text cut off). On narrow screens,
drop .notif's positioning context so the dropdown anchors to the .acct cluster at
the header's right edge, keeping it fully on-screen.

Also add route-level tests for the accounts feature (auth flow, subscription CRUD,
duplicate/validation, ownership 404s, notifications), plus a
THERMOGRAPH_ACCOUNTS_DB override so the suite writes to a throwaway DB and stays
hermetic.
2026-07-15 22:00:47 +00:00

77 lines
3.2 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
# Default to data/accounts.sqlite; THERMOGRAPH_ACCOUNTS_DB overrides it (tests point
# this at a throwaway temp file to stay hermetic).
DB_PATH = os.environ.get("THERMOGRAPH_ACCOUNTS_DB") or 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)