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, "label": "Frühstück"}, {"slug": "lunch", "setting": "push_missing_lunch", "hour": 12, "minute": 0, "label": "Mittagessen"}, {"slug": "dinner", "setting": "push_missing_dinner", "hour": 18, "minute": 0, "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) -> bool: target = now.replace(hour=hour, minute=minute, second=0, microsecond=0) delta = (now - target).total_seconds() return 0 <= delta < 180 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"]): 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)