Weekly Digest (send_weekly_digest Celery task): - Runs every Monday 8:30 AM UTC via beat schedule - Queries all followed bills updated in the past 7 days per user - Sends low-priority ntfy push (Priority: low, Tags: newspaper,calendar) - Creates a NotificationEvent (weekly_digest type) for RSS feed visibility - Admin can trigger immediately via POST /api/admin/trigger-weekly-digest - Manual Controls panel now includes "Send Weekly Digest" button Local-time quiet hours: - Browser auto-detects IANA timezone via Intl.DateTimeFormat().resolvedOptions().timeZone - Timezone saved to notification_prefs alongside quiet_hours_start/end on Save - Dispatcher converts UTC → user's local time (zoneinfo stdlib) before hour comparison - Falls back to UTC if timezone absent or unrecognised - Quiet hours UI: 12-hour AM/PM selectors, shows detected timezone as hint - Clearing quiet hours also clears stored timezone Co-Authored-By: Jack Levy
278 lines
8.8 KiB
Python
278 lines
8.8 KiB
Python
from datetime import date, datetime
|
|
from typing import Any, Generic, Optional, TypeVar
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
# ── Notifications ──────────────────────────────────────────────────────────────
|
|
|
|
class NotificationSettingsResponse(BaseModel):
|
|
ntfy_topic_url: str = ""
|
|
ntfy_auth_method: str = "none" # none | token | basic
|
|
ntfy_token: str = ""
|
|
ntfy_username: str = ""
|
|
ntfy_password: str = ""
|
|
ntfy_enabled: bool = False
|
|
rss_enabled: bool = False
|
|
rss_token: Optional[str] = None
|
|
# Digest
|
|
digest_enabled: bool = False
|
|
digest_frequency: str = "daily" # daily | weekly
|
|
# Quiet hours — stored as local-time hour integers (0-23); timezone is IANA name
|
|
quiet_hours_start: Optional[int] = None
|
|
quiet_hours_end: Optional[int] = None
|
|
timezone: Optional[str] = None # IANA name, e.g. "America/New_York"
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class NotificationSettingsUpdate(BaseModel):
|
|
ntfy_topic_url: Optional[str] = None
|
|
ntfy_auth_method: Optional[str] = None
|
|
ntfy_token: Optional[str] = None
|
|
ntfy_username: Optional[str] = None
|
|
ntfy_password: Optional[str] = None
|
|
ntfy_enabled: Optional[bool] = None
|
|
rss_enabled: Optional[bool] = None
|
|
digest_enabled: Optional[bool] = None
|
|
digest_frequency: Optional[str] = None
|
|
quiet_hours_start: Optional[int] = None
|
|
quiet_hours_end: Optional[int] = None
|
|
timezone: Optional[str] = None # IANA name sent by the browser on save
|
|
|
|
|
|
class NotificationEventSchema(BaseModel):
|
|
id: int
|
|
bill_id: str
|
|
event_type: str
|
|
payload: Optional[Any] = None
|
|
dispatched_at: Optional[datetime] = None
|
|
created_at: datetime
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class NtfyTestRequest(BaseModel):
|
|
ntfy_topic_url: str
|
|
ntfy_auth_method: str = "none"
|
|
ntfy_token: str = ""
|
|
ntfy_username: str = ""
|
|
ntfy_password: str = ""
|
|
|
|
|
|
class FollowModeTestRequest(BaseModel):
|
|
mode: str # pocket_veto | pocket_boost
|
|
event_type: str # new_document | new_amendment | bill_updated
|
|
|
|
|
|
class NotificationTestResult(BaseModel):
|
|
status: str # "ok" | "error"
|
|
detail: str
|
|
event_count: Optional[int] = None # RSS only
|
|
|
|
T = TypeVar("T")
|
|
|
|
|
|
class PaginatedResponse(BaseModel, Generic[T]):
|
|
items: list[T]
|
|
total: int
|
|
page: int
|
|
per_page: int
|
|
pages: int
|
|
|
|
|
|
# ── Member ────────────────────────────────────────────────────────────────────
|
|
|
|
class MemberSchema(BaseModel):
|
|
bioguide_id: str
|
|
name: str
|
|
first_name: Optional[str] = None
|
|
last_name: Optional[str] = None
|
|
party: Optional[str] = None
|
|
state: Optional[str] = None
|
|
chamber: Optional[str] = None
|
|
district: Optional[str] = None
|
|
photo_url: Optional[str] = None
|
|
official_url: Optional[str] = None
|
|
congress_url: Optional[str] = None
|
|
birth_year: Optional[str] = None
|
|
address: Optional[str] = None
|
|
phone: Optional[str] = None
|
|
terms_json: Optional[list[Any]] = None
|
|
leadership_json: Optional[list[Any]] = None
|
|
sponsored_count: Optional[int] = None
|
|
cosponsored_count: Optional[int] = None
|
|
latest_trend: Optional["MemberTrendScoreSchema"] = None
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
# ── Bill Brief ────────────────────────────────────────────────────────────────
|
|
|
|
class BriefSchema(BaseModel):
|
|
id: int
|
|
brief_type: str = "full"
|
|
summary: Optional[str] = None
|
|
key_points: Optional[list[Any]] = None
|
|
risks: Optional[list[Any]] = None
|
|
deadlines: Optional[list[dict[str, Any]]] = None
|
|
topic_tags: Optional[list[str]] = None
|
|
llm_provider: Optional[str] = None
|
|
llm_model: Optional[str] = None
|
|
govinfo_url: Optional[str] = None
|
|
created_at: Optional[datetime] = None
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
# ── Bill Action ───────────────────────────────────────────────────────────────
|
|
|
|
class BillActionSchema(BaseModel):
|
|
id: int
|
|
action_date: Optional[date] = None
|
|
action_text: Optional[str] = None
|
|
action_type: Optional[str] = None
|
|
chamber: Optional[str] = None
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
# ── News Article ──────────────────────────────────────────────────────────────
|
|
|
|
class NewsArticleSchema(BaseModel):
|
|
id: int
|
|
source: Optional[str] = None
|
|
headline: Optional[str] = None
|
|
url: Optional[str] = None
|
|
published_at: Optional[datetime] = None
|
|
relevance_score: Optional[float] = None
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
# ── Trend Score ───────────────────────────────────────────────────────────────
|
|
|
|
class TrendScoreSchema(BaseModel):
|
|
score_date: date
|
|
newsapi_count: int
|
|
gnews_count: int
|
|
gtrends_score: float
|
|
composite_score: float
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class MemberTrendScoreSchema(BaseModel):
|
|
score_date: date
|
|
newsapi_count: int
|
|
gnews_count: int
|
|
gtrends_score: float
|
|
composite_score: float
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class MemberNewsArticleSchema(BaseModel):
|
|
id: int
|
|
source: Optional[str] = None
|
|
headline: Optional[str] = None
|
|
url: Optional[str] = None
|
|
published_at: Optional[datetime] = None
|
|
relevance_score: Optional[float] = None
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
# ── Bill ──────────────────────────────────────────────────────────────────────
|
|
|
|
class BillSchema(BaseModel):
|
|
bill_id: str
|
|
congress_number: int
|
|
bill_type: str
|
|
bill_number: int
|
|
title: Optional[str] = None
|
|
short_title: Optional[str] = None
|
|
introduced_date: Optional[date] = None
|
|
latest_action_date: Optional[date] = None
|
|
latest_action_text: Optional[str] = None
|
|
status: Optional[str] = None
|
|
chamber: Optional[str] = None
|
|
congress_url: Optional[str] = None
|
|
sponsor: Optional[MemberSchema] = None
|
|
latest_brief: Optional[BriefSchema] = None
|
|
latest_trend: Optional[TrendScoreSchema] = None
|
|
updated_at: Optional[datetime] = None
|
|
has_document: bool = False
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class BillDetailSchema(BillSchema):
|
|
actions: list[BillActionSchema] = []
|
|
news_articles: list[NewsArticleSchema] = []
|
|
trend_scores: list[TrendScoreSchema] = []
|
|
briefs: list[BriefSchema] = []
|
|
has_document: bool = False
|
|
|
|
|
|
# ── Follow ────────────────────────────────────────────────────────────────────
|
|
|
|
class FollowCreate(BaseModel):
|
|
follow_type: str # bill | member | topic
|
|
follow_value: str
|
|
|
|
|
|
class FollowSchema(BaseModel):
|
|
id: int
|
|
user_id: int
|
|
follow_type: str
|
|
follow_value: str
|
|
follow_mode: str = "neutral"
|
|
created_at: datetime
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class FollowModeUpdate(BaseModel):
|
|
follow_mode: str
|
|
|
|
|
|
# ── Settings ──────────────────────────────────────────────────────────────────
|
|
|
|
# ── Auth ──────────────────────────────────────────────────────────────────────
|
|
|
|
class UserCreate(BaseModel):
|
|
email: str
|
|
password: str
|
|
|
|
|
|
class UserResponse(BaseModel):
|
|
id: int
|
|
email: str
|
|
is_admin: bool
|
|
notification_prefs: dict
|
|
created_at: Optional[datetime] = None
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class TokenResponse(BaseModel):
|
|
access_token: str
|
|
token_type: str = "bearer"
|
|
user: "UserResponse"
|
|
|
|
|
|
# ── Settings ──────────────────────────────────────────────────────────────────
|
|
|
|
class SettingUpdate(BaseModel):
|
|
key: str
|
|
value: str
|
|
|
|
|
|
class SettingsResponse(BaseModel):
|
|
llm_provider: str
|
|
llm_model: str
|
|
congress_poll_interval_minutes: int
|
|
newsapi_enabled: bool
|
|
pytrends_enabled: bool
|