* Centralize filesystem paths in a single module Add paths.py, which resolves the repo root once and derives the cache, accounts DB, logs, templates, frontend and bundled-city-data locations from it. Replace the 13 per-module `dirname(__file__)/..` anchors with references to it, so a module's location no longer determines where the app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are unchanged; every resolved path is byte-identical to before. Groundwork for moving modules into packages without re-pointing paths. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62 * Split the backend into domain packages Group the flat backend modules into packages that mirror their concerns: data/ climate, grading, scoring, grid, places, cities, city_events, store web/ app, views, homepage, content, schemas notifications/ notify, digest, push, mailer, discord, discord_interactions, discord_link accounts/ models, users, api_accounts, db core/ metrics, singleton, audit Intra-project imports are rewritten to the package-qualified form. The entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor) and paths.py stay at the backend/ root, and backend/app.py becomes a shim re-exporting web.app:app so the launch target stays `app:app` — run.sh, the systemd units, and CI need no change. Verified: full suite (318) passes, `uvicorn app:app` boots and serves the home/SEO/static/API surfaces, and every root script imports clean. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
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
|
|
|
|
import paths
|
|
|
|
# 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.join(paths.DATA_DIR, "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.
|
|
"""
|
|
from accounts import models # noqa: F401 (registers tables on Base.metadata)
|
|
|
|
async with async_engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|