Notifications: - New /notifications page accessible to all users (ntfy + RSS config) - ntfy now supports no-auth, Bearer token, and HTTP Basic auth (for ACL-protected self-hosted servers) - RSS enabled/disabled independently of ntfy; token auto-generated on first GET - Notification settings removed from admin-only Settings page; replaced with link card - Sidebar adds Notifications nav link for all users - notification_dispatcher.py: fan-out now marks RSS events dispatched independently Action history: - Migration 0012: deduplicates existing bill_actions rows and adds UNIQUE(bill_id, action_date, action_text) - congress_poller.py: replaces existence-check inserts with ON CONFLICT DO NOTHING (race-condition safe) - Added backfill_all_bill_actions task (no date filter) + admin endpoint POST /backfill-all-actions Authored-By: Jack Levy
116 lines
3.9 KiB
Python
116 lines
3.9 KiB
Python
"""
|
|
Notification dispatcher — sends pending notification events via ntfy.
|
|
|
|
RSS is pull-based so no dispatch is needed for it; events are simply
|
|
marked dispatched once ntfy is sent (or immediately if the user has no
|
|
ntfy configured but has an RSS token, so the feed can clean up old items).
|
|
|
|
Runs every 5 minutes on Celery Beat.
|
|
"""
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
|
|
import requests
|
|
|
|
from app.database import get_sync_db
|
|
from app.models.notification import NotificationEvent
|
|
from app.models.user import User
|
|
from app.workers.celery_app import celery_app
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
NTFY_TIMEOUT = 10
|
|
|
|
_EVENT_TITLES = {
|
|
"new_document": "New Bill Text Published",
|
|
"new_amendment": "Amendment Filed",
|
|
"bill_updated": "Bill Updated",
|
|
}
|
|
|
|
|
|
@celery_app.task(bind=True, name="app.workers.notification_dispatcher.dispatch_notifications")
|
|
def dispatch_notifications(self):
|
|
"""Fan out pending notification events to ntfy and mark dispatched."""
|
|
db = get_sync_db()
|
|
try:
|
|
pending = (
|
|
db.query(NotificationEvent)
|
|
.filter(NotificationEvent.dispatched_at.is_(None))
|
|
.order_by(NotificationEvent.created_at)
|
|
.limit(200)
|
|
.all()
|
|
)
|
|
|
|
sent = 0
|
|
failed = 0
|
|
now = datetime.now(timezone.utc)
|
|
|
|
for event in pending:
|
|
user = db.get(User, event.user_id)
|
|
if not user:
|
|
event.dispatched_at = now
|
|
db.commit()
|
|
continue
|
|
|
|
prefs = user.notification_prefs or {}
|
|
ntfy_url = prefs.get("ntfy_topic_url", "").strip()
|
|
ntfy_auth_method = prefs.get("ntfy_auth_method", "none")
|
|
ntfy_token = prefs.get("ntfy_token", "").strip()
|
|
ntfy_username = prefs.get("ntfy_username", "").strip()
|
|
ntfy_password = prefs.get("ntfy_password", "").strip()
|
|
ntfy_enabled = prefs.get("ntfy_enabled", False)
|
|
rss_enabled = prefs.get("rss_enabled", False)
|
|
|
|
if ntfy_enabled and ntfy_url:
|
|
try:
|
|
_send_ntfy(event, ntfy_url, ntfy_auth_method, ntfy_token, ntfy_username, ntfy_password)
|
|
sent += 1
|
|
except Exception as e:
|
|
logger.warning(f"ntfy dispatch failed for event {event.id}: {e}")
|
|
failed += 1
|
|
|
|
# Mark dispatched once handled by at least one enabled channel.
|
|
# RSS is pull-based — no action needed beyond creating the event record.
|
|
if (ntfy_enabled and ntfy_url) or rss_enabled:
|
|
event.dispatched_at = now
|
|
db.commit()
|
|
|
|
logger.info(f"dispatch_notifications: {sent} sent, {failed} failed, {len(pending)} pending")
|
|
return {"sent": sent, "failed": failed, "total": len(pending)}
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _send_ntfy(
|
|
event: NotificationEvent,
|
|
topic_url: str,
|
|
auth_method: str = "none",
|
|
token: str = "",
|
|
username: str = "",
|
|
password: str = "",
|
|
) -> None:
|
|
import base64
|
|
payload = event.payload or {}
|
|
bill_label = payload.get("bill_label", event.bill_id.upper())
|
|
bill_title = payload.get("bill_title", "")
|
|
message = f"{bill_label}: {bill_title}"
|
|
if payload.get("brief_summary"):
|
|
message += f"\n\n{payload['brief_summary'][:280]}"
|
|
|
|
headers = {
|
|
"Title": _EVENT_TITLES.get(event.event_type, "Bill Update"),
|
|
"Priority": "default",
|
|
"Tags": "scroll",
|
|
}
|
|
if payload.get("bill_url"):
|
|
headers["Click"] = payload["bill_url"]
|
|
|
|
if auth_method == "token" and token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
elif auth_method == "basic" and username:
|
|
creds = base64.b64encode(f"{username}:{password}".encode()).decode()
|
|
headers["Authorization"] = f"Basic {creds}"
|
|
|
|
resp = requests.post(topic_url, data=message.encode("utf-8"), headers=headers, timeout=NTFY_TIMEOUT)
|
|
resp.raise_for_status()
|