thermograph/backend/accounts/schemas.py
Emi Griffith c17a4c3dd7
All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 5s
shell-lint / shellcheck (pull_request) Successful in 8s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 1m1s
PR build (required check) / build-backend (pull_request) Successful in 1m29s
PR build (required check) / gate (pull_request) Successful in 1s
accounts: sign in with Google, on a shared provider-agnostic OAuth engine
Adds Google as a second identity provider. Rather than a second copy of the
Discord flow, the flow itself moves to accounts/oauth.py and both providers
become configurations of it — account resolution is the security-critical
part, and a parallel hand-rolled copy is where a subtle divergence becomes a
takeover. A third provider is now a PROVIDERS entry and nothing else.

Identity moves to an oauth_account table keyed (provider, subject), so a
login resolves on the provider's own stable id rather than an email that can
be changed or reassigned. user.discord_id deliberately stays: it is the DM
delivery address notify.py reads, not merely an identity, and the Discord
link keeps writing it. discord_only becomes oauth_only, and the lockout guard
now refuses to unlink the *last* provider from a password-less account rather
than singling out Discord — with two linked, either may go.

Migration 0005 renames the flag and backfills an oauth_account row for every
existing discord_id. Without that backfill an already-linked user would stop
being recognised at login and would silently get a second account on their
next sign-in. It also drops a vestigial discord_only that 0003 re-adds on a
database whose 0001 already built oauth_only from current metadata, which
otherwise left fresh and upgraded databases with different schemas.

Compatibility, since the frontend deploys independently of this service:
/discord/link/callback keeps answering because that URL is registered in
Discord's developer portal, the other /discord/* routes stay because the
deployed frontend calls them, the callback still emits ?discord= alongside
?oauth=, and UserRead still carries discord_only as a computed alias.

Google needs THERMOGRAPH_GOOGLE_CLIENT_ID/_CLIENT_SECRET and its own
registered redirect URI; unset, it reports disabled and shows no UI.
2026-07-26 12:06:56 -07:00

220 lines
5.8 KiB
Python

"""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
from pydantic import BaseModel, Field, computed_field, field_validator
from data import grading
# Canonical metric keys a subscription may watch (kept in lockstep with grading).
ALLOWED_METRICS = tuple(grading.CLIMO_METRICS)
DEFAULT_METRICS = ["tmax", "feels", "precip"]
# 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
# --- users (fastapi-users) ---------------------------------------------------
class UserRead(schemas.BaseUser[uuid.UUID]):
display_name: str | None = None
# Present so the frontend can show linked/unlinked state; set via the OAuth
# flow (accounts/oauth.py), not editable through the profile.
discord_id: str | None = None
discord_dm: bool = False
# 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
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
# --- 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
# --- 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]