diff --git a/AGENTS.md b/AGENTS.md index 10e4e8e..7d3783c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ Flask web app for tracking household meal attendance. Users register into househ | `Dockerfile` | Python 3.13-alpine. **Must list every `.py` file in the COPY line.** | | `docker-compose.yml` | Two services: `meal-tracker` (web) and `meal-cron` (sends reminders). Both on external `caddy` network, shared `meal-data` volume at `/data`. | | `watch-for-updates.sh` | Polls git remote, rebuilds on change. Uses `docker compose` with env vars from `.env`. | -| `send_reminders_cron.py` | Standalone script run every 60s by the `meal-cron` container. Checks per-household local time against 10:00/16:00 triggers. | +| `send_reminders_cron.sh` | Simple shell loop run by `meal-cron`: calls `POST /api/cron-tick` on `meal-tracker:5000` every 60s with `X-Cron-Secret` header. No DB access — all logic is server-side. | | `doc/index.md` | Full project documentation | | `README.md` | Symlink → `doc/index.md` | diff --git a/Dockerfile b/Dockerfile index 1d0e43f..564f8b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Copy application -COPY app.py models.py auth.py meals.py i18n.py ntfy.py send_reminders_cron.py ./ +COPY app.py models.py auth.py meals.py i18n.py ntfy.py send_reminders_cron.sh ./ COPY templates/ templates/ COPY static/ static/ diff --git a/app.py b/app.py index 664e153..f1c03f1 100644 --- a/app.py +++ b/app.py @@ -34,13 +34,14 @@ def create_app() -> Flask: def send_reminders_command(date, meal, base_url): """Send ntfy meal reminders to all users who have ntfy configured.""" target_date = date and __import__("datetime").datetime.strptime(date, "%Y-%m-%d").date() or date.today() - app_base_url = base_url or os.environ.get("APP_BASE_URL", "http://localhost:5000") + fallback_url = base_url or os.environ.get("APP_BASE_URL", "http://localhost:5000") meal_types = ["lunch", "dinner"] if meal == "both" else [meal] total_sent = 0 for household in get_all_households(): for user in get_users_with_ntfy(household["id"]): + user_base_url = user.get("base_url") or fallback_url for mt in meal_types: ok = send_meal_reminder( ntfy_url=user["ntfy_url"], @@ -48,7 +49,7 @@ def create_app() -> Flask: callback_token=user["callback_token"], meal_type=mt, for_date=target_date, - app_base_url=app_base_url, + app_base_url=user_base_url, ) if ok: total_sent += 1 diff --git a/docker-compose.yml b/docker-compose.yml index 7122402..08bfc60 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: - meal-data:/data environment: - SECRET_KEY=${SECRET_KEY:-change-me-in-production} + - CRON_SECRET=${CRON_SECRET:-change-me} restart: unless-stopped hostname: meal-tracker networks: @@ -18,12 +19,9 @@ services: build: context: . dockerfile: Dockerfile - command: ["sh", "-c", "while true; do python3 /app/send_reminders_cron.py; sleep 60; done"] - volumes: - - meal-data:/data + command: ["sh", "/app/send_reminders_cron.sh"] environment: - - SECRET_KEY=${SECRET_KEY:-change-me-in-production} - - APP_BASE_URL=${APP_BASE_URL:-http://meal-tracker:5000} + - CRON_SECRET=${CRON_SECRET:-change-me} restart: unless-stopped networks: - caddy diff --git a/meals.py b/meals.py index 0b69c51..1f16ec9 100644 --- a/meals.py +++ b/meals.py @@ -27,9 +27,14 @@ from models import ( get_user_by_callback_token, set_household_timezone, get_household_by_id, + get_all_households, + get_users_with_ntfy, + update_reminder_sent, ) from i18n import t -from ntfy import send_test_notification +from ntfy import send_test_notification, send_meal_reminder +import os +from zoneinfo import ZoneInfo meals_bp = Blueprint("meals", __name__) @@ -268,7 +273,10 @@ def ntfy_settings(): callback_token = "" ntfy_token = "" - set_user_ntfy(session["user_id"], ntfy_url, ntfy_token, callback_token) + # Capture the base URL from the user's browser request + base_url = request.host_url.rstrip('/') if ntfy_url else "" + + set_user_ntfy(session["user_id"], ntfy_url, ntfy_token, callback_token, base_url) flash(t("Ntfy settings saved."), "success") return redirect(url_for("meals.dashboard")) @@ -332,3 +340,66 @@ def ntfy_callback(): upsert_response(user["id"], period["id"], status) return {"message": "Response recorded", "status": status}, 200 + + +# ── Cron tick (called by meal-cron container) ───────────────────────────────── + +REMINDER_HOURS = {"lunch": 10, "dinner": 16} + + +def _resolve_timezone(tz_str: str) -> ZoneInfo: + try: + return ZoneInfo(tz_str) + except Exception: + return ZoneInfo("UTC") + + +@meals_bp.route("/api/cron-tick", methods=["POST"]) +def cron_tick(): + """Called by the cron container every 60s. + + Checks each household's local time and sends ntfy reminders + at 10:00 (lunch) and 16:00 (dinner). Protected by X-Cron-Secret header. + """ + expected = os.environ.get("CRON_SECRET", "") + if expected and request.headers.get("X-Cron-Secret") != expected: + return {"error": "unauthorized"}, 403 + + now_utc = datetime.now(ZoneInfo("UTC")) + sent = 0 + + for hh in get_all_households(): + tz = _resolve_timezone(hh.get("timezone", "UTC")) + local_now = now_utc.astimezone(tz) + today_str = local_now.strftime("%Y-%m-%d") + hour = local_now.hour + minute = local_now.minute + + for meal_type, trigger_hour in REMINDER_HOURS.items(): + if hour != trigger_hour or minute >= 5: + continue + + last_col = f"last_{meal_type}_reminder" + if hh.get(last_col) == today_str: + continue + + users = get_users_with_ntfy(hh["id"]) + if not users: + continue + + for user in users: + base_url = user.get("base_url", "") or request.host_url.rstrip("/") + ok = send_meal_reminder( + ntfy_url=user["ntfy_url"], + ntfy_token=user["ntfy_token"], + callback_token=user["callback_token"], + meal_type=meal_type, + for_date=local_now.date(), + app_base_url=base_url, + ) + if ok: + sent += 1 + + update_reminder_sent(hh["id"], meal_type, today_str) + + return {"sent": sent}, 200 diff --git a/models.py b/models.py index aaaf838..727ac2f 100644 --- a/models.py +++ b/models.py @@ -75,6 +75,8 @@ def init_db() -> None: db.execute("ALTER TABLE users ADD COLUMN ntfy_token TEXT DEFAULT ''") if "callback_token" not in cols: db.execute("ALTER TABLE users ADD COLUMN callback_token TEXT DEFAULT ''") + if "base_url" not in cols: + db.execute("ALTER TABLE users ADD COLUMN base_url TEXT DEFAULT ''") # Migration: add timezone and reminder tracking to households hh_cols = [r["name"] for r in db.execute("PRAGMA table_info(households)").fetchall()] @@ -207,13 +209,13 @@ def get_household_users(household_id: int) -> list[dict]: # ── Ntfy / notifications ───────────────────────────────────────────────────── def set_user_ntfy(user_id: int, ntfy_url: str, ntfy_token: str, - callback_token: str) -> None: + callback_token: str, base_url: str = "") -> None: """Save ntfy settings for a user.""" with get_db() as db: db.execute( - "UPDATE users SET ntfy_url = ?, ntfy_token = ?, callback_token = ? " - "WHERE id = ?", - (ntfy_url, ntfy_token, callback_token, user_id), + "UPDATE users SET ntfy_url = ?, ntfy_token = ?, callback_token = ?, " + "base_url = ? WHERE id = ?", + (ntfy_url, ntfy_token, callback_token, base_url, user_id), ) @@ -221,7 +223,7 @@ def get_user_ntfy(user_id: int) -> dict: """Get ntfy settings for a user.""" with get_db() as db: row = db.execute( - "SELECT ntfy_url, ntfy_token, callback_token FROM users WHERE id = ?", + "SELECT ntfy_url, ntfy_token, callback_token, base_url FROM users WHERE id = ?", (user_id,), ).fetchone() return dict(row) if row else {} @@ -241,7 +243,7 @@ def get_users_with_ntfy(household_id: int) -> list[dict]: """Return all users in a household who have ntfy configured.""" with get_db() as db: rows = db.execute( - "SELECT id, username, ntfy_url, ntfy_token, callback_token " + "SELECT id, username, ntfy_url, ntfy_token, callback_token, base_url " "FROM users WHERE household_id = ? " "AND ntfy_url != '' AND callback_token != '' " "ORDER BY username", diff --git a/send_reminders_cron.py b/send_reminders_cron.py deleted file mode 100644 index b159b96..0000000 --- a/send_reminders_cron.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -"""Run by the cron container every ~60s to send meal reminders. - -For each household, checks the current local time. If it's 10:00–10:04, -sends lunch reminders; if 16:00–16:04, sends dinner reminders. A -per-household sent-date guard prevents duplicate sends. -""" - -import os -import sys -from datetime import datetime -from zoneinfo import ZoneInfo, available_timezones - -# Ensure we can import from the app directory -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from models import get_all_households, get_users_with_ntfy, update_reminder_sent, init_db -from ntfy import send_meal_reminder - -APP_BASE_URL = os.environ.get("APP_BASE_URL", "http://meal-tracker:5000") - -# Reminder schedule (local time) -REMINDERS = { - "lunch": 10, # 10:00 - "dinner": 16, # 16:00 -} - - -def _resolve_timezone(tz_str: str) -> ZoneInfo: - """Return a ZoneInfo for *tz_str*, falling back to UTC on error.""" - try: - return ZoneInfo(tz_str) - except Exception: - return ZoneInfo("UTC") - - -def main() -> None: - init_db() # ensure tables exist (first run before web container) - now_utc = datetime.now(ZoneInfo("UTC")) - sent = 0 - - for hh in get_all_households(): - tz = _resolve_timezone(hh.get("timezone", "UTC")) - local_now = now_utc.astimezone(tz) - today_str = local_now.strftime("%Y-%m-%d") - hour = local_now.hour - minute = local_now.minute - - for meal_type, trigger_hour in REMINDERS.items(): - if hour != trigger_hour or minute >= 5: - continue - - last_col = f"last_{meal_type}_reminder" - if hh.get(last_col) == today_str: - continue # already sent today - - users = get_users_with_ntfy(hh["id"]) - if not users: - continue - - for user in users: - ok = send_meal_reminder( - ntfy_url=user["ntfy_url"], - ntfy_token=user["ntfy_token"], - callback_token=user["callback_token"], - meal_type=meal_type, - for_date=local_now.date(), - app_base_url=APP_BASE_URL, - ) - if ok: - print(f"[{local_now:%Y-%m-%d %H:%M}] sent {meal_type} → {user['username']}") - sent += 1 - - update_reminder_sent(hh["id"], meal_type, today_str) - - if sent: - print(f"Sent {sent} reminder(s)") - - -if __name__ == "__main__": - main() diff --git a/send_reminders_cron.sh b/send_reminders_cron.sh new file mode 100755 index 0000000..c87a81f --- /dev/null +++ b/send_reminders_cron.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# Cron loop — calls the meal-tracker API every 60s to trigger reminders. +# The Flask endpoint handles all timezone logic and ntfy sending. + +while true; do + wget -q -O- --post-data="" \ + --header="X-Cron-Secret: ${CRON_SECRET}" \ + http://meal-tracker:5000/api/cron-tick 2>/dev/null + sleep 60 +done diff --git a/tests/test_ntfy.py b/tests/test_ntfy.py index c43e970..5520469 100644 --- a/tests/test_ntfy.py +++ b/tests/test_ntfy.py @@ -36,10 +36,33 @@ def _setup_ntfy_user(client, username="ntfyuser", password="testpass", import secrets as sec token = sec.token_urlsafe(32) row = db.execute("SELECT id FROM users WHERE username = ?", (username,)).fetchone() - set_user_ntfy(row["id"], ntfy_url, ntfy_token, token) + set_user_ntfy(row["id"], ntfy_url, ntfy_token, token, "http://localhost") return token +def _setup_ntfy_household(client): + """Register an admin, set timezone, configure ntfy. Returns (client, callback_token).""" + client.post("/register", data={ + "username": "tzadmin", + "password": "tzpass123", + "confirm": "tzpass123", + "household_action": "create", + "new_household_name": "TZ Cron Household", + }, follow_redirects=True) + client.post("/admin/timezone", data={"timezone": "UTC"}, follow_redirects=True) + client.post("/ntfy-settings", data={ + "ntfy_url": "https://ntfy.sh/test-topic", + "ntfy_token": "tk_test", + }, follow_redirects=True) + + from models import get_db + db = get_db() + row = db.execute( + "SELECT callback_token, base_url FROM users WHERE username = ?", ("tzadmin",) + ).fetchone() + return client, row["callback_token"], row["base_url"] + + class TestNtfyCallback: def test_valid_callback_records_response(self, client): token = _setup_ntfy_user(client) @@ -164,12 +187,13 @@ class TestNtfySettings: db = get_db() row = db.execute( - "SELECT ntfy_url, ntfy_token, callback_token FROM users WHERE username = ?", + "SELECT ntfy_url, ntfy_token, callback_token, base_url FROM users WHERE username = ?", ("admin",) ).fetchone() assert row["ntfy_url"] == "https://ntfy.sh/my-alerts" assert row["ntfy_token"] == "tk_secret" - assert len(row["callback_token"]) >= 32 # random token generated + assert len(row["callback_token"]) >= 32 + assert row["base_url"] == "http://localhost" # captured from request # random token generated def test_settings_clears_on_empty_url(self, client): token = _setup_ntfy_user(client) @@ -191,11 +215,12 @@ class TestNtfySettings: }, follow_redirects=True) row = db.execute( - "SELECT ntfy_url, callback_token FROM users WHERE username = ?", + "SELECT ntfy_url, callback_token, base_url FROM users WHERE username = ?", ("ntfyuser",) ).fetchone() assert row["ntfy_url"] == "" assert row["callback_token"] == "" + assert row["base_url"] == "" def test_settings_preserves_callback_token_on_update(self, admin_client): # First save @@ -291,3 +316,36 @@ class TestTimezoneSettings: "timezone": "Europe/Zurich", }) assert resp.status_code == 302 + + +class TestCronTick: + def test_cron_tick_requires_secret(self, client, monkeypatch): + monkeypatch.setenv("CRON_SECRET", "my-secret") + + resp = client.post("/api/cron-tick") + assert resp.status_code == 403 + + resp = client.post("/api/cron-tick", headers={"X-Cron-Secret": "wrong"}) + assert resp.status_code == 403 + + def test_cron_tick_works_with_secret(self, client, monkeypatch): + monkeypatch.setenv("CRON_SECRET", "my-secret") + _setup_ntfy_household(client) + + resp = client.post("/api/cron-tick", + headers={"X-Cron-Secret": "my-secret"}) + assert resp.status_code == 200 + data = resp.get_json() + assert "sent" in data + + def test_ntfy_settings_captures_base_url(self, client): + """When saving ntfy settings, the base_url should be captured from the request.""" + _setup_ntfy_household(client) + + from models import get_db + db = get_db() + row = db.execute( + "SELECT base_url FROM users WHERE username = ?", ("tzadmin",) + ).fetchone() + # In test client, host_url is http://localhost + assert row["base_url"] == "http://localhost"