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
|
|
|
"""Pydantic request/response models for accounts, subscriptions, notifications.
|
|
|
|
|
|
|
|
|
|
The User* schemas are fastapi-users' base schemas extended with display_name; the
|
|
|
|
|
Subscription*/Notification* models are the app's first request bodies and shape the
|
|
|
|
|
JSON the frontend sends and receives.
|
|
|
|
|
"""
|
|
|
|
|
import uuid
|
|
|
|
|
from typing import Literal
|
|
|
|
|
|
|
|
|
|
from fastapi_users import schemas
|
2026-07-27 00:56:43 +00:00
|
|
|
from pydantic import BaseModel, Field, computed_field, field_validator
|
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 data import grading
|
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
|
|
|
|
|
|
|
|
# Canonical metric keys a subscription may watch (kept in lockstep with grading).
|
|
|
|
|
ALLOWED_METRICS = tuple(grading.CLIMO_METRICS)
|
|
|
|
|
DEFAULT_METRICS = ["tmax", "feels", "precip"]
|
|
|
|
|
|
2026-07-26 18:27:43 +00:00
|
|
|
# Bookmark limits, shared by the single-create and bulk-import paths.
|
|
|
|
|
BOOKMARK_LABEL_MAX_LEN = 80
|
|
|
|
|
BOOKMARK_MAX_PER_USER = 200
|
|
|
|
|
BOOKMARK_IMPORT_MAX_ITEMS = 200
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
# --- users (fastapi-users) ---------------------------------------------------
|
|
|
|
|
class UserRead(schemas.BaseUser[uuid.UUID]):
|
|
|
|
|
display_name: str | None = None
|
2026-07-20 02:26:33 +00:00
|
|
|
# Present so the frontend can show linked/unlinked state; set via the OAuth
|
2026-07-27 00:56:43 +00:00
|
|
|
# flow (accounts/oauth.py), not editable through the profile.
|
2026-07-20 02:26:33 +00:00
|
|
|
discord_id: str | None = None
|
2026-07-20 04:04:45 +00:00
|
|
|
discord_dm: bool = False
|
2026-07-27 00:56:43 +00:00
|
|
|
# True when an OAuth provider is the account's only credential, so the UI can
|
|
|
|
|
# explain why unlinking the last one is refused rather than just failing.
|
|
|
|
|
oauth_only: bool = False
|
|
|
|
|
# Provider names currently linked, e.g. ["discord", "google"] — what the
|
|
|
|
|
# account menu renders its connected-accounts section from.
|
|
|
|
|
oauth_providers: list[str] = []
|
|
|
|
|
|
|
|
|
|
# Superseded by oauth_only. Kept because frontend and backend deploy
|
|
|
|
|
# independently: the frontend in production reads this field, and dropping it
|
|
|
|
|
# would break its account menu the moment this backend shipped ahead. Remove
|
|
|
|
|
# once no deployed frontend predates oauth_only.
|
|
|
|
|
@computed_field
|
|
|
|
|
@property
|
|
|
|
|
def discord_only(self) -> bool:
|
|
|
|
|
return self.oauth_only
|
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 UserCreate(schemas.BaseUserCreate):
|
|
|
|
|
display_name: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UserUpdate(schemas.BaseUserUpdate):
|
|
|
|
|
display_name: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- subscriptions -----------------------------------------------------------
|
|
|
|
|
def _check_metrics(v: list[str]) -> list[str]:
|
|
|
|
|
if not v:
|
|
|
|
|
raise ValueError("pick at least one metric")
|
|
|
|
|
bad = [m for m in v if m not in ALLOWED_METRICS]
|
|
|
|
|
if bad:
|
|
|
|
|
raise ValueError(f"unknown metric(s): {', '.join(bad)}")
|
|
|
|
|
# de-dupe, preserve order
|
|
|
|
|
seen, out = set(), []
|
|
|
|
|
for m in v:
|
|
|
|
|
if m not in seen:
|
|
|
|
|
seen.add(m)
|
|
|
|
|
out.append(m)
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SubscriptionIn(BaseModel):
|
|
|
|
|
lat: float = Field(ge=-90, le=90)
|
|
|
|
|
lon: float = Field(ge=-180, le=180)
|
|
|
|
|
label: str | None = None
|
|
|
|
|
threshold: int = Field(ge=95, le=99)
|
|
|
|
|
metrics: list[str] = Field(default_factory=lambda: list(DEFAULT_METRICS))
|
|
|
|
|
kind: Literal["observed", "forecast"] = "observed"
|
|
|
|
|
two_sided: bool = True
|
|
|
|
|
|
|
|
|
|
@field_validator("metrics")
|
|
|
|
|
@classmethod
|
|
|
|
|
def _metrics(cls, v):
|
|
|
|
|
return _check_metrics(v)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SubscriptionPatch(BaseModel):
|
|
|
|
|
threshold: int | None = Field(default=None, ge=95, le=99)
|
|
|
|
|
metrics: list[str] | None = None
|
|
|
|
|
two_sided: bool | None = None
|
|
|
|
|
active: bool | None = None
|
|
|
|
|
|
|
|
|
|
@field_validator("metrics")
|
|
|
|
|
@classmethod
|
|
|
|
|
def _metrics(cls, v):
|
|
|
|
|
return _check_metrics(v) if v is not None else v
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SubscriptionOut(BaseModel):
|
|
|
|
|
id: int
|
|
|
|
|
cell_id: str
|
|
|
|
|
label: str | None
|
|
|
|
|
lat: float
|
|
|
|
|
lon: float
|
|
|
|
|
threshold: int
|
|
|
|
|
metrics: list[str]
|
|
|
|
|
kind: str
|
|
|
|
|
two_sided: bool
|
|
|
|
|
active: bool
|
|
|
|
|
last_notified_at: float | None
|
|
|
|
|
created_at: float
|
|
|
|
|
|
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- notifications -----------------------------------------------------------
|
|
|
|
|
class NotificationOut(BaseModel):
|
|
|
|
|
id: int
|
|
|
|
|
subscription_id: int
|
|
|
|
|
event_date: str
|
|
|
|
|
metric: str
|
|
|
|
|
direction: str
|
|
|
|
|
kind: str
|
|
|
|
|
percentile: float
|
|
|
|
|
value: float | None
|
|
|
|
|
grade: str | None
|
|
|
|
|
title: str
|
|
|
|
|
body: str | None
|
|
|
|
|
created_at: float
|
|
|
|
|
read_at: float | None
|
|
|
|
|
|
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class NotificationList(BaseModel):
|
|
|
|
|
notifications: list[NotificationOut]
|
|
|
|
|
unread_count: int
|
2026-07-15 23:21:06 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- web push ----------------------------------------------------------------
|
|
|
|
|
class PushKeys(BaseModel):
|
|
|
|
|
p256dh: str
|
|
|
|
|
auth: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PushSubscriptionIn(BaseModel):
|
|
|
|
|
"""Matches the browser's PushSubscription.toJSON() shape."""
|
|
|
|
|
endpoint: str
|
|
|
|
|
keys: PushKeys
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PushUnsubscribeIn(BaseModel):
|
|
|
|
|
endpoint: str
|
2026-07-26 18:27:43 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- bookmarks ----------------------------------------------------------------
|
|
|
|
|
def _check_label(v: str) -> str:
|
|
|
|
|
v = v.strip()
|
|
|
|
|
if not v:
|
|
|
|
|
raise ValueError("label is required")
|
|
|
|
|
if len(v) > BOOKMARK_LABEL_MAX_LEN:
|
|
|
|
|
raise ValueError(f"label must be at most {BOOKMARK_LABEL_MAX_LEN} characters")
|
|
|
|
|
return v
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BookmarkIn(BaseModel):
|
|
|
|
|
lat: float = Field(ge=-90, le=90)
|
|
|
|
|
lon: float = Field(ge=-180, le=180)
|
|
|
|
|
label: str
|
|
|
|
|
|
|
|
|
|
@field_validator("label")
|
|
|
|
|
@classmethod
|
|
|
|
|
def _label(cls, v):
|
|
|
|
|
return _check_label(v)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BookmarkPatch(BaseModel):
|
|
|
|
|
label: str
|
|
|
|
|
|
|
|
|
|
@field_validator("label")
|
|
|
|
|
@classmethod
|
|
|
|
|
def _label(cls, v):
|
|
|
|
|
return _check_label(v)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BookmarkOut(BaseModel):
|
|
|
|
|
id: int
|
|
|
|
|
cell_id: str
|
|
|
|
|
label: str
|
|
|
|
|
lat: float
|
|
|
|
|
lon: float
|
|
|
|
|
created_at: float
|
|
|
|
|
|
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BookmarkList(BaseModel):
|
|
|
|
|
bookmarks: list[BookmarkOut]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BookmarkImportItem(BaseModel):
|
|
|
|
|
lat: float = Field(ge=-90, le=90)
|
|
|
|
|
lon: float = Field(ge=-180, le=180)
|
|
|
|
|
label: str
|
|
|
|
|
|
|
|
|
|
@field_validator("label")
|
|
|
|
|
@classmethod
|
|
|
|
|
def _label(cls, v):
|
|
|
|
|
return _check_label(v)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BookmarkImportIn(BaseModel):
|
|
|
|
|
items: list[BookmarkImportItem] = Field(max_length=BOOKMARK_IMPORT_MAX_ITEMS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BookmarkImportOut(BaseModel):
|
|
|
|
|
imported: int
|
|
|
|
|
skipped: int
|
|
|
|
|
bookmarks: list[BookmarkOut]
|