thermograph/backend/accounts/db.py

153 lines
7.5 KiB
Python
Raw Permalink Normal View History

Containerize the app and move the databases to PostgreSQL 18 (#220) Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the data layer on Postgres, while keeping the test suite on SQLite. - accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write + read-only asyncpg pair (the RO engine pins read-only transactions, used by the pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL is unset). models.py: boolean server_default -> sa.false(). - store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg when configured, else the existing raw-sqlite3 paths byte-for-byte; sync interfaces and every fail-soft contract preserved. - Alembic (backend/alembic/) manages the accounts schema; the container entrypoint runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced), skips access_token, and resets identity sequences. - Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137) and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet'). - deploy.sh/thermograph.service rewired to manage the compose stack; env example, Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook. Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was verified via docker compose: alembic migrations, register/login, the RO endpoint, store/metrics round-trips, and the accounts data migration.
2026-07-20 06:28:23 +00:00
"""Authoritative account database — SQLAlchemy over PostgreSQL (or SQLite).
This is deliberately a *separate* database from the disposable derived cache
(data/store.py). Accounts, subscriptions and notifications have no source to
recompute from, so they live with stricter rules: foreign keys enforced, errors
surface rather than get swallowed, and the data must be backed up (it is not
regenerable).
Backend is chosen by ``THERMOGRAPH_DATABASE_URL``:
* **PostgreSQL** (``postgresql+asyncpg://``) in container/prod. Two async engines
over the one primary give the read / read-write split the deployment asks for
a read-only engine (``default_transaction_read_only``) for pure-GET endpoints
and a read-write engine for everything else (nearly every account op is a
SELECT-then-write in one transaction and must be RW). A separate **sync**
engine (psycopg) drives the background notifier thread, which has no event loop.
* **SQLite** (default / tests) keeps the previous single-file behaviour with WAL
so the async web path and the sync notifier coexist; the RO "engine" is just an
alias of the RW one (SQLite has no read-only transaction mode to enforce).
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.
2026-07-15 18:46:46 +00:00
"""
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
Split the backend into domain packages (#217) * 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
2026-07-20 05:31:03 +00:00
import paths
Containerize the app and move the databases to PostgreSQL 18 (#220) Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the data layer on Postgres, while keeping the test suite on SQLite. - accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write + read-only asyncpg pair (the RO engine pins read-only transactions, used by the pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL is unset). models.py: boolean server_default -> sa.false(). - store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg when configured, else the existing raw-sqlite3 paths byte-for-byte; sync interfaces and every fail-soft contract preserved. - Alembic (backend/alembic/) manages the accounts schema; the container entrypoint runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced), skips access_token, and resets identity sequences. - Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137) and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet'). - deploy.sh/thermograph.service rewired to manage the compose stack; env example, Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook. Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was verified via docker compose: alembic migrations, register/login, the RO endpoint, store/metrics round-trips, and the accounts data migration.
2026-07-20 06:28:23 +00:00
# The async SQLAlchemy URL. When unset we fall back to the legacy SQLite file
# (THERMOGRAPH_ACCOUNTS_DB overrides its path; tests point it at a temp file).
DATABASE_URL = os.environ.get("THERMOGRAPH_DATABASE_URL", "").strip()
IS_POSTGRES = DATABASE_URL.startswith("postgresql")
def _sync_url(async_url: str) -> str:
"""The sync driver URL for the notifier: asyncpg is async-only, so the sync
engine uses psycopg (``postgresql+psycopg://``); SQLite drops the aiosqlite
driver suffix."""
return (async_url.replace("+asyncpg", "+psycopg")
if async_url.startswith("postgresql")
else async_url.replace("+aiosqlite", ""))
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.
2026-07-15 18:46:46 +00:00
class Base(DeclarativeBase):
"""Declarative base shared by every account-domain table (models.py)."""
Containerize the app and move the databases to PostgreSQL 18 (#220) Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the data layer on Postgres, while keeping the test suite on SQLite. - accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write + read-only asyncpg pair (the RO engine pins read-only transactions, used by the pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL is unset). models.py: boolean server_default -> sa.false(). - store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg when configured, else the existing raw-sqlite3 paths byte-for-byte; sync interfaces and every fail-soft contract preserved. - Alembic (backend/alembic/) manages the accounts schema; the container entrypoint runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced), skips access_token, and resets identity sequences. - Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137) and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet'). - deploy.sh/thermograph.service rewired to manage the compose stack; env example, Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook. Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was verified via docker compose: alembic migrations, register/login, the RO endpoint, store/metrics round-trips, and the accounts data migration.
2026-07-20 06:28:23 +00:00
def _apply_sqlite_pragmas(dbapi_conn, _rec):
# SQLite-only: WAL for concurrent async/sync access, NORMAL sync, and
# foreign_keys ON so the ON DELETE CASCADE relationships are enforced
# (SQLite defaults them off; Postgres always enforces FKs).
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.
2026-07-15 18:46:46 +00:00
cur = dbapi_conn.cursor()
cur.execute("PRAGMA journal_mode=WAL")
cur.execute("PRAGMA synchronous=NORMAL")
cur.execute("PRAGMA foreign_keys=ON")
cur.close()
# Postgres-only pool sizing + sync-path timeouts, defined at module scope (not
# only inside `if IS_POSTGRES` below) so the values themselves are unit-testable
# without a real Postgres connection.
#
# pool_size=1/max_overflow=1 (the original setting on both) meant a 3rd
# concurrent request per worker had to wait out pool_timeout (~30s default) for a
# slot — easy to hit with 4 uvicorn workers x real traffic. pool_size=5 keeps that
# headroom for the steady state.
#
# max_overflow is deliberately *tighter* than pool_size (2, not 5), because the
# overflow is the part nobody budgets for and it is what saturated Postgres on
# 2026-07-26. The connection ceiling is shared far more widely than this process
# knows: two async engines here plus the sync engine below, times N uvicorn
# workers, times the replica count (web autoscales), and prod and beta are two
# databases on ONE Postgres instance. At 5+5 the configured worst case across all
# of that exceeded the server's max_connections; at 5+2 prod web's ceiling is
# 4x14 rather than 4x20. Steady-state behaviour is unchanged — only the burst
# ceiling narrows, and a burst that would have opened a 6th..10th connection now
# waits on pool_timeout instead of being refused outright by the server (which is
# an error, not a wait). See infra/deploy/db/init/20-tuning.sh for the server side.
_ASYNC_POOL_KWARGS = dict(pool_size=5, max_overflow=2, pool_pre_ping=True,
pool_recycle=1800, future=True)
# Smaller than the async engines: only the notifier leader (one process
# cluster-wide, see core/singleton.py) uses the sync engine, driving its periodic
# sweep from a plain thread with no event loop. connect_timeout + a
# statement_timeout (via psycopg's `options`) bound how long a wedged Postgres
# can hang the notifier — it runs for the lifetime of that process's leader
# flock, so an indefinite hang here would silently stop every subscription
# notification until the process is restarted. Left at 2+3: one process holds it,
# so it is not multiplied by the worker or replica count.
_SYNC_POOL_KWARGS = dict(pool_size=2, max_overflow=3, pool_pre_ping=True,
pool_recycle=1800, future=True,
connect_args={"connect_timeout": 5,
"options": "-c statement_timeout=30000"})
Containerize the app and move the databases to PostgreSQL 18 (#220) Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the data layer on Postgres, while keeping the test suite on SQLite. - accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write + read-only asyncpg pair (the RO engine pins read-only transactions, used by the pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL is unset). models.py: boolean server_default -> sa.false(). - store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg when configured, else the existing raw-sqlite3 paths byte-for-byte; sync interfaces and every fail-soft contract preserved. - Alembic (backend/alembic/) manages the accounts schema; the container entrypoint runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced), skips access_token, and resets identity sequences. - Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137) and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet'). - deploy.sh/thermograph.service rewired to manage the compose stack; env example, Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook. Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was verified via docker compose: alembic migrations, register/login, the RO endpoint, store/metrics round-trips, and the accounts data migration.
2026-07-20 06:28:23 +00:00
if IS_POSTGRES:
DB_PATH = None
# Read-write engine (default) + a read-only engine that pins every session to a
# read-only transaction on the server, so a pure-read endpoint can't accidentally
# write and a long read stays off the write connection.
async_engine = create_async_engine(DATABASE_URL, **_ASYNC_POOL_KWARGS)
Containerize the app and move the databases to PostgreSQL 18 (#220) Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the data layer on Postgres, while keeping the test suite on SQLite. - accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write + read-only asyncpg pair (the RO engine pins read-only transactions, used by the pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL is unset). models.py: boolean server_default -> sa.false(). - store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg when configured, else the existing raw-sqlite3 paths byte-for-byte; sync interfaces and every fail-soft contract preserved. - Alembic (backend/alembic/) manages the accounts schema; the container entrypoint runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced), skips access_token, and resets identity sequences. - Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137) and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet'). - deploy.sh/thermograph.service rewired to manage the compose stack; env example, Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook. Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was verified via docker compose: alembic migrations, register/login, the RO endpoint, store/metrics round-trips, and the accounts data migration.
2026-07-20 06:28:23 +00:00
async_engine_ro = create_async_engine(
DATABASE_URL, **_ASYNC_POOL_KWARGS,
Containerize the app and move the databases to PostgreSQL 18 (#220) Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the data layer on Postgres, while keeping the test suite on SQLite. - accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write + read-only asyncpg pair (the RO engine pins read-only transactions, used by the pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL is unset). models.py: boolean server_default -> sa.false(). - store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg when configured, else the existing raw-sqlite3 paths byte-for-byte; sync interfaces and every fail-soft contract preserved. - Alembic (backend/alembic/) manages the accounts schema; the container entrypoint runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced), skips access_token, and resets identity sequences. - Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137) and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet'). - deploy.sh/thermograph.service rewired to manage the compose stack; env example, Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook. Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was verified via docker compose: alembic migrations, register/login, the RO endpoint, store/metrics round-trips, and the accounts data migration.
2026-07-20 06:28:23 +00:00
connect_args={"server_settings": {"default_transaction_read_only": "on"}})
sync_engine = create_engine(_sync_url(DATABASE_URL), **_SYNC_POOL_KWARGS)
Containerize the app and move the databases to PostgreSQL 18 (#220) Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the data layer on Postgres, while keeping the test suite on SQLite. - accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write + read-only asyncpg pair (the RO engine pins read-only transactions, used by the pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL is unset). models.py: boolean server_default -> sa.false(). - store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg when configured, else the existing raw-sqlite3 paths byte-for-byte; sync interfaces and every fail-soft contract preserved. - Alembic (backend/alembic/) manages the accounts schema; the container entrypoint runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced), skips access_token, and resets identity sequences. - Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137) and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet'). - deploy.sh/thermograph.service rewired to manage the compose stack; env example, Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook. Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was verified via docker compose: alembic migrations, register/login, the RO endpoint, store/metrics round-trips, and the accounts data migration.
2026-07-20 06:28:23 +00:00
else:
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)
async_engine = create_async_engine(f"sqlite+aiosqlite:///{DB_PATH}", future=True)
event.listen(async_engine.sync_engine, "connect", _apply_sqlite_pragmas)
async_engine_ro = async_engine # SQLite has no read-only transaction mode
sync_engine = create_engine(f"sqlite:///{DB_PATH}", future=True)
event.listen(sync_engine, "connect", _apply_sqlite_pragmas)
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.
2026-07-15 18:46:46 +00:00
async_session_maker = async_sessionmaker(async_engine, expire_on_commit=False)
Containerize the app and move the databases to PostgreSQL 18 (#220) Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the data layer on Postgres, while keeping the test suite on SQLite. - accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write + read-only asyncpg pair (the RO engine pins read-only transactions, used by the pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL is unset). models.py: boolean server_default -> sa.false(). - store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg when configured, else the existing raw-sqlite3 paths byte-for-byte; sync interfaces and every fail-soft contract preserved. - Alembic (backend/alembic/) manages the accounts schema; the container entrypoint runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced), skips access_token, and resets identity sequences. - Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137) and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet'). - deploy.sh/thermograph.service rewired to manage the compose stack; env example, Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook. Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was verified via docker compose: alembic migrations, register/login, the RO endpoint, store/metrics round-trips, and the accounts data migration.
2026-07-20 06:28:23 +00:00
async_session_ro_maker = async_sessionmaker(async_engine_ro, expire_on_commit=False)
sync_session_maker = sessionmaker(sync_engine, expire_on_commit=False)
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.
2026-07-15 18:46:46 +00:00
async def get_async_session() -> AsyncSession:
Containerize the app and move the databases to PostgreSQL 18 (#220) Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the data layer on Postgres, while keeping the test suite on SQLite. - accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write + read-only asyncpg pair (the RO engine pins read-only transactions, used by the pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL is unset). models.py: boolean server_default -> sa.false(). - store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg when configured, else the existing raw-sqlite3 paths byte-for-byte; sync interfaces and every fail-soft contract preserved. - Alembic (backend/alembic/) manages the accounts schema; the container entrypoint runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced), skips access_token, and resets identity sequences. - Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137) and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet'). - deploy.sh/thermograph.service rewired to manage the compose stack; env example, Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook. Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was verified via docker compose: alembic migrations, register/login, the RO endpoint, store/metrics round-trips, and the accounts data migration.
2026-07-20 06:28:23 +00:00
"""FastAPI dependency: a read-write AsyncSession per request (the default)."""
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.
2026-07-15 18:46:46 +00:00
async with async_session_maker() as session:
yield session
Containerize the app and move the databases to PostgreSQL 18 (#220) Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the data layer on Postgres, while keeping the test suite on SQLite. - accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write + read-only asyncpg pair (the RO engine pins read-only transactions, used by the pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL is unset). models.py: boolean server_default -> sa.false(). - store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg when configured, else the existing raw-sqlite3 paths byte-for-byte; sync interfaces and every fail-soft contract preserved. - Alembic (backend/alembic/) manages the accounts schema; the container entrypoint runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced), skips access_token, and resets identity sequences. - Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137) and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet'). - deploy.sh/thermograph.service rewired to manage the compose stack; env example, Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook. Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was verified via docker compose: alembic migrations, register/login, the RO endpoint, store/metrics round-trips, and the accounts data migration.
2026-07-20 06:28:23 +00:00
async def get_read_session() -> AsyncSession:
"""FastAPI dependency for genuinely read-only endpoints — served by the
read-only engine so reads prefer the read connection. On Postgres the session
runs in a read-only transaction; on SQLite it's the same engine as RW."""
async with async_session_ro_maker() as session:
yield session
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.
2026-07-15 18:46:46 +00:00
async def create_db_and_tables() -> None:
Containerize the app and move the databases to PostgreSQL 18 (#220) Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the data layer on Postgres, while keeping the test suite on SQLite. - accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write + read-only asyncpg pair (the RO engine pins read-only transactions, used by the pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL is unset). models.py: boolean server_default -> sa.false(). - store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg when configured, else the existing raw-sqlite3 paths byte-for-byte; sync interfaces and every fail-soft contract preserved. - Alembic (backend/alembic/) manages the accounts schema; the container entrypoint runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced), skips access_token, and resets identity sequences. - Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137) and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet'). - deploy.sh/thermograph.service rewired to manage the compose stack; env example, Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook. Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was verified via docker compose: alembic migrations, register/login, the RO endpoint, store/metrics round-trips, and the accounts data migration.
2026-07-20 06:28:23 +00:00
"""Create any missing tables. Called once at startup (app lifespan). On
Postgres, retry briefly so an app boot that races the DB container recovers
instead of crashing."""
Split the backend into domain packages (#217) * 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
2026-07-20 05:31:03 +00:00
from accounts import models # noqa: F401 (registers tables on Base.metadata)
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.
2026-07-15 18:46:46 +00:00
Containerize the app and move the databases to PostgreSQL 18 (#220) Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the data layer on Postgres, while keeping the test suite on SQLite. - accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write + read-only asyncpg pair (the RO engine pins read-only transactions, used by the pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL is unset). models.py: boolean server_default -> sa.false(). - store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg when configured, else the existing raw-sqlite3 paths byte-for-byte; sync interfaces and every fail-soft contract preserved. - Alembic (backend/alembic/) manages the accounts schema; the container entrypoint runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced), skips access_token, and resets identity sequences. - Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137) and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet'). - deploy.sh/thermograph.service rewired to manage the compose stack; env example, Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook. Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was verified via docker compose: alembic migrations, register/login, the RO endpoint, store/metrics round-trips, and the accounts data migration.
2026-07-20 06:28:23 +00:00
import asyncio
last = None
for attempt in range(10):
try:
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
return
except Exception as e: # noqa: BLE001 - transient "DB not ready yet" on container start
last = e
if not IS_POSTGRES:
raise
await asyncio.sleep(min(2 ** attempt, 5))
raise last