Lets a signed-in user connect their Discord account (OAuth2 identify), storing the Discord user id that a later feature (DM alerts) will deliver to. Standard authorization-code flow, all server-side: - backend/discord_link.py: /discord/link/start redirects to Discord's consent screen; /discord/link/callback exchanges the code, reads the Discord user id, and stores it; /discord/unlink forgets it. The `state` is signed with the app auth secret (stdlib hmac, no new dependency) and carries the Thermograph user id, so a callback can't be replayed or bound to another account. Every route requires an active session, so linking acts on whoever is actually logged in. - models.py: User.discord_id (unique, nullable). schemas.py exposes it on UserRead so the frontend can show link state. - app.py: the router under /api/v2/discord. - account.js: a "Link Discord" / "Unlink Discord" control in the account popover. - deploy/migrations/001-user-discord-id.sql: the manual column add for the existing prod accounts DB. This project has no Alembic — create_all only makes missing tables, so an added column needs a hand-applied migration (documented in-file). - env example: THERMOGRAPH_DISCORD_CLIENT_SECRET + the redirect to register. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
133 lines
3.5 KiB
Python
133 lines
3.5 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, field_validator
|
|
|
|
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"]
|
|
|
|
|
|
# --- 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 (discord_link.py), not editable through the profile.
|
|
discord_id: str | None = None
|
|
|
|
|
|
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
|