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
|
|
|
"""ORM tables for the account domain (data/accounts.sqlite).
|
|
|
|
|
|
|
|
|
|
``User`` / ``AccessToken`` are fastapi-users' base tables (UUID primary keys);
|
|
|
|
|
the access-token table backs a database session strategy, so logins survive a
|
|
|
|
|
process restart and are individually revocable. ``Subscription`` and
|
|
|
|
|
``Notification`` are our own, keyed to a user and cascading on delete.
|
|
|
|
|
"""
|
|
|
|
|
import time
|
|
|
|
|
import uuid
|
|
|
|
|
|
|
|
|
|
from fastapi_users_db_sqlalchemy import SQLAlchemyBaseUserTableUUID
|
|
|
|
|
from fastapi_users_db_sqlalchemy.access_token import SQLAlchemyBaseAccessTokenTableUUID
|
|
|
|
|
from fastapi_users_db_sqlalchemy.generics import GUID
|
|
|
|
|
from sqlalchemy import (
|
|
|
|
|
JSON,
|
|
|
|
|
Boolean,
|
|
|
|
|
CheckConstraint,
|
|
|
|
|
Float,
|
|
|
|
|
ForeignKey,
|
|
|
|
|
Index,
|
|
|
|
|
Integer,
|
|
|
|
|
String,
|
|
|
|
|
Text,
|
|
|
|
|
UniqueConstraint,
|
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
|
|
|
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
|
|
|
)
|
2026-07-27 00:56:43 +00:00
|
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
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
|
|
|
|
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.db import Base
|
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 User(SQLAlchemyBaseUserTableUUID, Base):
|
|
|
|
|
# Inherits id (UUID), email (unique), hashed_password, is_active,
|
2026-07-20 02:26:33 +00:00
|
|
|
# is_superuser, is_verified. Optional extras:
|
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
|
|
|
display_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
2026-07-20 02:26:33 +00:00
|
|
|
# Linked Discord account id (OAuth2 identify) — the key DM alerts reach the user
|
2026-07-26 18:16:55 +00:00
|
|
|
# by, and the identity "Sign in with Discord" resolves an account from. Unique,
|
|
|
|
|
# so one Discord account can never own two Thermograph accounts.
|
2026-07-20 02:26:33 +00:00
|
|
|
discord_id: Mapped[str | None] = mapped_column(String(32), unique=True, nullable=True)
|
2026-07-20 04:04:45 +00:00
|
|
|
# Whether to also deliver alerts as a Discord DM. Set True on linking (an active
|
|
|
|
|
# opt-in); the user can mute it while staying linked. Same migration caveat.
|
|
|
|
|
discord_dm: Mapped[bool] = mapped_column(Boolean, nullable=False, default=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
|
|
|
server_default=false())
|
2026-07-27 00:56:43 +00:00
|
|
|
# True when the account was created by signing in with an OAuth provider and so
|
|
|
|
|
# has no password its owner has ever seen (hashed_password is NOT NULL, so the
|
|
|
|
|
# row carries a generated one nobody knows). Load-bearing: unlinking the *last*
|
|
|
|
|
# linked provider from such an account would remove its only way in, so
|
|
|
|
|
# accounts/oauth.py refuses that. Was `discord_only` before Google was added.
|
|
|
|
|
oauth_only: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False,
|
|
|
|
|
server_default=false())
|
|
|
|
|
|
|
|
|
|
# selectin, not the lazy default: these are read while serialising /users/me on
|
|
|
|
|
# the async engine, where a lazy load raises MissingGreenlet rather than
|
|
|
|
|
# quietly issuing a query. One extra SELECT per user load is the price.
|
|
|
|
|
oauth_accounts: Mapped[list["OAuthAccount"]] = relationship(
|
|
|
|
|
"OAuthAccount", lazy="selectin", cascade="all, delete-orphan",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def oauth_providers(self) -> list[str]:
|
|
|
|
|
"""Linked provider names — read straight onto UserRead by from_attributes."""
|
|
|
|
|
return sorted(a.provider for a in self.oauth_accounts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OAuthAccount(Base):
|
|
|
|
|
"""One external identity — a provider plus that provider's own user id — bound
|
|
|
|
|
to a Thermograph account. This is what "sign in with X" resolves against.
|
|
|
|
|
|
|
|
|
|
Keyed on ``subject``, never email: the provider's id for an account is stable,
|
|
|
|
|
while an email address can be changed or reassigned. Email is stored only for
|
|
|
|
|
support and debugging, and is deliberately not used for lookup.
|
|
|
|
|
|
|
|
|
|
Note ``User.discord_id`` is *not* redundant with a discord row here. That column
|
|
|
|
|
is a delivery address — notify.py DMs it — and survives on its own terms; this
|
|
|
|
|
table is purely about authentication.
|
|
|
|
|
"""
|
|
|
|
|
__tablename__ = "oauth_account"
|
|
|
|
|
|
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
|
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
|
|
|
GUID, ForeignKey("user.id", ondelete="CASCADE"), nullable=False
|
|
|
|
|
)
|
|
|
|
|
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
|
|
|
# "sub" for Google, "id" for Discord.
|
|
|
|
|
subject: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
|
|
|
email: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
|
|
|
|
created_at: Mapped[float] = mapped_column(Float, nullable=False, default=time.time)
|
|
|
|
|
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
# A provider account signs into exactly one Thermograph account — this is
|
|
|
|
|
# the constraint that stops one Google login resolving two ways.
|
|
|
|
|
UniqueConstraint("provider", "subject", name="uq_oauth_provider_subject"),
|
|
|
|
|
# And an account holds at most one identity per provider, so "connect
|
|
|
|
|
# Google" is idempotent rather than accumulating rows.
|
|
|
|
|
UniqueConstraint("user_id", "provider", name="uq_oauth_user_provider"),
|
|
|
|
|
Index("idx_oauth_user", "user_id"),
|
|
|
|
|
)
|
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 AccessToken(SQLAlchemyBaseAccessTokenTableUUID, Base):
|
|
|
|
|
# Inherits token (PK), user_id (FK -> user.id), created_at. Rows here ARE the
|
|
|
|
|
# sessions: DatabaseStrategy looks a cookie's token up in this table.
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Subscription(Base):
|
|
|
|
|
__tablename__ = "subscription"
|
|
|
|
|
|
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
|
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
|
|
|
GUID, ForeignKey("user.id", ondelete="CASCADE"), nullable=False
|
|
|
|
|
)
|
|
|
|
|
# grid.snap(lat, lon)["id"] — the stable per-location key used across the app.
|
|
|
|
|
cell_id: Mapped[str] = mapped_column(String(40), nullable=False)
|
|
|
|
|
label: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
|
|
|
|
lat: Mapped[float] = mapped_column(Float, nullable=False)
|
|
|
|
|
lon: Mapped[float] = mapped_column(Float, nullable=False)
|
|
|
|
|
# Unusualness cutoff the user picked; the low tail mirrors it at 100-threshold.
|
|
|
|
|
threshold: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
|
|
|
# Grading metric keys this subscription watches, e.g. ["tmax", "feels", "precip"].
|
|
|
|
|
metrics: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
|
|
|
|
|
# 'observed' (a recorded day crossed) or 'forecast' (an upcoming day is projected to).
|
|
|
|
|
kind: Mapped[str] = mapped_column(String(16), nullable=False, default="observed")
|
|
|
|
|
# Also alert the cold/low tail for temperature-like metrics (precip stays one-sided).
|
|
|
|
|
two_sided: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
|
|
|
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
|
|
|
# Epoch seconds of the last notification emitted — enforces the weekly cap.
|
|
|
|
|
last_notified_at: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
|
|
|
created_at: Mapped[float] = mapped_column(Float, nullable=False, default=time.time)
|
|
|
|
|
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
CheckConstraint("threshold BETWEEN 95 AND 99", name="ck_sub_threshold"),
|
|
|
|
|
CheckConstraint("kind IN ('observed','forecast')", name="ck_sub_kind"),
|
|
|
|
|
# One observed + one forecast subscription per location per user.
|
|
|
|
|
UniqueConstraint("user_id", "cell_id", "kind", name="uq_sub_user_cell_kind"),
|
|
|
|
|
Index("idx_sub_user", "user_id"),
|
|
|
|
|
Index("idx_sub_active", "active"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Notification(Base):
|
|
|
|
|
__tablename__ = "notification"
|
|
|
|
|
|
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
|
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
|
|
|
GUID, ForeignKey("user.id", ondelete="CASCADE"), nullable=False
|
|
|
|
|
)
|
|
|
|
|
subscription_id: Mapped[int] = mapped_column(
|
|
|
|
|
Integer, ForeignKey("subscription.id", ondelete="CASCADE"), nullable=False
|
|
|
|
|
)
|
|
|
|
|
event_date: Mapped[str] = mapped_column(String(10), nullable=False) # YYYY-MM-DD
|
|
|
|
|
metric: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
|
|
|
direction: Mapped[str] = mapped_column(String(4), nullable=False) # 'high' | 'low'
|
|
|
|
|
kind: Mapped[str] = mapped_column(String(16), nullable=False, default="observed")
|
|
|
|
|
percentile: Mapped[float] = mapped_column(Float, nullable=False)
|
|
|
|
|
value: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
|
|
|
grade: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
|
|
|
|
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
|
|
|
|
body: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
|
# 'inapp' today; the seam for future 'email' / 'push' delivery.
|
|
|
|
|
channel: Mapped[str] = mapped_column(String(16), nullable=False, default="inapp")
|
|
|
|
|
created_at: Mapped[float] = mapped_column(Float, nullable=False, default=time.time)
|
|
|
|
|
read_at: Mapped[float | None] = mapped_column(Float, nullable=True) # NULL == unread
|
|
|
|
|
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
# The dedup key: a given event (day+metric+direction+kind) notifies a
|
|
|
|
|
# subscription at most once, so re-running the evaluator never repeats it.
|
|
|
|
|
UniqueConstraint(
|
|
|
|
|
"subscription_id", "event_date", "metric", "direction", "kind",
|
|
|
|
|
name="uq_notif_event",
|
|
|
|
|
),
|
|
|
|
|
Index("idx_notif_user_created", "user_id", "created_at"),
|
|
|
|
|
Index("idx_notif_user_read", "user_id", "read_at"),
|
|
|
|
|
)
|
2026-07-15 23:21:06 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class PushSubscription(Base):
|
|
|
|
|
"""A single browser/device Web Push registration, owned by a user.
|
|
|
|
|
|
|
|
|
|
One row per device (a user with a phone + a laptop has two). The `endpoint`
|
|
|
|
|
is the push service URL the browser handed us; it's the natural identity, so
|
|
|
|
|
re-subscribing from the same device updates the keys in place rather than
|
|
|
|
|
duplicating. Rows are pruned when the push service reports the endpoint gone
|
|
|
|
|
(404/410) — see notify.py / api_accounts.py.
|
|
|
|
|
"""
|
|
|
|
|
__tablename__ = "push_subscription"
|
|
|
|
|
|
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
|
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
|
|
|
GUID, ForeignKey("user.id", ondelete="CASCADE"), nullable=False
|
|
|
|
|
)
|
|
|
|
|
# The push service URL (per-device). Unique — it identifies the device.
|
|
|
|
|
endpoint: Mapped[str] = mapped_column(Text, nullable=False)
|
|
|
|
|
# The two client keys from PushSubscription.toJSON().keys, used to encrypt the
|
|
|
|
|
# payload so only this device can read it.
|
|
|
|
|
p256dh: Mapped[str] = mapped_column(String(200), nullable=False)
|
|
|
|
|
auth: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
|
|
|
# Best-effort label for a future "manage devices" view.
|
|
|
|
|
user_agent: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
|
|
|
|
created_at: Mapped[float] = mapped_column(Float, nullable=False, default=time.time)
|
|
|
|
|
last_used_at: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
|
|
|
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
UniqueConstraint("endpoint", name="uq_push_endpoint"),
|
|
|
|
|
Index("idx_push_user", "user_id"),
|
|
|
|
|
)
|
Rebuild the homepage as a distribution landing; add an SMTP seam (#178)
The homepage was a bare tool: a find-bar and an empty panel reading "Find a
location to begin." A visitor arriving from a search result or a shared link
learned nothing about what the product does before deciding to leave.
Rebuild it around the Weekly view, which is untouched:
- Hero with the question as the h1, and a grade card showing a real graded
example — the most unusual city we're currently tracking, either tail. The
card's frame and text slots are server-rendered with reserved heights, so
app.js re-pointing it at the visitor's own place shifts nothing.
- "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city
is force-included whenever one qualifies.
- Stance line, how-it-works, explore cards, and 12 city chips linking into the
~1000-page /climate surface.
- Monthly digest form, in the footer of every page.
Serve / from Jinja instead of a static file with placeholder substitution, so
crawlers and no-JS readers get the whole page as real HTML. home.html.j2
extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand
degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted
rather than left behind the static mount, where it would keep serving indexable
duplicate content.
"Where is it most unusual right now" has no cheap answer at request time —
percentiles live inside zlib-compressed payload blobs with no column to sort on.
So homepage.py sweeps the warm cache and writes data/homepage.json, read by the
template. The sweep is strictly cache-only (climate.load_cached_recent_forecast
is new, the sibling of load_cached_history), so grading ~1000 cities costs zero
upstream requests. It rides the notifier's timer behind an hourly guard rather
than starting a second daemon, and also runs at the tail of warm_cities so a
fresh deploy has a populated feed.
Instrumentation: metrics gains a product-event counter keyed by (event,
referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by
POST /api/v2/event. The referrer is taken from the request's own header, never
from the client. The beacon gets its own inbound category that record_inbound
ignores, so reporting an interaction doesn't also count as traffic. The
dashboard grows an events block with per-referrer attribution.
Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a
local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the
choice between direct-to-MX and relaying through a provider stays a Postfix
config change with no code change. The backend defaults to "console", which
logs and sends nothing, so dev and tests exercise the whole signup path safely.
Signups land in pending_digest unconfirmed; collecting the list shouldn't wait
on delivery.
Also adds /privacy, linked from the footer and kept out of the sitemap.
The strip's classes are named unusual-* rather than record-*: .record-card is
already the SEO records page's, and reusing it leaked layout rules onto
/climate/<slug>/records.
2026-07-18 07:39:47 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class PendingDigest(Base):
|
|
|
|
|
"""A monthly-digest signup, collected before email delivery is wired up.
|
|
|
|
|
|
|
|
|
|
The digest form ships ahead of SMTP on purpose: building the list is the
|
|
|
|
|
slow part, and making people wait for the mailer would throw away every
|
|
|
|
|
signup in the meantime. Rows land here unconfirmed; once delivery is live, a
|
|
|
|
|
confirmation pass mails each address and stamps ``confirmed_at``.
|
|
|
|
|
|
|
|
|
|
Deliberately NOT tied to ``user`` — signing up for the digest must not
|
|
|
|
|
require an account, and most subscribers won't have one.
|
|
|
|
|
"""
|
|
|
|
|
__tablename__ = "pending_digest"
|
|
|
|
|
|
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
|
|
|
# 320 = the practical maximum length of an email address (64 local + @ + 255 domain).
|
|
|
|
|
email: Mapped[str] = mapped_column(String(320), nullable=False)
|
|
|
|
|
# The place the digest should cover. Optional: an address with no place is
|
|
|
|
|
# still a real signup, and the place can be asked for at confirmation time.
|
|
|
|
|
place_label: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
|
|
|
|
lat: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
|
|
|
lon: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
|
|
|
cell_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
|
|
|
|
# Where the signup came from (bare referrer domain), for attribution only.
|
|
|
|
|
source: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
created_at: Mapped[float] = mapped_column(Float, nullable=False, default=time.time)
|
|
|
|
|
# Set when the address is verified. The double opt-in token is stored as a
|
|
|
|
|
# sha256 hash, never in the clear, so a leaked database can't confirm addresses.
|
|
|
|
|
token_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
confirmed_at: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
|
|
|
last_sent_at: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
|
|
|
unsubscribed_at: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
|
|
|
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
# One row per address: a re-submit updates in place rather than
|
|
|
|
|
# duplicating, which is what makes the form idempotent.
|
|
|
|
|
UniqueConstraint("email", name="uq_pending_digest_email"),
|
|
|
|
|
)
|
2026-07-26 18:27:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class Bookmark(Base):
|
|
|
|
|
"""A saved location, owned by a user — the "bookmarked locations" feature.
|
|
|
|
|
|
|
|
|
|
Keyed like ``Subscription`` on ``grid.snap(lat, lon)["id"]``: one bookmark per
|
|
|
|
|
(user, cell), so re-bookmarking the same cell is an upsert of the label rather
|
|
|
|
|
than a duplicate row (see ``create_bookmark`` / ``import_bookmarks`` in
|
|
|
|
|
api_accounts.py, which enforce this at the application layer too).
|
|
|
|
|
"""
|
|
|
|
|
__tablename__ = "bookmark"
|
|
|
|
|
|
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
|
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
|
|
|
GUID, ForeignKey("user.id", ondelete="CASCADE"), nullable=False
|
|
|
|
|
)
|
|
|
|
|
# grid.snap(lat, lon)["id"] — the stable per-location key used across the app.
|
|
|
|
|
cell_id: Mapped[str] = mapped_column(String(40), nullable=False)
|
|
|
|
|
label: Mapped[str] = mapped_column(String(80), nullable=False)
|
|
|
|
|
lat: Mapped[float] = mapped_column(Float, nullable=False)
|
|
|
|
|
lon: Mapped[float] = mapped_column(Float, nullable=False)
|
|
|
|
|
created_at: Mapped[float] = mapped_column(Float, nullable=False, default=time.time)
|
|
|
|
|
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
# One bookmark per location per user — re-bookmarking upserts the label.
|
|
|
|
|
UniqueConstraint("user_id", "cell_id", name="uq_bookmark_user_cell"),
|
|
|
|
|
Index("idx_bookmark_user", "user_id"),
|
|
|
|
|
)
|