203 lines
6.9 KiB
Python
203 lines
6.9 KiB
Python
from __future__ import annotations
|
|
|
|
import time
|
|
from datetime import date, datetime
|
|
from urllib.parse import quote
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from flask import current_app, g
|
|
|
|
from .db import get_db
|
|
from .main import build_home_recipe_suggestions, get_user_settings
|
|
from .push import send_push_message
|
|
|
|
|
|
MEAL_PUSH_RULES = [
|
|
{"slug": "breakfast", "setting": "push_missing_breakfast", "hour": 8, "minute": 0, "end_hour": 12, "label": "Frühstück"},
|
|
{"slug": "lunch", "setting": "push_missing_lunch", "hour": 12, "minute": 0, "end_hour": 18, "label": "Mittagessen"},
|
|
{"slug": "dinner", "setting": "push_missing_dinner", "hour": 18, "minute": 0, "end_hour": 24, "label": "Abendessen"},
|
|
]
|
|
|
|
|
|
def current_local_time() -> datetime:
|
|
timezone_name = current_app.config.get("TIMEZONE", "Europe/Berlin")
|
|
try:
|
|
timezone = ZoneInfo(timezone_name)
|
|
except Exception:
|
|
timezone = ZoneInfo("Europe/Berlin")
|
|
return datetime.now(timezone)
|
|
|
|
|
|
def push_delivery_channel_enabled(settings: dict) -> bool:
|
|
return (
|
|
settings.get("reminders_enabled")
|
|
and settings.get("push_enabled")
|
|
and settings.get("notification_channel") in {"push", "both"}
|
|
)
|
|
|
|
|
|
def fetch_push_ready_users() -> list:
|
|
return get_db().execute(
|
|
"""
|
|
SELECT users.*
|
|
FROM users
|
|
JOIN user_settings ON user_settings.user_id = users.id
|
|
WHERE users.is_active = 1
|
|
AND user_settings.reminders_enabled = 1
|
|
AND user_settings.push_enabled = 1
|
|
AND user_settings.notification_channel IN ('push', 'both')
|
|
ORDER BY users.id
|
|
"""
|
|
).fetchall()
|
|
|
|
|
|
def fetch_daypart_map() -> dict[str, dict]:
|
|
return {
|
|
row["slug"]: {"id": int(row["id"]), "name": row["name"]}
|
|
for row in get_db().execute("SELECT id, slug, name FROM dayparts").fetchall()
|
|
}
|
|
|
|
|
|
def plan_exists_for_daypart(user, *, planned_date: date, daypart_id: int) -> bool:
|
|
row = get_db().execute(
|
|
"""
|
|
SELECT COUNT(*) AS count
|
|
FROM plan_entries
|
|
WHERE household_id = ?
|
|
AND plan_date = ?
|
|
AND daypart_id = ?
|
|
AND (visibility = 'shared' OR owner_user_id = ?)
|
|
""",
|
|
(int(user["household_id"]), planned_date.isoformat(), daypart_id, int(user["id"])),
|
|
).fetchone()
|
|
return bool(int(row["count"] or 0))
|
|
|
|
|
|
def reminder_event_exists(user_id: int, event_key: str) -> bool:
|
|
row = get_db().execute(
|
|
"SELECT 1 FROM reminder_events WHERE user_id = ? AND event_key = ? LIMIT 1",
|
|
(user_id, event_key),
|
|
).fetchone()
|
|
return row is not None
|
|
|
|
|
|
def mark_reminder_event(user_id: int, event_key: str) -> None:
|
|
get_db().execute(
|
|
"""
|
|
INSERT OR IGNORE INTO reminder_events (user_id, event_key)
|
|
VALUES (?, ?)
|
|
""",
|
|
(user_id, event_key),
|
|
)
|
|
get_db().commit()
|
|
|
|
|
|
def due_for_rule(now: datetime, *, hour: int, minute: int, end_hour: int) -> bool:
|
|
current_minutes = (now.hour * 60) + now.minute
|
|
target_minutes = (hour * 60) + minute
|
|
end_minutes = end_hour * 60
|
|
return target_minutes <= current_minutes < end_minutes
|
|
|
|
|
|
def build_push_target_url(*, planned_date: date, daypart_id: int, suggestion: dict | None) -> str:
|
|
base = f"/planner/day?date={planned_date.isoformat()}&daypart_id={daypart_id}"
|
|
if not suggestion:
|
|
return f"{base}#daypart-{daypart_id}"
|
|
if suggestion.get("existing_item_id"):
|
|
return f"{base}&item_id={int(suggestion['existing_item_id'])}#daypart-{daypart_id}"
|
|
component_ids = ",".join(str(component_id) for component_id in suggestion.get("component_ids", []))
|
|
if suggestion.get("title") and component_ids:
|
|
meal_name = quote(str(suggestion["title"]))
|
|
return f"{base}&meal_name={meal_name}&component_ids={component_ids}#daypart-{daypart_id}"
|
|
return f"{base}#daypart-{daypart_id}"
|
|
|
|
|
|
def build_push_message(label: str, suggestion: dict | None) -> tuple[str, str]:
|
|
title = f"Nouri · {label}"
|
|
if suggestion and suggestion.get("title"):
|
|
return title, f"Für {label.lower()} ist noch nichts geplant. Möglich wäre gerade: {suggestion['title']}."
|
|
return title, f"Für {label.lower()} ist noch nichts geplant."
|
|
|
|
|
|
def best_suggestion_for_user(user, daypart_id: int) -> dict | None:
|
|
previous_user = getattr(g, "user", None)
|
|
g.user = user
|
|
try:
|
|
suggestions = build_home_recipe_suggestions(daypart_id, limit=1)
|
|
finally:
|
|
g.user = previous_user
|
|
return suggestions[0] if suggestions else None
|
|
|
|
|
|
def send_due_meal_pushes(now: datetime | None = None) -> int:
|
|
now = now or current_local_time()
|
|
planned_date = now.date()
|
|
sent_count = 0
|
|
dayparts = fetch_daypart_map()
|
|
|
|
for user in fetch_push_ready_users():
|
|
g.user = user
|
|
settings = get_user_settings()
|
|
if not push_delivery_channel_enabled(settings):
|
|
continue
|
|
|
|
subscriptions = get_db().execute(
|
|
"""
|
|
SELECT endpoint, p256dh, auth
|
|
FROM push_subscriptions
|
|
WHERE user_id = ? AND is_active = 1
|
|
ORDER BY updated_at DESC
|
|
""",
|
|
(int(user["id"]),),
|
|
).fetchall()
|
|
if not subscriptions:
|
|
continue
|
|
|
|
for rule in MEAL_PUSH_RULES:
|
|
if not settings.get(rule["setting"]):
|
|
continue
|
|
if not due_for_rule(now, hour=rule["hour"], minute=rule["minute"], end_hour=rule["end_hour"]):
|
|
continue
|
|
|
|
daypart = dayparts.get(rule["slug"])
|
|
if not daypart:
|
|
continue
|
|
if plan_exists_for_daypart(user, planned_date=planned_date, daypart_id=daypart["id"]):
|
|
continue
|
|
|
|
event_key = f"meal-push:{planned_date.isoformat()}:{rule['slug']}"
|
|
if reminder_event_exists(int(user["id"]), event_key):
|
|
continue
|
|
|
|
suggestion = best_suggestion_for_user(user, daypart["id"])
|
|
title, body = build_push_message(rule["label"], suggestion)
|
|
url = build_push_target_url(planned_date=planned_date, daypart_id=daypart["id"], suggestion=suggestion)
|
|
|
|
delivered = False
|
|
for subscription in subscriptions:
|
|
ok, _error = send_push_message(
|
|
{
|
|
"endpoint": subscription["endpoint"],
|
|
"keys": {"p256dh": subscription["p256dh"], "auth": subscription["auth"]},
|
|
},
|
|
title=title,
|
|
body=body,
|
|
url=url,
|
|
)
|
|
delivered = delivered or ok
|
|
|
|
if delivered:
|
|
mark_reminder_event(int(user["id"]), event_key)
|
|
sent_count += 1
|
|
|
|
return sent_count
|
|
|
|
|
|
def reminder_worker_loop(sleep_seconds: int = 60) -> None:
|
|
while True:
|
|
try:
|
|
send_due_meal_pushes()
|
|
except Exception as exc: # pragma: no cover - background worker fallback
|
|
current_app.logger.warning("Reminder worker skipped one cycle: %s", exc)
|
|
time.sleep(sleep_seconds)
|