release nouri 1.0.0

This commit is contained in:
2026-04-12 19:18:55 +02:00
parent b0d1cee5f5
commit 325101da99
17 changed files with 769 additions and 45 deletions
+201
View File
@@ -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)