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.
77 lines
3.2 KiB
Python
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)
|