From 35e6a7b56ee978adfdcd6b4cbdaa465678fba754 Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Sun, 12 Apr 2026 20:37:57 +0200 Subject: [PATCH] Stabilize Cloudron SQLite and refine planner suggestions --- nouri/admin.py | 28 ++++++ nouri/db.py | 2 + nouri/main.py | 129 +++++++++++++++++++------- nouri/static/js/ui.js | 39 +++++++- nouri/templates/admin/categories.html | 6 ++ nouri/templates/planner/day.html | 7 +- start.sh | 2 +- 7 files changed, 173 insertions(+), 40 deletions(-) diff --git a/nouri/admin.py b/nouri/admin.py index dbf442c..c820192 100644 --- a/nouri/admin.py +++ b/nouri/admin.py @@ -289,3 +289,31 @@ def category_update(category_id: int): get_db().commit() flash("Die Zuordnung wurde aktualisiert.", "success") return redirect(url_for("admin.category_settings")) + + +@admin_bp.post("/categories//delete") +@admin_required +def category_delete(category_id: int): + category = get_db().execute( + """ + SELECT * + FROM household_categories + WHERE id = ? AND household_id = ? + """, + (category_id, g.user["household_id"]), + ).fetchone() + if category is None: + flash("Die Kategorie wurde nicht gefunden.", "error") + return redirect(url_for("admin.category_settings")) + + if category["name"] in DEFAULT_CATEGORIES: + flash("Standardkategorien bleiben erhalten. Du kannst sie bei Bedarf pausieren.", "info") + return redirect(url_for("admin.category_settings")) + + get_db().execute( + "DELETE FROM household_categories WHERE id = ? AND household_id = ?", + (category_id, g.user["household_id"]), + ) + get_db().commit() + flash("Die Kategorie wurde entfernt.", "success") + return redirect(url_for("admin.category_settings")) diff --git a/nouri/db.py b/nouri/db.py index efbdd69..5bf0cf4 100644 --- a/nouri/db.py +++ b/nouri/db.py @@ -18,9 +18,11 @@ def get_db() -> sqlite3.Connection: g.db = sqlite3.connect( current_app.config["DATABASE_PATH"], detect_types=sqlite3.PARSE_DECLTYPES, + timeout=30, ) g.db.row_factory = sqlite3.Row g.db.execute("PRAGMA foreign_keys = ON") + g.db.execute("PRAGMA busy_timeout = 30000") return g.db diff --git a/nouri/main.py b/nouri/main.py index c9a9044..f00c638 100644 --- a/nouri/main.py +++ b/nouri/main.py @@ -4,6 +4,7 @@ from collections import defaultdict from datetime import date, datetime, timedelta from itertools import product from pathlib import Path +import sqlite3 from flask import ( Blueprint, @@ -79,8 +80,10 @@ def refresh_due_context(): if getattr(g, "user", None) is None: return None if request.method == "GET" and endpoint.startswith("main."): - ensure_user_settings_row() - activate_due_shopping_needs() + try: + activate_due_shopping_needs() + except sqlite3.OperationalError: + current_app.logger.warning("Due shopping needs could not be activated during this request.") return None @@ -172,19 +175,54 @@ def get_household_settings() -> dict: } -def ensure_user_settings_row() -> None: +def default_user_settings() -> dict: + suggestion_style = "balanced" + return { + "user_id": int(g.user["id"]), + "reminders_enabled": True, + "push_enabled": False, + "notification_channel": "in_app", + "suggestion_style": suggestion_style, + "energy_preference": suggestion_style_energy_preference(suggestion_style), + "remind_before_shopping": True, + "remind_on_shopping_day": True, + "show_missing_for_upcoming_week": True, + "show_planned_not_shopped": True, + "remind_tomorrow_if_sparse": True, + "remind_week_if_sparse": True, + "push_missing_breakfast": False, + "push_missing_lunch": False, + "push_missing_dinner": False, + "suggest_home_for_today": True, + "remind_small_snack": False, + "remind_nuts": False, + "show_meal_balancing": True, + "suggest_templates": True, + "suggest_patterns": True, + } + + +def ensure_user_settings_row(*, commit: bool = False) -> None: + existing = get_db().execute( + "SELECT 1 FROM user_settings WHERE user_id = ? LIMIT 1", + (g.user["id"],), + ).fetchone() + if existing is not None: + return get_db().execute( - "INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)", + "INSERT INTO user_settings (user_id) VALUES (?)", (g.user["id"],), ) + if commit: + get_db().commit() def get_user_settings() -> dict: - ensure_user_settings_row() + settings = default_user_settings() row = get_db().execute("SELECT * FROM user_settings WHERE user_id = ?", (g.user["id"],)).fetchone() if row is None: - return {} - settings = dict(row) + return settings + settings.update(dict(row)) boolean_fields = { "reminders_enabled", "push_enabled", @@ -1239,57 +1277,76 @@ def build_generated_meal_name(combo: list[dict], daypart_slug: str) -> str: def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, limit: int) -> list[dict]: - builder_groups: dict[str, list[dict]] = defaultdict(list) - for food in home_foods: - for builder_key in food.get("builder_keys", ["neutral"]): - builder_groups[builder_key].append(food) - if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}: target_patterns = [ - ("carb", "dairy", "fruit"), - ("carb", "dairy", "nuts"), - ("carb", "dairy", "seeds"), - ("carb", "fruit", "dairy"), + { + "slots": ({"carb"}, {"dairy", "protein"}, {"fruit", "nuts", "seeds"}), + "reason": "Passt gut zu Frühstück oder Snack", + }, + { + "slots": ({"carb"}, {"dairy", "protein"}), + "reason": "Zuhause schnell kombinierbar", + }, + { + "slots": ({"dairy", "protein"}, {"fruit", "nuts", "seeds"}), + "reason": "Lässt sich gut als kleiner Snack vormerken", + }, ] - reasons = { - ("carb", "dairy", "fruit"): "Passt gut zu Frühstück oder Snack", - ("carb", "dairy", "nuts"): "Lässt sich gut für einen Snack vormerken", - ("carb", "dairy", "seeds"): "Lässt sich gut für einen Snack vormerken", - ("carb", "fruit", "dairy"): "Zuhause gut kombinierbar", - } else: target_patterns = [ - ("protein", "carb", "veg"), - ("protein", "carb"), + { + "slots": ({"protein"}, {"carb"}, {"veg"}), + "reason": "Zuhause als vollständige Mahlzeit möglich", + }, + { + "slots": ({"protein"}, {"carb"}), + "reason": "Lässt sich leicht ergänzen", + }, + { + "slots": ({"protein"}, {"veg"}), + "reason": "Zuhause schon gut kombinierbar", + }, + { + "slots": ({"carb"}, {"veg"}), + "reason": "Daraus kann schnell etwas Einfaches werden", + }, ] - reasons = { - ("protein", "carb", "veg"): "Zuhause als vollständige Mahlzeit möglich", - ("protein", "carb"): "Lässt sich leicht ergänzen", - } suggestions: list[dict] = [] seen_signatures: set[tuple[int, ...]] = set() + def slot_matches(food: dict, slot_keys: set[str]) -> bool: + return bool(slot_keys & set(food.get("builder_keys", ["neutral"]))) + for pattern in target_patterns: - groups = [builder_groups.get(builder_key, []) for builder_key in pattern] - if any(not group for group in groups): + slot_candidates = [] + for slot_keys in pattern["slots"]: + matches = [food for food in home_foods if slot_matches(food, slot_keys)] + if not matches: + slot_candidates = [] + break + slot_candidates.append(matches) + if not slot_candidates: continue - for combo in product(*groups): + + for combo in product(*slot_candidates): signature = normalized_component_signature([item["id"] for item in combo]) - if len(signature) != len(pattern) or signature in seen_signatures: + if len(signature) != len(combo) or signature in seen_signatures: continue seen_signatures.add(signature) combo_items = list(combo) suggestions.append( { "title": build_generated_meal_name(combo_items, daypart_slug), - "reason": reasons.get(pattern, "Zuhause gut kombinierbar"), + "reason": pattern["reason"], "component_ids": [item["id"] for item in combo_items], "existing_item_id": None, } ) - if len(suggestions) >= limit: - return suggestions + if len(suggestions) >= limit * 3: + break + if len(suggestions) >= limit * 3: + break return suggestions @@ -1719,6 +1776,7 @@ def build_day_planner_sections( + [item for item in candidates if item["kind"] == "food"], limit=20, ) + search_candidates = dedupe_items(meal_candidates + food_candidates, limit=24) entry_item_ids = [int(entry["item_id"]) for entry in entries] sections.append( { @@ -1727,6 +1785,7 @@ def build_day_planner_sections( "candidates": candidates, "meal_candidates": meal_candidates, "food_candidates": food_candidates, + "search_candidates": search_candidates, "recipe_suggestions": build_home_recipe_suggestions(int(daypart["id"]), limit=3), "suggestions": build_daypart_suggestions(daypart["id"]), "balance_suggestion": build_balance_suggestion(int(daypart["id"]), entry_item_ids), diff --git a/nouri/static/js/ui.js b/nouri/static/js/ui.js index ff89a60..fb19af5 100644 --- a/nouri/static/js/ui.js +++ b/nouri/static/js/ui.js @@ -78,8 +78,8 @@ const applyFilter = () => { const term = input.value.trim().toLowerCase(); if (!term) { - items.forEach((item) => { - item.hidden = false; + items.forEach((item, index) => { + item.hidden = hasLimit ? index >= resultLimit : false; }); syncGroups(); return; @@ -109,8 +109,43 @@ }); }; + const initIosPullToRefresh = () => { + const isAppleTouchDevice = /iP(ad|hone|od)/.test(navigator.userAgent) + || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); + if (!isAppleTouchDevice) return; + + let startY = 0; + let maxPull = 0; + let tracking = false; + + window.addEventListener("touchstart", (event) => { + if (window.scrollY > 0) { + tracking = false; + return; + } + startY = event.touches[0].clientY; + maxPull = 0; + tracking = true; + }, { passive: true }); + + window.addEventListener("touchmove", (event) => { + if (!tracking) return; + const currentY = event.touches[0].clientY; + maxPull = Math.max(maxPull, currentY - startY); + }, { passive: true }); + + window.addEventListener("touchend", () => { + if (tracking && maxPull > 96 && window.scrollY <= 2) { + window.location.reload(); + } + tracking = false; + maxPull = 0; + }, { passive: true }); + }; + document.addEventListener("DOMContentLoaded", () => { initMobileSheet(); initFilterInputs(); + initIosPullToRefresh(); }); })(); diff --git a/nouri/templates/admin/categories.html b/nouri/templates/admin/categories.html index db7f2e3..2534bb1 100644 --- a/nouri/templates/admin/categories.html +++ b/nouri/templates/admin/categories.html @@ -66,6 +66,12 @@ {% if category.is_active %}Pausieren{% else %}Wieder aktivieren{% endif %} + {% if category.name not in default_categories %} +
+ {{ csrf_input() }} + +
+ {% endif %} {% endfor %} diff --git a/nouri/templates/planner/day.html b/nouri/templates/planner/day.html index 3775b90..5c83bfd 100644 --- a/nouri/templates/planner/day.html +++ b/nouri/templates/planner/day.html @@ -175,7 +175,7 @@
- {% for item in section.food_candidates %} + {% for item in section.search_candidates %}
{{ csrf_input() }} @@ -184,7 +184,10 @@
{% endfor %} diff --git a/start.sh b/start.sh index fb512ea..05c7427 100755 --- a/start.sh +++ b/start.sh @@ -20,4 +20,4 @@ if [ "${NOURI_RUN_REMINDER_WORKER:-1}" = "1" ]; then ) & fi -exec gunicorn --bind 0.0.0.0:8000 --workers 2 --threads 4 wsgi:app +exec gunicorn --bind 0.0.0.0:8000 --workers 1 --threads 4 wsgi:app