Add cron container and per-household timezone for auto-reminders
CI / test-and-package (push) Successful in 36s
CI / test-and-package (push) Successful in 36s
- docker-compose.yml: meal-cron service loops send_reminders_cron.py every 60s - send_reminders_cron.py: checks each household's local time, sends ntfy reminders at 10:00 (lunch) and 16:00 (dinner), guarded against duplicates - models.py: timezone, last_lunch_reminder, last_dinner_reminder on households - meals.py: /admin/timezone route for admins to set household timezone - dashboard: timezone selector (20 common zones) in Account Settings - i18n: 5 new translation keys for timezone strings - Dockerfile: copy send_reminders_cron.py - tests: 4 new timezone tests (admin set, non-admin denied, default UTC, auth) - docs: updated AGENTS.md
This commit is contained in:
@@ -22,8 +22,9 @@ Flask web app for tracking household meal attendance. Users register into househ
|
|||||||
| `static/style.css` | All CSS (no framework) |
|
| `static/style.css` | All CSS (no framework) |
|
||||||
| `tests/` | Pytest suite (47 tests): `conftest.py` sets up in-memory DB per test |
|
| `tests/` | Pytest suite (47 tests): `conftest.py` sets up in-memory DB per test |
|
||||||
| `Dockerfile` | Python 3.13-alpine. **Must list every `.py` file in the COPY line.** |
|
| `Dockerfile` | Python 3.13-alpine. **Must list every `.py` file in the COPY line.** |
|
||||||
| `docker-compose.yml` | Single service `meal-tracker` on external `caddy` network, volume `meal-data` at `/data` |
|
| `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`. |
|
| `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. |
|
||||||
| `doc/index.md` | Full project documentation |
|
| `doc/index.md` | Full project documentation |
|
||||||
| `README.md` | Symlink → `doc/index.md` |
|
| `README.md` | Symlink → `doc/index.md` |
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ COPY requirements.txt .
|
|||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy application
|
# Copy application
|
||||||
COPY app.py models.py auth.py meals.py i18n.py ntfy.py ./
|
COPY app.py models.py auth.py meals.py i18n.py ntfy.py send_reminders_cron.py ./
|
||||||
COPY templates/ templates/
|
COPY templates/ templates/
|
||||||
COPY static/ static/
|
COPY static/ static/
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,20 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- caddy
|
- caddy
|
||||||
|
|
||||||
|
meal-cron:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
command: ["sh", "-c", "while true; do python3 /app/send_reminders_cron.py; sleep 60; done"]
|
||||||
|
volumes:
|
||||||
|
- meal-data:/data
|
||||||
|
environment:
|
||||||
|
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
||||||
|
- APP_BASE_URL=${APP_BASE_URL:-http://meal-tracker:5000}
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- caddy
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
meal-data:
|
meal-data:
|
||||||
|
|
||||||
|
|||||||
@@ -366,6 +366,28 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
|
|||||||
"fr": "Échec de l'envoi du test. Vérifiez votre URL ntfy et votre jeton.",
|
"fr": "Échec de l'envoi du test. Vérifiez votre URL ntfy et votre jeton.",
|
||||||
"de": "Testbenachrichtigung fehlgeschlagen. Überprüfen Sie Ihre ntfy-URL und Ihr Token.",
|
"de": "Testbenachrichtigung fehlgeschlagen. Überprüfen Sie Ihre ntfy-URL und Ihr Token.",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# ── Timezone ──
|
||||||
|
"Household Timezone": {
|
||||||
|
"fr": "Fuseau horaire du foyer",
|
||||||
|
"de": "Zeitzone des Haushalts",
|
||||||
|
},
|
||||||
|
"Used to send reminders at 10:00 (lunch) and 16:00 (dinner) local time.": {
|
||||||
|
"fr": "Utilisé pour envoyer les rappels à 10h00 (déjeuner) et 16h00 (dîner) heure locale.",
|
||||||
|
"de": "Wird verwendet, um Erinnerungen um 10:00 (Mittagessen) und 16:00 (Abendessen) Ortszeit zu senden.",
|
||||||
|
},
|
||||||
|
"Only admins can change the timezone.": {
|
||||||
|
"fr": "Seuls les administrateurs peuvent changer le fuseau horaire.",
|
||||||
|
"de": "Nur Administratoren können die Zeitzone ändern.",
|
||||||
|
},
|
||||||
|
"Please select a timezone.": {
|
||||||
|
"fr": "Veuillez sélectionner un fuseau horaire.",
|
||||||
|
"de": "Bitte wählen Sie eine Zeitzone aus.",
|
||||||
|
},
|
||||||
|
"Timezone updated.": {
|
||||||
|
"fr": "Fuseau horaire mis à jour.",
|
||||||
|
"de": "Zeitzone aktualisiert.",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ from models import (
|
|||||||
set_user_ntfy,
|
set_user_ntfy,
|
||||||
get_user_ntfy,
|
get_user_ntfy,
|
||||||
get_user_by_callback_token,
|
get_user_by_callback_token,
|
||||||
|
set_household_timezone,
|
||||||
|
get_household_by_id,
|
||||||
)
|
)
|
||||||
from i18n import t
|
from i18n import t
|
||||||
from ntfy import send_test_notification
|
from ntfy import send_test_notification
|
||||||
@@ -73,6 +75,7 @@ def dashboard():
|
|||||||
|
|
||||||
data = get_dashboard_data(today, household_id)
|
data = get_dashboard_data(today, household_id)
|
||||||
ntfy = get_user_ntfy(session["user_id"])
|
ntfy = get_user_ntfy(session["user_id"])
|
||||||
|
household = get_household_by_id(household_id)
|
||||||
return render_template(
|
return render_template(
|
||||||
"dashboard.html",
|
"dashboard.html",
|
||||||
dashboard=data,
|
dashboard=data,
|
||||||
@@ -83,6 +86,7 @@ def dashboard():
|
|||||||
current_user_id=session["user_id"],
|
current_user_id=session["user_id"],
|
||||||
is_admin=session["is_admin"],
|
is_admin=session["is_admin"],
|
||||||
ntfy=ntfy,
|
ntfy=ntfy,
|
||||||
|
household=household,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -132,6 +136,7 @@ def history():
|
|||||||
|
|
||||||
data = get_dashboard_data(target_date, session["household_id"])
|
data = get_dashboard_data(target_date, session["household_id"])
|
||||||
ntfy = get_user_ntfy(session["user_id"])
|
ntfy = get_user_ntfy(session["user_id"])
|
||||||
|
household = get_household_by_id(session["household_id"])
|
||||||
return render_template(
|
return render_template(
|
||||||
"dashboard.html",
|
"dashboard.html",
|
||||||
dashboard=data,
|
dashboard=data,
|
||||||
@@ -143,6 +148,7 @@ def history():
|
|||||||
is_admin=session["is_admin"],
|
is_admin=session["is_admin"],
|
||||||
history_mode=True,
|
history_mode=True,
|
||||||
ntfy=ntfy,
|
ntfy=ntfy,
|
||||||
|
household=household,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -221,6 +227,24 @@ def admin_delete_household():
|
|||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
|
||||||
|
@meals_bp.route("/admin/timezone", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def admin_set_timezone():
|
||||||
|
"""Admin sets the household timezone."""
|
||||||
|
if not session["is_admin"]:
|
||||||
|
flash(t("Only admins can change the timezone."), "error")
|
||||||
|
return redirect(url_for("meals.dashboard"))
|
||||||
|
|
||||||
|
timezone = request.form.get("timezone", "").strip()
|
||||||
|
if not timezone:
|
||||||
|
flash(t("Please select a timezone."), "error")
|
||||||
|
return redirect(url_for("meals.dashboard"))
|
||||||
|
|
||||||
|
set_household_timezone(session["household_id"], timezone)
|
||||||
|
flash(t("Timezone updated."), "success")
|
||||||
|
return redirect(url_for("meals.dashboard"))
|
||||||
|
|
||||||
|
|
||||||
# ── Ntfy integration ─────────────────────────────────────────────────────────
|
# ── Ntfy integration ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@meals_bp.route("/ntfy-settings", methods=["POST"])
|
@meals_bp.route("/ntfy-settings", methods=["POST"])
|
||||||
|
|||||||
@@ -76,6 +76,15 @@ def init_db() -> None:
|
|||||||
if "callback_token" not in cols:
|
if "callback_token" not in cols:
|
||||||
db.execute("ALTER TABLE users ADD COLUMN callback_token TEXT DEFAULT ''")
|
db.execute("ALTER TABLE users ADD COLUMN callback_token TEXT DEFAULT ''")
|
||||||
|
|
||||||
|
# Migration: add timezone and reminder tracking to households
|
||||||
|
hh_cols = [r["name"] for r in db.execute("PRAGMA table_info(households)").fetchall()]
|
||||||
|
if "timezone" not in hh_cols:
|
||||||
|
db.execute("ALTER TABLE households ADD COLUMN timezone TEXT DEFAULT 'UTC'")
|
||||||
|
if "last_lunch_reminder" not in hh_cols:
|
||||||
|
db.execute("ALTER TABLE households ADD COLUMN last_lunch_reminder TEXT DEFAULT ''")
|
||||||
|
if "last_dinner_reminder" not in hh_cols:
|
||||||
|
db.execute("ALTER TABLE households ADD COLUMN last_dinner_reminder TEXT DEFAULT ''")
|
||||||
|
|
||||||
|
|
||||||
# ── Households ────────────────────────────────────────────────────────────────
|
# ── Households ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -130,6 +139,21 @@ def delete_household(household_id: int) -> None:
|
|||||||
db.execute("DELETE FROM households WHERE id = ?", (household_id,))
|
db.execute("DELETE FROM households WHERE id = ?", (household_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def set_household_timezone(household_id: int, timezone: str) -> None:
|
||||||
|
"""Update the timezone for a household."""
|
||||||
|
with get_db() as db:
|
||||||
|
db.execute("UPDATE households SET timezone = ? WHERE id = ?",
|
||||||
|
(timezone, household_id))
|
||||||
|
|
||||||
|
|
||||||
|
def update_reminder_sent(household_id: int, meal_type: str, date_str: str) -> None:
|
||||||
|
"""Mark a reminder as sent for the given meal and date."""
|
||||||
|
col = "last_lunch_reminder" if meal_type == "lunch" else "last_dinner_reminder"
|
||||||
|
with get_db() as db:
|
||||||
|
db.execute(f"UPDATE households SET {col} = ? WHERE id = ?",
|
||||||
|
(date_str, household_id))
|
||||||
|
|
||||||
|
|
||||||
# ── Users ─────────────────────────────────────────────────────────────────────
|
# ── Users ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_user_by_id(user_id: int) -> dict | None:
|
def get_user_by_id(user_id: int) -> dict | None:
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#!/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()
|
||||||
@@ -113,6 +113,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{% if is_admin %}
|
||||||
|
<form method="POST" action="{{ url_for('meals.admin_set_timezone') }}" class="manage-form">
|
||||||
|
<h4>🕐 {{ t('Household Timezone') }}</h4>
|
||||||
|
<p class="hint">{{ t('Used to send reminders at 10:00 (lunch) and 16:00 (dinner) local time.') }}</p>
|
||||||
|
<select name="timezone" class="select-input">
|
||||||
|
{% set tz = household.timezone or 'UTC' %}
|
||||||
|
{% for zone in ['UTC', 'Europe/Zurich', 'Europe/Paris', 'Europe/Berlin', 'Europe/London',
|
||||||
|
'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
|
||||||
|
'America/Toronto', 'America/Sao_Paulo', 'America/Argentina/Buenos_Aires',
|
||||||
|
'Asia/Tokyo', 'Asia/Shanghai', 'Asia/Kolkata', 'Asia/Dubai',
|
||||||
|
'Australia/Sydney', 'Pacific/Auckland', 'Africa/Cairo', 'Africa/Johannesburg'] %}
|
||||||
|
<option value="{{ zone }}" {% if zone == tz %}selected{% endif %}>{{ zone }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm" style="margin-top: 8px;">{{ t('Save') }}</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if is_admin %}
|
{% if is_admin %}
|
||||||
<form method="POST" action="{{ url_for('meals.admin_delete_household') }}"
|
<form method="POST" action="{{ url_for('meals.admin_delete_household') }}"
|
||||||
onsubmit="return confirm(this.dataset.confirmMsg);"
|
onsubmit="return confirm(this.dataset.confirmMsg);"
|
||||||
|
|||||||
@@ -229,3 +229,65 @@ class TestNtfyTestEndpoint:
|
|||||||
"ntfy_url": "https://ntfy.sh/test",
|
"ntfy_url": "https://ntfy.sh/test",
|
||||||
})
|
})
|
||||||
assert resp.status_code == 302
|
assert resp.status_code == 302
|
||||||
|
|
||||||
|
|
||||||
|
class TestTimezoneSettings:
|
||||||
|
def test_admin_can_set_timezone(self, admin_client):
|
||||||
|
resp = admin_client.post("/admin/timezone", data={
|
||||||
|
"timezone": "Europe/Zurich",
|
||||||
|
}, follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
from models import get_db
|
||||||
|
db = get_db()
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT timezone FROM households WHERE name = ?", ("Admin Household",)
|
||||||
|
).fetchone()
|
||||||
|
assert row["timezone"] == "Europe/Zurich"
|
||||||
|
|
||||||
|
def test_non_admin_cannot_set_timezone(self, client):
|
||||||
|
# Register a regular user in an existing household
|
||||||
|
client.post("/register", data={
|
||||||
|
"username": "admin_tz",
|
||||||
|
"password": "admin_tz_pass",
|
||||||
|
"confirm": "admin_tz_pass",
|
||||||
|
"household_action": "create",
|
||||||
|
"new_household_name": "TZ Household",
|
||||||
|
}, follow_redirects=True)
|
||||||
|
client.get("/logout")
|
||||||
|
|
||||||
|
from models import get_household_by_name
|
||||||
|
hh = get_household_by_name("TZ Household")
|
||||||
|
client.post("/register", data={
|
||||||
|
"username": "regular_tz",
|
||||||
|
"password": "regular_tz_pass",
|
||||||
|
"confirm": "regular_tz_pass",
|
||||||
|
"household_action": "join",
|
||||||
|
"household_id": str(hh["id"]),
|
||||||
|
}, follow_redirects=True)
|
||||||
|
|
||||||
|
resp = client.post("/admin/timezone", data={
|
||||||
|
"timezone": "Europe/Zurich",
|
||||||
|
}, follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Timezone should NOT have changed
|
||||||
|
from models import get_db
|
||||||
|
db = get_db()
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT timezone FROM households WHERE id = ?", (hh["id"],)
|
||||||
|
).fetchone()
|
||||||
|
assert row["timezone"] == "UTC" # unchanged
|
||||||
|
|
||||||
|
def test_default_timezone_is_utc(self, admin_client):
|
||||||
|
from models import get_db
|
||||||
|
db = get_db()
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT timezone FROM households WHERE name = ?", ("Admin Household",)
|
||||||
|
).fetchone()
|
||||||
|
assert row["timezone"] == "UTC"
|
||||||
|
|
||||||
|
def test_timezone_requires_login(self, client):
|
||||||
|
resp = client.post("/admin/timezone", data={
|
||||||
|
"timezone": "Europe/Zurich",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 302
|
||||||
|
|||||||
Reference in New Issue
Block a user