Follow Modes (neutral / pocket_veto / pocket_boost):
- Alembic migration 0013 adds follow_mode column to follows table
- FollowButton rewritten as mode-aware dropdown for bills; simple toggle for members/topics
- PATCH /api/follows/{id}/mode endpoint with validation
- Dispatcher filters pocket_veto follows (suppress new_document/new_amendment events)
- Dispatcher adds ntfy Actions header for pocket_boost follows
Change-driven (milestone) Alerts:
- New notification_utils.py with shared emit helpers and 30-min dedup
- congress_poller emits bill_updated events on milestone action text
- llm_processor replaced with shared emit util (also notifies member/topic followers)
Notification Enhancements:
- ntfy priority levels (high for bill_updated, default for others)
- Quiet hours (UTC): dispatcher holds events outside allowed window
- Digest mode (daily/weekly): send_notification_digest Celery beat task
- Notification history endpoint + Recent Alerts UI section
- Enriched following page (bill titles, member photos/details via sub-components)
- Follow mode test buttons in admin settings panel
Infrastructure:
- nginx: switch upstream blocks to set $variable proxy_pass so Docker DNS
re-resolves upstream IPs after container rebuilds (valid=10s)
- TROUBLESHOOTING.md documenting common Docker/nginx/postgres gotchas
Authored-By: Jack Levy
275 lines
8.6 KiB
Python
275 lines
8.6 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 (UTC hour integers 0-23, None = disabled)
|
|
quiet_hours_start: Optional[int] = None
|
|
quiet_hours_end: Optional[int] = None
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
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
|