UMBRA/backend/app/services/ntfy_templates.py
Kyle Pope 67456c78dd Implement Track C: NTFY push notification integration
- Add ntfy columns to Settings model (server_url, topic, auth_token, enabled, per-type toggles, lead times)
- Create NtfySent dedup model to prevent duplicate notifications
- Create ntfy service with SSRF validation and async httpx send
- Create ntfy_templates service with per-type payload builders
- Create APScheduler background dispatch job (60s interval, events/reminders/todos/projects)
- Register scheduler in main.py lifespan with max_instances=1
- Update SettingsUpdate with ntfy validators (URL scheme, topic regex, lead time ranges)
- Update SettingsResponse with ntfy fields; ntfy_has_token computed, token never exposed
- Add POST /api/settings/ntfy/test endpoint
- Update GET/PUT settings to use explicit _to_settings_response() helper
- Add Alembic migration 022 for ntfy settings columns + ntfy_sent table
- Add httpx==0.27.2 and apscheduler==3.10.4 to requirements.txt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 04:04:23 +08:00

135 lines
4.2 KiB
Python

"""
Notification template builders for ntfy push notifications.
Each build_* function returns a dict with keys: title, message, tags, priority.
These are passed directly to send_ntfy_notification().
"""
from datetime import datetime, date
from typing import Optional
# ── Shared helpers ────────────────────────────────────────────────────────────
def urgency_label(target_date: date, today: date) -> str:
"""Human-readable urgency string relative to today."""
delta = (target_date - today).days
if delta < 0:
return f"OVERDUE ({abs(delta)}d ago)"
elif delta == 0:
return "Today"
elif delta == 1:
return "Tomorrow"
elif delta <= 7:
return f"in {delta} days"
else:
return target_date.strftime("%d %b")
def day_str(dt: datetime, today: date) -> str:
"""Return 'Today', 'Tomorrow', or a short date string."""
d = dt.date()
if d == today:
return "Today"
delta = (d - today).days
if delta == 1:
return "Tomorrow"
return dt.strftime("%a %d %b")
def time_str(dt: datetime, all_day: bool = False) -> str:
"""Return 'All day' or HH:MM."""
if all_day:
return "All day"
return dt.strftime("%H:%M")
def _truncate(text: str, max_len: int) -> str:
"""Truncate with ellipsis if over limit."""
return (text[:max_len - 3] + "...") if len(text) > max_len else text
# ── Template builders ─────────────────────────────────────────────────────────
def build_event_notification(
title: str,
start_datetime: datetime,
all_day: bool,
today: date,
location_name: Optional[str] = None,
description: Optional[str] = None,
is_starred: bool = False,
) -> dict:
"""Build notification payload for a calendar event reminder."""
day = day_str(start_datetime, today)
time = time_str(start_datetime, all_day)
loc = f" @ {location_name}" if location_name else ""
desc = f"{description[:80]}" if description else ""
return {
"title": _truncate(f"Calendar: {title}", 80),
"message": _truncate(f"{day} at {time}{loc}{desc}", 200),
"tags": ["calendar"],
"priority": 4 if is_starred else 3,
}
def build_reminder_notification(
title: str,
remind_at: datetime,
today: date,
description: Optional[str] = None,
) -> dict:
"""Build notification payload for a general reminder."""
day = day_str(remind_at, today)
time = time_str(remind_at)
desc = f"{description[:80]}" if description else ""
return {
"title": _truncate(f"Reminder: {title}", 80),
"message": _truncate(f"{day} at {time}{desc}", 200),
"tags": ["bell"],
"priority": 3,
}
def build_todo_notification(
title: str,
due_date: date,
today: date,
priority: str = "medium",
category: Optional[str] = None,
) -> dict:
"""Build notification payload for a todo due date alert."""
urgency = urgency_label(due_date, today)
priority_label = priority.capitalize()
cat = f" [{category}]" if category else ""
# High priority for today/overdue, default otherwise
ntfy_priority = 4 if (due_date - today).days <= 0 else 3
return {
"title": _truncate(f"Due {urgency}: {title}", 80),
"message": _truncate(f"Priority: {priority_label}{cat}", 200),
"tags": ["white_check_mark"],
"priority": ntfy_priority,
}
def build_project_notification(
name: str,
due_date: date,
today: date,
status: str = "in_progress",
) -> dict:
"""Build notification payload for a project deadline alert."""
urgency = urgency_label(due_date, today)
# Format status label
status_label = status.replace("_", " ").title()
ntfy_priority = 4 if due_date <= today else 3
return {
"title": _truncate(f"Project Deadline: {name}", 80),
"message": _truncate(f"Due {urgency} — Status: {status_label}", 200),
"tags": ["briefcase"],
"priority": ntfy_priority,
}