thermograph/schemas.py
Emi Griffith 024613c06d Deliver unusual-weather alerts as Discord DMs for linked users (#210)
Adds Discord DM as a notification channel beside web push, for users who linked
their Discord account and opted in. It rides the same fan-out point as push
(_dispatch_discord next to _dispatch_push in the notifier pass), reusing the
transport-agnostic title/body/deep-link, and stays best-effort and isolated: the
in-app row is already committed, and push/email remain the fallback for anyone
Discord can't reach (its DM rule requires a shared server / user install).

- discord.py: send_dm() opens the DM channel then posts an embed via the bot token,
  with one capped 429 retry. Absolute deep links (a DM can't resolve a relative
  path the way the service worker can).
- notify.py: _dispatch_discord() delivers only when the subscriber has a linked
  discord_id and discord_dm on; no-ops entirely when no bot token is configured.
- models.py: User.discord_dm (opt-in flag) + migration 002. Linking sets it True
  (an active opt-in); a new POST /discord/dm mutes it without unlinking, and unlink
  clears it. Exposed on UserRead; account popover shows an on/off toggle.

Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-20 04:04:45 +00:00

134 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
discord_dm: bool = False
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