Add ntfy push notifications with action buttons for meal reminders
CI / test-and-package (push) Successful in 37s
CI / test-and-package (push) Successful in 37s
- ntfy.py: send_meal_reminder() with Home/Out HTTP action buttons, test helper - models.py: new columns ntfy_url, ntfy_token, callback_token on users table - meals.py: /api/ntfy-callback (public, token-authenticated), /ntfy-settings, /ntfy-test - app.py: flask send-reminders CLI command (cron-schedulable) - Dashboard: ntfy config form in Account Settings - i18n: translations for all ntfy strings (en/fr/de) - Dockerfile: include ntfy.py in COPY - tests: 15 new tests covering callback, settings, and auth - docs: updated AGENTS.md and doc/index.md
This commit is contained in:
@@ -22,8 +22,12 @@ from models import (
|
||||
delete_user,
|
||||
delete_household,
|
||||
count_household_users,
|
||||
set_user_ntfy,
|
||||
get_user_ntfy,
|
||||
get_user_by_callback_token,
|
||||
)
|
||||
from i18n import t
|
||||
from ntfy import send_test_notification
|
||||
|
||||
meals_bp = Blueprint("meals", __name__)
|
||||
|
||||
@@ -68,6 +72,7 @@ def dashboard():
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
data = get_dashboard_data(today, household_id)
|
||||
ntfy = get_user_ntfy(session["user_id"])
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
dashboard=data,
|
||||
@@ -77,6 +82,7 @@ def dashboard():
|
||||
next_date=today + timedelta(days=1),
|
||||
current_user_id=session["user_id"],
|
||||
is_admin=session["is_admin"],
|
||||
ntfy=ntfy,
|
||||
)
|
||||
|
||||
|
||||
@@ -125,6 +131,7 @@ def history():
|
||||
target_date = date.today()
|
||||
|
||||
data = get_dashboard_data(target_date, session["household_id"])
|
||||
ntfy = get_user_ntfy(session["user_id"])
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
dashboard=data,
|
||||
@@ -135,6 +142,7 @@ def history():
|
||||
current_user_id=session["user_id"],
|
||||
is_admin=session["is_admin"],
|
||||
history_mode=True,
|
||||
ntfy=ntfy,
|
||||
)
|
||||
|
||||
|
||||
@@ -211,3 +219,92 @@ def admin_delete_household():
|
||||
for key in ("user_id", "username", "is_admin", "household_id", "household_name"):
|
||||
session.pop(key, None)
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
# ── Ntfy integration ─────────────────────────────────────────────────────────
|
||||
|
||||
@meals_bp.route("/ntfy-settings", methods=["POST"])
|
||||
@login_required
|
||||
def ntfy_settings():
|
||||
"""Save ntfy URL and token for the current user."""
|
||||
import secrets
|
||||
|
||||
ntfy_url = request.form.get("ntfy_url", "").strip()
|
||||
ntfy_token = request.form.get("ntfy_token", "").strip()
|
||||
|
||||
existing = get_user_ntfy(session["user_id"])
|
||||
callback_token = existing.get("callback_token") if existing else ""
|
||||
|
||||
# Generate a new callback token if user is enabling ntfy for the first time
|
||||
if ntfy_url and not callback_token:
|
||||
callback_token = secrets.token_urlsafe(32)
|
||||
|
||||
# If clearing ntfy, also clear the callback token
|
||||
if not ntfy_url:
|
||||
callback_token = ""
|
||||
ntfy_token = ""
|
||||
|
||||
set_user_ntfy(session["user_id"], ntfy_url, ntfy_token, callback_token)
|
||||
flash(t("Ntfy settings saved."), "success")
|
||||
return redirect(url_for("meals.dashboard"))
|
||||
|
||||
|
||||
@meals_bp.route("/ntfy-test", methods=["POST"])
|
||||
@login_required
|
||||
def ntfy_test():
|
||||
"""Send a test ntfy notification to verify the configuration."""
|
||||
ntfy_url = request.form.get("ntfy_url", "").strip()
|
||||
ntfy_token = request.form.get("ntfy_token", "").strip()
|
||||
|
||||
if not ntfy_url:
|
||||
flash(t("Please enter an ntfy URL first."), "error")
|
||||
return redirect(url_for("meals.dashboard"))
|
||||
|
||||
ok = send_test_notification(ntfy_url, ntfy_token)
|
||||
if ok:
|
||||
flash(t("Test notification sent! Check your device."), "success")
|
||||
else:
|
||||
flash(t("Failed to send test notification. Check your ntfy URL and token."), "error")
|
||||
return redirect(url_for("meals.dashboard"))
|
||||
|
||||
|
||||
@meals_bp.route("/api/ntfy-callback", methods=["POST"])
|
||||
def ntfy_callback():
|
||||
"""Receive ntfy action callbacks (no login — authenticated via callback_token).
|
||||
|
||||
Expects JSON body: {"token": "...", "meal_type": "lunch|dinner",
|
||||
"date": "YYYY-MM-DD", "status": "yes|no"}
|
||||
"""
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return {"error": "Invalid JSON"}, 400
|
||||
|
||||
token = data.get("token", "")
|
||||
meal_type = data.get("meal_type", "")
|
||||
date_str = data.get("date", "")
|
||||
status = data.get("status", "")
|
||||
|
||||
user = get_user_by_callback_token(token)
|
||||
if user is None:
|
||||
return {"error": "Invalid token"}, 403
|
||||
|
||||
if meal_type not in ("lunch", "dinner"):
|
||||
return {"error": "Invalid meal_type"}, 400
|
||||
if status not in ("yes", "no"):
|
||||
return {"error": "Invalid status"}, 400
|
||||
|
||||
try:
|
||||
target_date = datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
except (ValueError, TypeError):
|
||||
return {"error": "Invalid date"}, 400
|
||||
|
||||
if target_date < date.today():
|
||||
return {"error": "Cannot change past meals"}, 400
|
||||
|
||||
periods = ensure_meal_periods(target_date)
|
||||
period = next((p for p in periods if p["meal_type"] == meal_type), None)
|
||||
if period is None:
|
||||
return {"error": "Meal period not found"}, 400
|
||||
|
||||
upsert_response(user["id"], period["id"], status)
|
||||
return {"message": "Response recorded", "status": status}, 200
|
||||
|
||||
Reference in New Issue
Block a user