"""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]