release nouri 1.0.0
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user