feat: per-user notifications (ntfy + RSS), deduplicated actions, backfill task
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
This commit is contained in:
115
backend/app/workers/notification_dispatcher.py
Normal file
115
backend/app/workers/notification_dispatcher.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user