diff --git a/CloudronManifest.json b/CloudronManifest.json index eef58bd..95e32e1 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -4,8 +4,8 @@ "author": "Florian Heinz", "description": "Private Flask app for meals, shopping and gentle food planning", "tagline": "einfach essen planen", - "version": "0.4.0", - "upstreamVersion": "0.4.0", + "version": "0.5.0", + "upstreamVersion": "0.5.0", "healthCheckPath": "/", "httpPort": 8000, "manifestVersion": 2, diff --git a/Dockerfile.cloudron b/Dockerfile.cloudron index 3ad19d1..a83042e 100644 --- a/Dockerfile.cloudron +++ b/Dockerfile.cloudron @@ -2,19 +2,30 @@ FROM python:3.13-slim WORKDIR /app/code -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=1 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV PORT=8000 -RUN apt-get update && apt-get install -y --no-install-recommends sqlite3 \ +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + sqlite3 \ && rm -rf /var/lib/apt/lists/* +RUN useradd -r -m -d /home/cloudron cloudron + COPY requirements.txt /app/code/ -RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir -r requirements.txt gunicorn COPY . /app/code -RUN chmod +x /app/code/start.sh +# Lokale Daten für den ersten Start sichern und danach /app/code/data auf /app/data zeigen lassen +RUN if [ -d /app/code/data ]; then mv /app/code/data /app/bootstrap-data; else mkdir -p /app/bootstrap-data; fi \ + && ln -s /app/data /app/code/data \ + && chmod +x /app/code/start.sh \ + && chown -R cloudron:cloudron /app/code /app/bootstrap-data \ + && chown -h cloudron:cloudron /app/code/data + +USER cloudron EXPOSE 8000 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..2e6e4da --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,52 @@ +PolyForm Noncommercial License 1.0.0 + +Acceptance + +In order to get any license under these terms, you must agree to them as +both strict obligations and conditions to all your licenses. + +Copyright License + +The licensor grants you a copyright license for the software to do everything +you might do with the software that would otherwise infringe the licensor's +copyright in it for any permitted purpose. + +Limitations + +You may use this software for private, personal, internal, or other +noncommercial purposes only. + +Any use of this software or any derivative work for commercial advantage or +monetary compensation is not permitted under this license. + +Distribution of modified or unmodified copies must include this license text +and all copyright notices. + +Patent License + +The licensor grants you a patent license for the software that covers patent +claims the licensor can license, or becomes able to license, that you would +otherwise infringe by using the software for any permitted purpose. + +Notices + +You must ensure that anyone who gets a copy of any part of this software from +you also gets a copy of these terms. + +No Liability + +As far as the law allows, the software comes as is, without any warranty or +condition, and the licensor will not be liable to you for any damages arising +out of these terms or the use or nature of the software, under any kind of +legal claim. + +Definitions + +The licensor is the person or entity offering these terms, and the software is +the software the licensor makes available under these terms, including any +portion of it. + +Noncommercial means not primarily intended for or directed toward commercial +advantage or monetary compensation. + +If you need a standardized reference text, see https://polyformproject.org/licenses/noncommercial/1.0.0/ diff --git a/README.md b/README.md index eb3828b..fac8afb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Einkäufe, vorhandene Lebensmittel und eine einfache Tages- oder Wochenplanung ruhig und alltagsnah festzuhalten. -## Merkmale in Version 0.4 +## Merkmale in Version 0.5 - Lebensmittel und Mahlzeitenideen anlegen - Fotos lokal hochladen @@ -19,10 +19,15 @@ Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Ein - kompaktere mobile Navigation mit Bottom-Bar - Tagesvorlagen und Wochenvorlagen - kleine Pakete für wiederkehrende Einkaufs- oder Planungsbausteine -- sanfte Hinweise und Vorschläge aus Zuhause, Archiv und bisherigen Planungen +- ruhige Hinweise und Vorschläge aus Zuhause, Archiv und bisherigen Planungen - globale Kategorien pro Haushalt - „Für wen?“ direkt an Lebensmitteln und Mahlzeiten - Mobile-Mehr-Menü als Sheet statt eigener Seite +- Einkaufsrhythmus mit geplantem Einkaufstag und später aktivierten Bedarfen +- ausgewogene Ergänzungsvorschläge auf Basis ruhiger Bausteine +- einfache Kombinations- und Rezeptideen aus zuhause vorhandenen Lebensmitteln +- Optionen für Erinnerungen, Hinweise und kleine Routinen +- PWA-Vorbereitung mit Web App Manifest, Service Worker und optionalem Web Push ## Lokal starten @@ -44,10 +49,13 @@ Wichtige Umgebungsvariablen: - `NOURI_SECRET_KEY`: Session-Secret für Produktion - `NOURI_DATA_DIR`: Pfad für Datenbank und Uploads, z. B. `/app/data` auf Cloudron - `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5` +- `NOURI_VAPID_PUBLIC_KEY`: öffentlicher VAPID-Schlüssel für Web Push +- `NOURI_VAPID_PRIVATE_KEY`: privater VAPID-Schlüssel für Web Push +- `NOURI_VAPID_SUBJECT`: Kontaktangabe für Web Push, z. B. `mailto:mail@hnz.io` -## Migration von 0.3 auf 0.4 +## Migration von 0.4 auf 0.5 -Beim Start erweitert Nouri das Schema pragmatisch direkt in SQLite weiter: Vorlagen, kleine Pakete, Kategorien pro Haushalt, Zielnutzer an Lebensmitteln und zusätzliche Einkaufs-Kontexte werden ergänzt. Vorhandene 0.3-Daten bleiben erhalten und werden weiterverwendet. +Beim Start erweitert Nouri das Schema pragmatisch direkt in SQLite weiter: Einkaufsrhythmus, vorgemerkte Bedarfe, Nutzer-Einstellungen, Push-Registrierungen und Baustein-Zuordnungen für Kategorien werden ergänzt. Vorhandene 0.4-Daten bleiben erhalten und werden weiterverwendet. ## Cloudron-Hinweis @@ -61,3 +69,7 @@ Lokale Testdaten und produktive Cloudron-Daten bleiben bewusst getrennt: - `/app/data` ist auf Cloudron persistent und bleibt bei App-Updates erhalten Wenn die App auf Cloudron bereits installiert ist, bitte **kein neues `cloudron install`** ausführen. Stattdessen die bestehende App aktualisieren, also ein neues Image bzw. Paket bauen und dann die vorhandene Installation updaten. + +## Lizenz + +Nouri ist in diesem Repository für private, nicht-kommerzielle Nutzung freigegeben. Details stehen in [LICENSE.md](LICENSE.md). diff --git a/RELEASE_NOTES_0.5.0.md b/RELEASE_NOTES_0.5.0.md new file mode 100644 index 0000000..7997f6f --- /dev/null +++ b/RELEASE_NOTES_0.5.0.md @@ -0,0 +1,47 @@ +# Nouri 0.5.0 + +## Highlights + +- Einkaufsrhythmus mit geplantem Einkaufstag und ruhiger Vorlauf-Logik +- Optionen für Erinnerungen, Hinweise und kleine Routinen +- Ausgewogene Ergänzungsvorschläge ohne Diät- oder Kontrollsprache +- Einfache Rezeptideen aus zuhause vorhandenen Lebensmitteln +- PWA-Vorbereitung mit Home-Screen-Nutzung, Service Worker und optionalem Web Push +- Überarbeitetes mobiles Mehr-Menü als Karten-Sheet mit Icons + +## Neu in 0.5.0 + +### Planung und Vorschläge + +- Der Tagesplan priorisiert jetzt vorhandene Mahlzeitenideen, dann passende Kombinationsvorschläge und danach einzelne Lebensmittel. +- Für Mittag- und Abendessen zeigt Nouri kleine Ergänzungsideen, wenn noch etwas gut dazupassen könnte. +- Zuhause vorhandene Lebensmittel werden zu einfachen Frühstücks- und Hauptmahlzeit-Ideen kombiniert. + +### Einkauf + +- Fehlende Lebensmittel aus zukünftigen Planungen landen nicht mehr sofort auf der Einkaufsliste. +- Stattdessen merkt Nouri sie zunächst für spätere Einkäufe vor und aktiviert sie passend zum eingestellten Einkaufstag. +- Auch bei Mahlzeiten werden nur die tatsächlich fehlenden Lebensmittel auf den Einkauf bezogen, nicht die Mahlzeit selbst. + +### Einstellungen + +- Neuer Bereich `Optionen` für Einkaufstag, Vorlauf, Hinweise, Routinen und Push. +- Hinweise lassen sich pro Person fein, aber weiterhin niedrigschwellig steuern. +- Push kann optional je Gerät aktiviert oder wieder beendet werden. + +### Mobile und PWA + +- Web App Manifest und Service Worker sind ergänzt. +- Nouri lässt sich besser auf dem iPhone zum Home-Bildschirm hinzufügen. +- Das mobile Mehr-Menü öffnet sich als kompaktes Karten-Sheet direkt über der Bottom Navigation. + +### Haushalt und Kategorien + +- Kategorien können jetzt zusätzlich einem ruhigen Baustein zugeordnet werden, zum Beispiel Proteinquelle, Gemüse oder Obst. +- Diese Zuordnung hilft nur intern bei Vorschlägen und fühlt sich nicht wie Tracking an. + +## Technische Hinweise + +- Neue SQLite-Tabellen und Spalten werden beim Start automatisch ergänzt. +- Für Web Push werden VAPID-Schlüssel über Umgebungsvariablen unterstützt. +- Cloudron-Version wurde auf `0.5.0` angehoben. diff --git a/nouri/__init__.py b/nouri/__init__.py index 0bf8ba1..9c436a4 100644 --- a/nouri/__init__.py +++ b/nouri/__init__.py @@ -11,13 +11,18 @@ from . import db from .admin import admin_bp from .auth import auth_bp from .constants import ( + BUILDER_DESCRIPTIONS, + BUILDER_LABELS, + BUILDER_OPTIONS, DAYPARTS, DEFAULT_CATEGORIES, ITEM_KIND_LABELS, ITEM_KIND_SINGULAR_LABELS, + NOTIFICATION_CHANNEL_OPTIONS, ROLE_LABELS, VISIBILITY_DESCRIPTIONS, VISIBILITY_LABELS, + WEEKDAY_OPTIONS, ) from .main import main_bp @@ -63,6 +68,10 @@ def create_app() -> Flask: PERMANENT_SESSION_LIFETIME=timedelta(days=30), SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", + APP_VERSION="0.5.0", + VAPID_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""), + VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""), + VAPID_SUBJECT=os.environ.get("NOURI_VAPID_SUBJECT", "mailto:mail@hnz.io"), ) db.init_app(app) @@ -78,11 +87,19 @@ def create_app() -> Flask: "item_kind_labels": ITEM_KIND_LABELS, "item_kind_singular_labels": ITEM_KIND_SINGULAR_LABELS, "category_suggestions": DEFAULT_CATEGORIES, + "builder_labels": BUILDER_LABELS, + "builder_descriptions": BUILDER_DESCRIPTIONS, + "builder_options": BUILDER_OPTIONS, "daypart_suggestions": DAYPARTS, "visibility_labels": VISIBILITY_LABELS, "visibility_descriptions": VISIBILITY_DESCRIPTIONS, "role_labels": ROLE_LABELS, + "weekday_options": WEEKDAY_OPTIONS, + "notification_channel_options": NOTIFICATION_CHANNEL_OPTIONS, "today": date.today(), + "app_version": app.config["APP_VERSION"], + "push_public_key": app.config["VAPID_PUBLIC_KEY"], + "push_available": bool(app.config["VAPID_PUBLIC_KEY"] and app.config["VAPID_PRIVATE_KEY"]), "weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()], "weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()], "is_admin": lambda: bool(getattr(g, "user", None)) and g.user["role"] == "admin", @@ -92,4 +109,12 @@ def create_app() -> Flask: def uploaded_file(filename: str): return send_from_directory(app.config["UPLOAD_FOLDER"], filename) + @app.get("/app.webmanifest") + def webmanifest(): + return send_from_directory(root_dir / "nouri" / "static" / "pwa", "app.webmanifest", mimetype="application/manifest+json") + + @app.get("/service-worker.js") + def service_worker(): + return send_from_directory(root_dir / "nouri" / "static" / "pwa", "service-worker.js", mimetype="application/javascript") + return app diff --git a/nouri/admin.py b/nouri/admin.py index 7f519eb..dbf442c 100644 --- a/nouri/admin.py +++ b/nouri/admin.py @@ -4,7 +4,7 @@ from flask import Blueprint, flash, g, redirect, render_template, request, url_f from werkzeug.security import generate_password_hash from .auth import admin_required, can_remove_last_admin, validate_admin_user_form -from .constants import DEFAULT_CATEGORIES, ROLE_LABELS +from .constants import BUILDER_DESCRIPTIONS, BUILDER_OPTIONS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS, ROLE_LABELS from .db import get_db @@ -197,6 +197,7 @@ def user_edit(user_id: int): def category_settings(): if request.method == "POST": name = request.form.get("name", "").strip() + builder_key = request.form.get("builder_key", "neutral").strip() if not name: flash("Bitte einen Kategorienamen eintragen.", "error") else: @@ -210,8 +211,8 @@ def category_settings(): ).fetchone() if existing: get_db().execute( - "UPDATE household_categories SET is_active = 1 WHERE id = ?", - (existing["id"],), + "UPDATE household_categories SET is_active = 1, builder_key = ? WHERE id = ?", + (builder_key, existing["id"]), ) flash("Die Kategorie ist wieder aktiv.", "success") else: @@ -221,10 +222,10 @@ def category_settings(): ).fetchone() get_db().execute( """ - INSERT INTO household_categories (household_id, name, sort_order, is_active) - VALUES (?, ?, ?, 1) + INSERT INTO household_categories (household_id, name, builder_key, sort_order, is_active) + VALUES (?, ?, ?, ?, 1) """, - (g.user["household_id"], name, int(sort_row["max_sort"]) + 10), + (g.user["household_id"], name, builder_key, int(sort_row["max_sort"]) + 10), ) flash("Die Kategorie wurde ergänzt.", "success") get_db().commit() @@ -234,6 +235,9 @@ def category_settings(): "admin/categories.html", categories=fetch_household_categories(), default_categories=DEFAULT_CATEGORIES, + default_category_builders=DEFAULT_CATEGORY_BUILDERS, + builder_options=BUILDER_OPTIONS, + builder_descriptions=BUILDER_DESCRIPTIONS, ) @@ -260,3 +264,28 @@ def category_toggle(category_id: int): get_db().commit() flash("Die Kategorie wurde aktualisiert.", "success") return redirect(url_for("admin.category_settings")) + + +@admin_bp.post("/categories//update") +@admin_required +def category_update(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")) + + builder_key = request.form.get("builder_key", "neutral").strip() + get_db().execute( + "UPDATE household_categories SET builder_key = ? WHERE id = ?", + (builder_key, category_id), + ) + get_db().commit() + flash("Die Zuordnung wurde aktualisiert.", "success") + return redirect(url_for("admin.category_settings")) diff --git a/nouri/constants.py b/nouri/constants.py index e038155..6779f28 100644 --- a/nouri/constants.py +++ b/nouri/constants.py @@ -20,6 +20,57 @@ DEFAULT_CATEGORIES = [ "Kleines Essen", ] +DEFAULT_CATEGORY_BUILDERS = { + "Brot & Getreide": "carb", + "Milchprodukt": "dairy", + "Obst": "fruit", + "Gemüse": "veg", + "Eiweißquelle": "protein", + "Snack": "neutral", + "Getränk": "neutral", + "Vorrat & Basics": "neutral", + "Warmes": "carb", + "Kleines Essen": "neutral", +} + +BUILDER_LABELS = { + "protein": "Proteinquelle", + "carb": "Kohlenhydratquelle", + "veg": "Gemüse / Ballaststoffquelle", + "nuts": "Nüsse / Samen", + "fruit": "Obst", + "dairy": "Milchprodukt", + "neutral": "Neutral / sonstiges", +} + +BUILDER_DESCRIPTIONS = { + "protein": "Passt eher zu sättigenden Eiweißquellen.", + "carb": "Passt eher zu Brot, Getreide, Reis, Kartoffeln oder ähnlichem.", + "veg": "Passt eher zu Gemüse oder ballaststoffreichen Begleitern.", + "nuts": "Passt eher zu Nüssen oder Samen.", + "fruit": "Passt eher zu Obst.", + "dairy": "Passt eher zu Joghurt, Milch, Käse oder ähnlichem.", + "neutral": "Ohne feste Zuordnung, aber weiterhin gut nutzbar.", +} + +BUILDER_OPTIONS = [(key, label) for key, label in BUILDER_LABELS.items()] + +WEEKDAY_OPTIONS = [ + (0, "Montag"), + (1, "Dienstag"), + (2, "Mittwoch"), + (3, "Donnerstag"), + (4, "Freitag"), + (5, "Samstag"), + (6, "Sonntag"), +] + +NOTIFICATION_CHANNEL_OPTIONS = [ + ("in_app", "Nur in der App"), + ("push", "Nur Push"), + ("both", "App und Push"), +] + ITEM_KIND_LABELS = { "food": "Lebensmittel", "meal": "Mahlzeitenideen", diff --git a/nouri/db.py b/nouri/db.py index f198733..e7602e7 100644 --- a/nouri/db.py +++ b/nouri/db.py @@ -8,7 +8,7 @@ from flask import Flask, current_app, g from flask.cli import with_appcontext from werkzeug.security import generate_password_hash -from .constants import DAYPARTS, DEFAULT_CATEGORIES +from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS def get_db() -> sqlite3.Connection: @@ -53,6 +53,9 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None: CREATE TABLE IF NOT EXISTS households ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, + shopping_weekday INTEGER NOT NULL DEFAULT 5, + shopping_prep_days INTEGER NOT NULL DEFAULT 1, + shopping_reminder_time TEXT NOT NULL DEFAULT '18:00', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """ @@ -64,6 +67,7 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None: id INTEGER PRIMARY KEY AUTOINCREMENT, household_id INTEGER NOT NULL, name TEXT NOT NULL, + builder_key TEXT NOT NULL DEFAULT 'neutral', sort_order INTEGER NOT NULL DEFAULT 100, is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -72,6 +76,14 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None: """ ) + if table_exists(database, "households"): + add_column_if_missing(database, "households", "shopping_weekday INTEGER NOT NULL DEFAULT 5") + add_column_if_missing(database, "households", "shopping_prep_days INTEGER NOT NULL DEFAULT 1") + add_column_if_missing(database, "households", "shopping_reminder_time TEXT NOT NULL DEFAULT '18:00'") + + if table_exists(database, "household_categories"): + add_column_if_missing(database, "household_categories", "builder_key TEXT NOT NULL DEFAULT 'neutral'") + if table_exists(database, "users"): add_column_if_missing(database, "users", "household_id INTEGER") add_column_if_missing(database, "users", "email TEXT") @@ -92,6 +104,82 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None: add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT") add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER") + if table_exists(database, "shopping_needs"): + add_column_if_missing(database, "shopping_needs", "source_item_id INTEGER") + add_column_if_missing(database, "shopping_needs", "activation_date TEXT") + add_column_if_missing(database, "shopping_needs", "is_activated INTEGER NOT NULL DEFAULT 0") + add_column_if_missing(database, "shopping_needs", "activated_at TEXT") + + database.execute( + """ + CREATE TABLE IF NOT EXISTS user_settings ( + user_id INTEGER PRIMARY KEY, + reminders_enabled INTEGER NOT NULL DEFAULT 1, + push_enabled INTEGER NOT NULL DEFAULT 0, + notification_channel TEXT NOT NULL DEFAULT 'in_app', + remind_before_shopping INTEGER NOT NULL DEFAULT 1, + remind_on_shopping_day INTEGER NOT NULL DEFAULT 1, + show_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1, + show_planned_not_shopped INTEGER NOT NULL DEFAULT 1, + remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1, + remind_week_if_sparse INTEGER NOT NULL DEFAULT 1, + suggest_home_for_today INTEGER NOT NULL DEFAULT 1, + remind_small_snack INTEGER NOT NULL DEFAULT 0, + remind_nuts INTEGER NOT NULL DEFAULT 0, + show_meal_balancing INTEGER NOT NULL DEFAULT 1, + suggest_templates INTEGER NOT NULL DEFAULT 1, + suggest_patterns INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + + database.execute( + """ + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + user_agent TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_test_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + + database.execute( + """ + CREATE TABLE IF NOT EXISTS shopping_needs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + household_id INTEGER NOT NULL, + owner_user_id INTEGER, + visibility TEXT NOT NULL DEFAULT 'shared', + item_id INTEGER NOT NULL, + source_item_id INTEGER, + needed_for_date TEXT NOT NULL, + needed_for_daypart_id INTEGER, + activation_date TEXT NOT NULL, + is_activated INTEGER NOT NULL DEFAULT 0, + activated_at TEXT, + created_by INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (item_id, source_item_id, needed_for_date, needed_for_daypart_id, visibility), + FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, + FOREIGN KEY (source_item_id) REFERENCES items(id) ON DELETE SET NULL, + FOREIGN KEY (needed_for_daypart_id) REFERENCES dayparts(id) ON DELETE SET NULL, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL + ) + """ + ) + if table_exists(database, "plan_entries"): add_column_if_missing(database, "plan_entries", "household_id INTEGER") add_column_if_missing(database, "plan_entries", "owner_user_id INTEGER") @@ -126,10 +214,18 @@ def sync_default_categories(database: sqlite3.Connection) -> None: for sort_order, name in enumerate(DEFAULT_CATEGORIES, start=10): database.execute( """ - INSERT OR IGNORE INTO household_categories (household_id, name, sort_order, is_active) - VALUES (?, ?, ?, 1) + INSERT OR IGNORE INTO household_categories (household_id, name, builder_key, sort_order, is_active) + VALUES (?, ?, ?, ?, 1) """, - (household_id, name, sort_order), + (household_id, name, DEFAULT_CATEGORY_BUILDERS.get(name, "neutral"), sort_order), + ) + database.execute( + """ + UPDATE household_categories + SET builder_key = COALESCE(NULLIF(builder_key, ''), ?) + WHERE household_id = ? AND name = ? + """, + (DEFAULT_CATEGORY_BUILDERS.get(name, "neutral"), household_id, name), ) @@ -141,6 +237,11 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: add_column_if_missing(database, "users", "updated_at TEXT") default_household_id = ensure_default_household(database) + database.execute("UPDATE households SET shopping_weekday = COALESCE(shopping_weekday, 5)") + database.execute("UPDATE households SET shopping_prep_days = COALESCE(shopping_prep_days, 1)") + database.execute( + "UPDATE households SET shopping_reminder_time = COALESCE(NULLIF(shopping_reminder_time, ''), '18:00')" + ) database.execute( "UPDATE users SET household_id = ? WHERE household_id IS NULL", (default_household_id,), @@ -204,6 +305,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: database.execute("UPDATE plan_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''") sync_default_categories(database) + database.execute( + """ + INSERT OR IGNORE INTO user_settings (user_id) + SELECT id FROM users + """ + ) database.execute( """ @@ -236,6 +343,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: ON shopping_entries (household_id, visibility, is_checked) """ ) + database.execute( + """ + CREATE INDEX IF NOT EXISTS idx_shopping_needs_household_activation + ON shopping_needs (household_id, activation_date, is_activated) + """ + ) def apply_schema(database: sqlite3.Connection) -> None: diff --git a/nouri/main.py b/nouri/main.py index 50d3262..c27fd45 100644 --- a/nouri/main.py +++ b/nouri/main.py @@ -21,16 +21,21 @@ from werkzeug.utils import secure_filename from .auth import login_required from .constants import ( AVAILABILITY_LABELS, + BUILDER_LABELS, + DEFAULT_CATEGORY_BUILDERS, DAY_TEMPLATE_NAME_SUGGESTIONS, DEFAULT_CATEGORIES, ITEM_KIND_LABELS, ITEM_KIND_SINGULAR_LABELS, ITEM_SET_NAME_SUGGESTIONS, + NOTIFICATION_CHANNEL_OPTIONS, VISIBILITY_DESCRIPTIONS, VISIBILITY_LABELS, + WEEKDAY_OPTIONS, WEEK_TEMPLATE_NAME_SUGGESTIONS, ) from .db import get_db +from .push import push_is_configured, push_public_key, send_push_message main_bp = Blueprint("main", __name__) @@ -59,6 +64,17 @@ TARGET_USER_OPTIONS_DEFAULT = "__all__" WEEKDAY_LABELS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] +@main_bp.before_app_request +def refresh_due_context(): + endpoint = request.endpoint or "" + 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() + return None + + def current_household_id() -> int: return int(g.user["household_id"]) @@ -110,6 +126,95 @@ def get_category_options(include_inactive_selected: str | None = None) -> list[s return categories +def get_category_builder_map() -> dict[str, str]: + rows = get_db().execute( + """ + SELECT name, builder_key + FROM household_categories + WHERE household_id = ? + """, + (current_household_id(),), + ).fetchall() + builder_map = {row["name"]: (row["builder_key"] or "neutral") for row in rows} + for name, builder_key in DEFAULT_CATEGORY_BUILDERS.items(): + builder_map.setdefault(name, builder_key) + return builder_map + + +def get_household_settings() -> dict: + row = get_db().execute( + """ + SELECT shopping_weekday, shopping_prep_days, shopping_reminder_time + FROM households + WHERE id = ? + """, + (current_household_id(),), + ).fetchone() + if row is None: + return { + "shopping_weekday": 5, + "shopping_prep_days": 1, + "shopping_reminder_time": "18:00", + } + return { + "shopping_weekday": int(row["shopping_weekday"] or 5), + "shopping_prep_days": int(row["shopping_prep_days"] or 1), + "shopping_reminder_time": row["shopping_reminder_time"] or "18:00", + } + + +def ensure_user_settings_row() -> None: + get_db().execute( + "INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)", + (g.user["id"],), + ) + + +def get_user_settings() -> dict: + ensure_user_settings_row() + row = get_db().execute("SELECT * FROM user_settings WHERE user_id = ?", (g.user["id"],)).fetchone() + if row is None: + return {} + settings = dict(row) + boolean_fields = { + "reminders_enabled", + "push_enabled", + "remind_before_shopping", + "remind_on_shopping_day", + "show_missing_for_upcoming_week", + "show_planned_not_shopped", + "remind_tomorrow_if_sparse", + "remind_week_if_sparse", + "suggest_home_for_today", + "remind_small_snack", + "remind_nuts", + "show_meal_balancing", + "suggest_templates", + "suggest_patterns", + } + for field in boolean_fields: + settings[field] = bool(settings.get(field)) + settings["notification_channel"] = settings.get("notification_channel") or "in_app" + return settings + + +def parse_checkbox(name: str, default: bool = False) -> int: + return 1 if request.form.get(name, "1" if default else "0") == "1" else 0 + + +def normalize_weekday(raw: str | None, default: int = 5) -> int: + if raw and raw.isdigit(): + value = int(raw) + if 0 <= value <= 6: + return value + return default + + +def normalize_notification_channel(raw: str | None, default: str = "in_app") -> str: + allowed = {value for value, _label in NOTIFICATION_CHANNEL_OPTIONS} + return raw if raw in allowed else default + + def visible_clause(table_alias: str) -> str: return ( f"{table_alias}.household_id = ? " @@ -331,6 +436,81 @@ def attach_components(items: list[dict]) -> list[dict]: return items +def attach_builder_keys(items: list[dict]) -> list[dict]: + if not items: + return [] + + category_builder_map = get_category_builder_map() + meal_ids = [item["id"] for item in items if item["kind"] == "meal"] + meal_builder_map: dict[int, set[str]] = defaultdict(set) + + if meal_ids: + placeholders = ",".join("?" for _ in meal_ids) + rows = get_db().execute( + f""" + SELECT meal_components.meal_item_id, + component.category + FROM meal_components + JOIN items AS component ON component.id = meal_components.food_item_id + WHERE meal_components.meal_item_id IN ({placeholders}) + """, + meal_ids, + ).fetchall() + for row in rows: + builder_key = category_builder_map.get(row["category"] or "", "neutral") + meal_builder_map[int(row["meal_item_id"])].add(builder_key) + + for item in items: + builder_keys: list[str] + if item["kind"] == "meal": + builder_keys = sorted(meal_builder_map.get(item["id"], set())) + if not builder_keys: + builder_keys = [category_builder_map.get(item.get("category") or "", "neutral")] + else: + builder_keys = [category_builder_map.get(item.get("category") or "", "neutral")] + item["builder_keys"] = builder_keys + item["builder_labels"] = [BUILDER_LABELS.get(key, BUILDER_LABELS["neutral"]) for key in builder_keys] + item["primary_builder_key"] = builder_keys[0] if builder_keys else "neutral" + return items + + +def decorate_items(rows) -> list[dict]: + return attach_builder_keys(attach_components(attach_dayparts(describe_records(rows)))) + + +def fetch_builder_keys_for_item_ids(item_ids: list[int]) -> dict[int, set[str]]: + if not item_ids: + return {} + category_builder_map = get_category_builder_map() + placeholders = ",".join("?" for _ in item_ids) + rows = get_db().execute( + f""" + SELECT id, kind, category + FROM items + WHERE id IN ({placeholders}) + """, + item_ids, + ).fetchall() + builder_map: dict[int, set[str]] = {int(row["id"]): {category_builder_map.get(row["category"] or "", "neutral")} for row in rows} + meal_ids = [int(row["id"]) for row in rows if row["kind"] == "meal"] + if meal_ids: + meal_placeholders = ",".join("?" for _ in meal_ids) + component_rows = get_db().execute( + f""" + SELECT meal_components.meal_item_id, component.category + FROM meal_components + JOIN items AS component ON component.id = meal_components.food_item_id + WHERE meal_components.meal_item_id IN ({meal_placeholders}) + """, + meal_ids, + ).fetchall() + for row in component_rows: + builder_map.setdefault(int(row["meal_item_id"]), set()).add( + category_builder_map.get(row["category"] or "", "neutral") + ) + return builder_map + + def fetch_items( *, kind: str | None = None, @@ -393,7 +573,7 @@ def fetch_items( """, params, ).fetchall() - return attach_components(attach_dayparts(describe_records(rows))) + return decorate_items(rows) def fetch_food_options(query: str | None = None): @@ -460,6 +640,93 @@ def create_quick_food_from_form(form_data: dict) -> int: return food_id +def shopping_activation_date_for(needed_date: date) -> date: + settings = get_household_settings() + shopping_weekday = int(settings["shopping_weekday"]) + prep_days = int(settings["shopping_prep_days"]) + days_back = (needed_date.weekday() - shopping_weekday) % 7 + shopping_date = needed_date - timedelta(days=days_back) + return shopping_date - timedelta(days=prep_days) + + +def should_activate_shopping_need(needed_date: date, today: date | None = None) -> bool: + return (today or date.today()) >= shopping_activation_date_for(needed_date) + + +def schedule_shopping_need( + *, + item_id: int, + user_id: int, + visibility: str, + needed_for_date: str, + needed_for_daypart_id: int | None, + source_item_id: int | None = None, +) -> bool: + activation_date = shopping_activation_date_for(parse_plan_date(needed_for_date)) + existing = get_db().execute( + """ + SELECT id + FROM shopping_needs + WHERE household_id = ? + AND item_id = ? + AND COALESCE(source_item_id, 0) = COALESCE(?, 0) + AND needed_for_date = ? + AND COALESCE(needed_for_daypart_id, 0) = COALESCE(?, 0) + AND visibility = ? + AND is_activated = 0 + LIMIT 1 + """, + ( + current_household_id(), + item_id, + source_item_id, + needed_for_date, + needed_for_daypart_id, + visibility, + ), + ).fetchone() + if existing: + get_db().execute( + """ + UPDATE shopping_needs + SET activation_date = ?, + owner_user_id = ? + WHERE id = ? + """, + (activation_date.isoformat(), user_id, existing["id"]), + ) + else: + get_db().execute( + """ + INSERT INTO shopping_needs ( + household_id, + owner_user_id, + visibility, + item_id, + source_item_id, + needed_for_date, + needed_for_daypart_id, + activation_date, + created_by + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + current_household_id(), + user_id, + visibility, + item_id, + source_item_id, + needed_for_date, + needed_for_daypart_id, + activation_date.isoformat(), + g.user["id"], + ), + ) + get_db().commit() + return True + + def add_to_shopping_list( item_id: int, user_id: int, @@ -523,7 +790,7 @@ def fetch_meal_missing_components(meal_id: int) -> list[dict]: """, (meal_id, current_household_id(), g.user["id"]), ).fetchall() - return attach_dayparts(describe_records(rows)) + return attach_builder_keys(attach_dayparts(describe_records(rows))) def ensure_item_or_missing_components_are_shopped( @@ -533,32 +800,79 @@ def ensure_item_or_missing_components_are_shopped( *, needed_for_date: str | None = None, needed_for_daypart_id: int | None = None, + source_item_id: int | None = None, ) -> dict: item = get_item(item_id) if item["kind"] == "meal": missing_components = fetch_meal_missing_components(item_id) if missing_components: added_names = [] + scheduled_names = [] for component in missing_components: - added = add_to_shopping_list( - component["id"], - user_id, - visibility_override=visibility, - needed_for_date=needed_for_date, - needed_for_daypart_id=needed_for_daypart_id, - ) - if added: - added_names.append(component["name"]) + if needed_for_date and not should_activate_shopping_need(parse_plan_date(needed_for_date)): + schedule_shopping_need( + item_id=component["id"], + user_id=user_id, + visibility=visibility, + needed_for_date=needed_for_date, + needed_for_daypart_id=needed_for_daypart_id, + source_item_id=source_item_id or item_id, + ) + scheduled_names.append(component["name"]) + else: + added = add_to_shopping_list( + component["id"], + user_id, + visibility_override=visibility, + needed_for_date=needed_for_date, + needed_for_daypart_id=needed_for_daypart_id, + ) + if added: + added_names.append(component["name"]) return { "added": bool(added_names), "count": len(added_names), "names": added_names, + "scheduled_count": len(scheduled_names), + "scheduled_names": scheduled_names, "used_components": True, } - return {"added": False, "count": 0, "names": [], "used_components": True} + return { + "added": False, + "count": 0, + "names": [], + "scheduled_count": 0, + "scheduled_names": [], + "used_components": True, + } if item["availability_state"] == "home": - return {"added": False, "count": 0, "names": [], "used_components": False} + return { + "added": False, + "count": 0, + "names": [], + "scheduled_count": 0, + "scheduled_names": [], + "used_components": False, + } + + if needed_for_date and not should_activate_shopping_need(parse_plan_date(needed_for_date)): + schedule_shopping_need( + item_id=item_id, + user_id=user_id, + visibility=visibility, + needed_for_date=needed_for_date, + needed_for_daypart_id=needed_for_daypart_id, + source_item_id=source_item_id, + ) + return { + "added": False, + "count": 0, + "names": [], + "scheduled_count": 1, + "scheduled_names": [item["name"]], + "used_components": False, + } added = add_to_shopping_list( item_id, @@ -571,6 +885,8 @@ def ensure_item_or_missing_components_are_shopped( "added": added, "count": 1 if added else 0, "names": [item["name"]] if added else [], + "scheduled_count": 0, + "scheduled_names": [], "used_components": False, } @@ -643,6 +959,80 @@ def fetch_shopping_entries(): return entries +def activate_due_shopping_needs(today: date | None = None) -> int: + today = today or date.today() + rows = get_db().execute( + """ + SELECT shopping_needs.*, + items.availability_state + FROM shopping_needs + JOIN items ON items.id = shopping_needs.item_id + WHERE shopping_needs.household_id = ? + AND shopping_needs.is_activated = 0 + AND shopping_needs.activation_date <= ? + ORDER BY shopping_needs.activation_date, shopping_needs.needed_for_date + """, + (current_household_id(), today.isoformat()), + ).fetchall() + activated = 0 + for row in rows: + if row["availability_state"] != "home": + was_added = add_to_shopping_list( + int(row["item_id"]), + int(row["owner_user_id"] or g.user["id"]), + visibility_override=row["visibility"], + needed_for_date=row["needed_for_date"], + needed_for_daypart_id=row["needed_for_daypart_id"], + ) + activated += 1 if was_added else 0 + get_db().execute( + "UPDATE shopping_needs SET is_activated = 1, activated_at = CURRENT_TIMESTAMP WHERE id = ?", + (row["id"],), + ) + if rows: + get_db().commit() + return activated + + +def fetch_upcoming_shopping_needs(limit: int | None = None) -> list[dict]: + query = f""" + SELECT shopping_needs.*, + items.name AS item_name, + items.kind AS item_kind, + items.photo_filename, + items.availability_state, + owner.display_name AS owner_display_name, + owner.username AS owner_username, + target.display_name AS target_display_name, + target.username AS target_username, + dayparts.name AS needed_daypart_name + FROM shopping_needs + JOIN items ON items.id = shopping_needs.item_id + LEFT JOIN users AS owner ON owner.id = shopping_needs.owner_user_id + LEFT JOIN users AS target ON target.id = items.target_user_id + LEFT JOIN dayparts ON dayparts.id = shopping_needs.needed_for_daypart_id + WHERE shopping_needs.household_id = ? + AND shopping_needs.is_activated = 0 + AND (shopping_needs.visibility = 'shared' OR shopping_needs.owner_user_id = ?) + AND items.availability_state != 'home' + ORDER BY shopping_needs.activation_date, shopping_needs.needed_for_date, LOWER(items.name) + """ + params: list[object] = [current_household_id(), g.user["id"]] + if limit: + query += " LIMIT ?" + params.append(limit) + rows = get_db().execute(query, params).fetchall() + entries = describe_records(rows) + for entry in entries: + try: + entry["needed_for_label"] = datetime.strptime(entry["needed_for_date"], "%Y-%m-%d").date().strftime("%d.%m.%Y") + entry["activation_label"] = datetime.strptime(entry["activation_date"], "%Y-%m-%d").date().strftime("%d.%m.%Y") + except ValueError: + entry["needed_for_label"] = entry["needed_for_date"] + entry["activation_label"] = entry["activation_date"] + return entries + + def fetch_plan_entries_for_range(start_date: date, end_date: date): rows = get_db().execute( f""" @@ -706,7 +1096,7 @@ def fetch_recent_plan_items(daypart_id: int, limit: int = 6): """, [daypart_id, *visible_params(), limit * 3], ).fetchall() - return attach_components(attach_dayparts(describe_records(rows))) + return decorate_items(rows) def fetch_plan_candidates(daypart_id: int, query: str | None = None): @@ -740,7 +1130,7 @@ def fetch_plan_candidates(daypart_id: int, query: str | None = None): """, params, ).fetchall() - return attach_components(attach_dayparts(describe_records(rows))) + return decorate_items(rows) def fetch_home_food_ids() -> set[int]: @@ -755,179 +1145,271 @@ def fetch_home_food_ids() -> set[int]: return {int(row["id"]) for row in rows} -def build_daypart_suggestions(daypart_id: int) -> list[dict]: - home_food_ids = fetch_home_food_ids() - suggestions: list[dict] = [] +def get_daypart_by_id(daypart_id: int): + for daypart in get_dayparts(): + if int(daypart["id"]) == int(daypart_id): + return daypart + return None + +def format_item_names(items: list[dict], limit: int = 3) -> str: + return ", ".join(item["name"] for item in items[:limit]) + + +def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) -> list[dict]: + home_foods = fetch_items(kind="food", availability="home", daypart_id=daypart_id) + home_food_ids = {item["id"] for item in home_foods} + 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) + + suggestions: list[dict] = [] meals = fetch_items(kind="meal", daypart_id=daypart_id) for meal in meals: - if not meal["component_ids"]: - continue - if all(component_id in home_food_ids for component_id in meal["component_ids"]): + if meal["component_ids"] and all(component_id in home_food_ids for component_id in meal["component_ids"]): suggestions.append( { "title": meal["name"], "reason": "Zuhause vorhanden", - "note": "Die Bestandteile sind gerade da und passen gut dazu.", + "component_ids": meal["component_ids"], + "existing_item_id": meal["id"], } ) - archived_items = fetch_items(availability="archived", include_archived=True, daypart_id=daypart_id) - for item in archived_items[:2]: - suggestions.append( - { - "title": item["name"], - "reason": "Für später merken", - "note": "War schon einmal dabei und könnte heute wieder passen.", - } - ) + daypart_slug = (get_daypart_by_id(daypart_id)["slug"] if daypart_id and get_daypart_by_id(daypart_id) else "") + if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}: + if builder_groups["carb"] and builder_groups["dairy"]: + combo = [builder_groups["carb"][0], builder_groups["dairy"][0]] + if builder_groups["fruit"]: + combo.append(builder_groups["fruit"][0]) + if builder_groups["nuts"]: + combo.append(builder_groups["nuts"][0]) + suggestions.append( + { + "title": " mit ".join([combo[0]["name"], combo[1]["name"]]) if len(combo) == 2 else f"{combo[0]['name']} mit {', '.join(item['name'] for item in combo[1:])}", + "reason": "Lässt sich gut ergänzen", + "component_ids": [item["id"] for item in combo], + "existing_item_id": None, + } + ) + else: + if builder_groups["protein"] and builder_groups["carb"]: + combo = [builder_groups["protein"][0], builder_groups["carb"][0]] + if builder_groups["veg"]: + combo.append(builder_groups["veg"][0]) + suggestions.append( + { + "title": f"{combo[0]['name']} mit {', '.join(item['name'] for item in combo[1:])}", + "reason": "Aus Zuhause zusammengesetzt", + "component_ids": [item["id"] for item in combo], + "existing_item_id": None, + } + ) - often_used = get_db().execute( - f""" - SELECT items.name, COUNT(*) AS usage_count - FROM plan_entries - JOIN items ON items.id = plan_entries.item_id - WHERE plan_entries.daypart_id = ? AND {visible_clause('items')} - GROUP BY items.id, items.name - HAVING COUNT(*) > 1 - ORDER BY usage_count DESC, LOWER(items.name) - LIMIT 2 - """, - [daypart_id, *visible_params()], - ).fetchall() - for row in often_used: - suggestions.append( - { - "title": row["name"], - "reason": "Oft gemeinsam genutzt", - "note": "Ist in diesem Zeitfenster schon öfter aufgetaucht.", - } - ) - - deduped = [] + deduped: list[dict] = [] seen = set() for suggestion in suggestions: - key = suggestion["title"] - if key in seen: + if suggestion["title"] in seen: continue - seen.add(key) + seen.add(suggestion["title"]) deduped.append(suggestion) - if len(deduped) >= 4: + if len(deduped) >= limit: break return deduped +def build_balance_suggestion(daypart_id: int, item_ids: list[int]) -> dict | None: + settings = get_user_settings() + if not (settings.get("reminders_enabled") and settings.get("show_meal_balancing")): + return None + daypart = get_daypart_by_id(daypart_id) + if not daypart or daypart["slug"] not in {"lunch", "dinner"}: + return None + builder_map = fetch_builder_keys_for_item_ids(item_ids) + present = set() + for keys in builder_map.values(): + present.update(keys) + target_order = ["protein", "carb", "veg"] + missing = [key for key in target_order if key not in present] + if not missing: + return None + first_missing = missing[0] + home_matches = [ + item for item in fetch_items(kind="food", availability="home", daypart_id=daypart_id) + if first_missing in item.get("builder_keys", []) + ] + text_map = { + "protein": "Dazu könnte noch eine Proteinquelle gut passen.", + "carb": "Das lässt sich gut mit einer Kohlenhydratquelle ergänzen.", + "veg": "Dazu könnte noch etwas Gemüse gut passen.", + } + return { + "text": text_map.get(first_missing, "Dazu könnte noch etwas Kleines gut passen."), + "items": home_matches[:3], + } + + +def build_daypart_suggestions(daypart_id: int) -> list[dict]: + settings = get_user_settings() + if not settings.get("suggest_home_for_today"): + return [] + suggestions = build_home_recipe_suggestions(daypart_id, limit=3) + if suggestions: + return suggestions + + archived_items = fetch_items(availability="archived", include_archived=True, daypart_id=daypart_id) + return [ + { + "title": item["name"], + "reason": "Für später vormerken", + "component_ids": [], + "existing_item_id": item["id"] if item["kind"] == "meal" else None, + } + for item in archived_items[:2] + ] + + def build_dashboard_hints(today: date) -> list[str]: + settings = get_user_settings() + if not settings.get("reminders_enabled"): + return [] + hints: list[str] = [] tomorrow = today + timedelta(days=1) - breakfast = get_db().execute( - f""" - SELECT COUNT(*) AS count - FROM plan_entries - JOIN dayparts ON dayparts.id = plan_entries.daypart_id - WHERE plan_entries.plan_date = ? AND dayparts.slug = 'breakfast' AND {visible_clause('plan_entries')} - """, - [tomorrow.isoformat(), *visible_params()], - ).fetchone() - if int(breakfast["count"]) == 0: - hints.append("Für morgen ist noch kein Frühstück eingeplant.") + household_settings = get_household_settings() - dinner_home = get_db().execute( - f""" - SELECT COUNT(*) AS count - FROM items - JOIN item_dayparts ON item_dayparts.item_id = items.id - JOIN dayparts ON dayparts.id = item_dayparts.daypart_id - WHERE items.availability_state = 'home' - AND dayparts.slug = 'dinner' - AND {visible_clause('items')} - """, - visible_params(), - ).fetchone() - if int(dinner_home["count"]) > 0: - hints.append("Zuhause ist bereits etwas für Abendessen da.") + if settings.get("remind_tomorrow_if_sparse"): + breakfast = get_db().execute( + f""" + SELECT COUNT(*) AS count + FROM plan_entries + JOIN dayparts ON dayparts.id = plan_entries.daypart_id + WHERE plan_entries.plan_date = ? AND dayparts.slug = 'breakfast' AND {visible_clause('plan_entries')} + """, + [tomorrow.isoformat(), *visible_params()], + ).fetchone() + if int(breakfast["count"]) == 0: + hints.append("Für morgen ist noch kein Frühstück eingeplant.") - old_template = get_db().execute( - f""" - SELECT name - FROM day_templates - WHERE {visible_clause('day_templates')} - AND last_used_at IS NOT NULL - AND DATE(last_used_at) <= DATE('now', '-21 day') - ORDER BY last_used_at ASC - LIMIT 1 - """, - visible_params(), - ).fetchone() - if old_template: - hints.append(f"Die Tagesvorlage „{old_template['name']}“ wurde länger nicht genutzt.") - return hints[:3] + if settings.get("suggest_home_for_today"): + dinner_home = get_db().execute( + f""" + SELECT COUNT(*) AS count + FROM items + JOIN item_dayparts ON item_dayparts.item_id = items.id + JOIN dayparts ON dayparts.id = item_dayparts.daypart_id + WHERE items.availability_state = 'home' + AND dayparts.slug = 'dinner' + AND {visible_clause('items')} + """, + visible_params(), + ).fetchone() + if int(dinner_home["count"]) > 0: + hints.append("Zuhause ist bereits etwas da, das gut zu Abendessen passt.") + + if settings.get("remind_before_shopping") and (today + timedelta(days=1)).weekday() == household_settings["shopping_weekday"]: + upcoming = fetch_upcoming_shopping_needs(limit=3) + if upcoming: + hints.append("Für den nächsten Einkauf sind schon ein paar Dinge vorgemerkt.") + + if settings.get("remind_nuts"): + nut_items = [item for item in fetch_items(kind="food", availability="home") if "nuts" in item.get("builder_keys", [])] + if nut_items: + hints.append("Heute schon an Nüsse gedacht?") + + if settings.get("suggest_templates"): + old_template = get_db().execute( + f""" + SELECT name + FROM day_templates + WHERE {visible_clause('day_templates')} + AND last_used_at IS NOT NULL + AND DATE(last_used_at) <= DATE('now', '-21 day') + ORDER BY last_used_at ASC + LIMIT 1 + """, + visible_params(), + ).fetchone() + if old_template: + hints.append(f"„{old_template['name']}“ könnte diese Woche wieder passen.") + return hints[:4] def build_day_hints(selected_date: date) -> list[str]: - hints: list[str] = [] - breakfast_count = get_db().execute( - f""" - SELECT COUNT(*) AS count - FROM plan_entries - JOIN dayparts ON dayparts.id = plan_entries.daypart_id - WHERE plan_entries.plan_date = ? AND dayparts.slug = 'breakfast' AND {visible_clause('plan_entries')} - """, - [selected_date.isoformat(), *visible_params()], - ).fetchone() - if int(breakfast_count["count"]) == 0: - hints.append("Für diesen Tag ist noch kein Frühstück eingeplant.") + settings = get_user_settings() + if not settings.get("reminders_enabled"): + return [] - afternoon_options = get_db().execute( - f""" - SELECT COUNT(*) AS count - FROM items - JOIN item_dayparts ON item_dayparts.item_id = items.id - JOIN dayparts ON dayparts.id = item_dayparts.daypart_id - WHERE items.availability_state != 'archived' - AND dayparts.slug = 'afternoon-snack' - AND {visible_clause('items')} - """, - visible_params(), - ).fetchone() - if int(afternoon_options["count"]) < 2: - hints.append("Für den Nachmittag gibt es gerade wenig eingeplante Optionen.") - return hints[:3] + hints: list[str] = [] + if settings.get("remind_tomorrow_if_sparse"): + breakfast_count = get_db().execute( + f""" + SELECT COUNT(*) AS count + FROM plan_entries + JOIN dayparts ON dayparts.id = plan_entries.daypart_id + WHERE plan_entries.plan_date = ? AND dayparts.slug = 'breakfast' AND {visible_clause('plan_entries')} + """, + [selected_date.isoformat(), *visible_params()], + ).fetchone() + if int(breakfast_count["count"]) == 0: + hints.append("Für diesen Tag ist noch kein Frühstück eingeplant.") + + if settings.get("remind_small_snack"): + afternoon_options = get_db().execute( + f""" + SELECT COUNT(*) AS count + FROM items + JOIN item_dayparts ON item_dayparts.item_id = items.id + JOIN dayparts ON dayparts.id = item_dayparts.daypart_id + WHERE items.availability_state != 'archived' + AND dayparts.slug = 'afternoon-snack' + AND {visible_clause('items')} + """, + visible_params(), + ).fetchone() + if int(afternoon_options["count"]) < 2: + hints.append("Für den Nachmittag wäre noch etwas Kleines möglich.") + + if settings.get("show_planned_not_shopped"): + pending_for_day = fetch_upcoming_shopping_needs(limit=20) + pending_for_day = [entry for entry in pending_for_day if entry["needed_for_date"] == selected_date.isoformat()] + if pending_for_day: + hints.append("Ein paar Dinge sind für diesen Tag schon vorgemerkt, aber noch nicht auf der Einkaufsliste.") + return hints[:4] def build_week_hints(week_start: date) -> list[str]: + settings = get_user_settings() + if not settings.get("reminders_enabled"): + return [] + hints: list[str] = [] week_end = week_start + timedelta(days=6) - breakfast_days = get_db().execute( - f""" - SELECT COUNT(DISTINCT plan_entries.plan_date) AS count - FROM plan_entries - JOIN dayparts ON dayparts.id = plan_entries.daypart_id - WHERE plan_entries.plan_date BETWEEN ? AND ? - AND dayparts.slug = 'breakfast' - AND {visible_clause('plan_entries')} - """, - [week_start.isoformat(), week_end.isoformat(), *visible_params()], - ).fetchone() - missing_breakfasts = 7 - int(breakfast_days["count"]) - if missing_breakfasts > 0: - hints.append(f"In dieser Woche sind noch {missing_breakfasts} Tage ohne Frühstücksplan.") + if settings.get("remind_week_if_sparse"): + breakfast_days = get_db().execute( + f""" + SELECT COUNT(DISTINCT plan_entries.plan_date) AS count + FROM plan_entries + JOIN dayparts ON dayparts.id = plan_entries.daypart_id + WHERE plan_entries.plan_date BETWEEN ? AND ? + AND dayparts.slug = 'breakfast' + AND {visible_clause('plan_entries')} + """, + [week_start.isoformat(), week_end.isoformat(), *visible_params()], + ).fetchone() + missing_breakfasts = 7 - int(breakfast_days["count"]) + if missing_breakfasts > 0: + hints.append(f"Für diese Woche sind noch {missing_breakfasts} Tage ohne Frühstück eingeplant.") - home_dinners = get_db().execute( - f""" - SELECT COUNT(*) AS count - FROM items - JOIN item_dayparts ON item_dayparts.item_id = items.id - JOIN dayparts ON dayparts.id = item_dayparts.daypart_id - WHERE items.availability_state = 'home' - AND dayparts.slug = 'dinner' - AND {visible_clause('items')} - """, - visible_params(), - ).fetchone() - if int(home_dinners["count"]) > 0: - hints.append("Zuhause sind bereits Dinge da, die gut zu Abendessen passen.") - return hints[:3] + if settings.get("show_missing_for_upcoming_week"): + due_entries = [entry for entry in fetch_upcoming_shopping_needs(limit=20) if week_start.isoformat() <= entry["needed_for_date"] <= week_end.isoformat()] + if due_entries: + hints.append("Für diese Woche fehlt noch etwas, das später zum Einkauf dazukommen kann.") + + if settings.get("remind_on_shopping_day") and week_start.weekday() <= get_household_settings()["shopping_weekday"] <= week_end.weekday(): + hints.append("Der Einkaufstag liegt in dieser Woche schon bereit im Blick.") + return hints[:4] def build_home_sections(items: list[dict], dayparts: list, selected_daypart_id: int | None): @@ -971,18 +1453,29 @@ def build_day_planner_sections(selected_date: date, selected_item_id: int | None day_entries = fetch_day_plan_entries(selected_date) for daypart in get_dayparts(): candidates = fetch_plan_candidates(daypart["id"]) - home_candidates = [item for item in candidates if item["availability_state"] == "home"] - matching_candidates = [item for item in candidates if any(meta["id"] == daypart["id"] for meta in item["dayparts_meta"])] - recent_candidates = fetch_recent_plan_items(daypart["id"]) - quick_items = dedupe_items(home_candidates + recent_candidates + matching_candidates, limit=6) entries = day_entries.get((selected_date.isoformat(), daypart["id"]), []) + meal_candidates = dedupe_items( + [item for item in candidates if item["kind"] == "meal" and item["availability_state"] == "home"] + + [item for item in candidates if item["kind"] == "meal"], + limit=6, + ) + food_candidates = dedupe_items( + [item for item in candidates if item["kind"] == "food" and item["availability_state"] == "home"] + + fetch_recent_plan_items(daypart["id"]) + + [item for item in candidates if item["kind"] == "food"], + limit=20, + ) + entry_item_ids = [int(entry["item_id"]) for entry in entries] sections.append( { "daypart": daypart, "entries": entries, "candidates": candidates, - "quick_items": quick_items, + "meal_candidates": meal_candidates, + "food_candidates": food_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), "selected_item_id": selected_item_id if selected_daypart_id == daypart["id"] else None, "is_open": selected_daypart_id == daypart["id"], "summary_items": [entry["item_name"] for entry in entries][:2], @@ -1200,6 +1693,7 @@ def apply_day_template(template_id: int, selected_date: date) -> int: template["visibility"], needed_for_date=selected_date.isoformat(), needed_for_daypart_id=daypart_id, + source_item_id=item_id, ) inserted += 1 get_db().commit() @@ -1474,6 +1968,78 @@ def render_item_form(kind: str, *, item: dict | None, form_data: dict): ) +def create_or_get_generated_meal( + *, + name: str, + component_ids: list[int], + daypart_id: int, + visibility: str, +) -> int: + existing = get_db().execute( + f""" + SELECT items.id + FROM items + WHERE items.kind = 'meal' AND LOWER(items.name) = LOWER(?) AND {visible_clause('items')} + ORDER BY items.id + LIMIT 1 + """, + [name, *visible_params()], + ).fetchone() + if existing: + return int(existing["id"]) + + cursor = get_db().execute( + """ + INSERT INTO items ( + household_id, owner_user_id, visibility, kind, name, category, created_by, updated_by + ) + VALUES (?, ?, ?, 'meal', ?, ?, ?, ?) + """, + ( + current_household_id(), + g.user["id"], + visibility, + name, + "Kleines Essen", + g.user["id"], + g.user["id"], + ), + ) + meal_id = int(cursor.lastrowid) + sync_item_dayparts(meal_id, [daypart_id]) + sync_meal_components(meal_id, component_ids) + get_db().commit() + return meal_id + + +def insert_plan_entry(*, item_id: int, daypart_id: int, plan_date: date, visibility: str, note: str = "") -> dict: + get_db().execute( + """ + INSERT INTO plan_entries (household_id, owner_user_id, visibility, plan_date, daypart_id, item_id, note, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + current_household_id(), + g.user["id"], + visibility, + plan_date.isoformat(), + daypart_id, + item_id, + note, + g.user["id"], + ), + ) + get_db().commit() + return ensure_item_or_missing_components_are_shopped( + item_id, + g.user["id"], + visibility, + needed_for_date=plan_date.isoformat(), + needed_for_daypart_id=daypart_id, + source_item_id=item_id, + ) + + def planner_template_options(): return fetch_day_templates() @@ -1504,6 +2070,8 @@ def dashboard(): today=today, week_cards=week_cards[:3], dashboard_hints=build_dashboard_hints(today), + recipe_suggestions=build_home_recipe_suggestions(limit=4), + upcoming_entries=fetch_upcoming_shopping_needs(limit=4), day_templates=fetch_day_templates()[:3], week_templates=fetch_week_templates()[:3], ) @@ -1842,6 +2410,176 @@ def item_set_apply(set_id: int): return redirect(url_for("main.shopping_list")) +@main_bp.route("/settings", methods=("GET", "POST")) +@login_required +def settings_view(): + if request.method == "POST": + form_name = request.form.get("form_name", "").strip() + if form_name == "household": + shopping_weekday = normalize_weekday(request.form.get("shopping_weekday"), 5) + raw_prep_days = request.form.get("shopping_prep_days", "1").strip() + shopping_prep_days = max(0, min(7, int(raw_prep_days))) if raw_prep_days.isdigit() else 1 + shopping_reminder_time = request.form.get("shopping_reminder_time", "18:00").strip() or "18:00" + get_db().execute( + """ + UPDATE households + SET shopping_weekday = ?, shopping_prep_days = ?, shopping_reminder_time = ? + WHERE id = ? + """, + (shopping_weekday, shopping_prep_days, shopping_reminder_time, current_household_id()), + ) + get_db().commit() + flash("Die Einkaufsrhythmus-Einstellungen wurden gespeichert.", "success") + elif form_name == "reminders": + ensure_user_settings_row() + get_db().execute( + """ + UPDATE user_settings + SET reminders_enabled = ?, + push_enabled = ?, + notification_channel = ?, + remind_before_shopping = ?, + remind_on_shopping_day = ?, + show_missing_for_upcoming_week = ?, + show_planned_not_shopped = ?, + remind_tomorrow_if_sparse = ?, + remind_week_if_sparse = ?, + suggest_home_for_today = ?, + remind_small_snack = ?, + remind_nuts = ?, + show_meal_balancing = ?, + suggest_templates = ?, + suggest_patterns = ?, + updated_at = CURRENT_TIMESTAMP + WHERE user_id = ? + """, + ( + parse_checkbox("reminders_enabled", True), + parse_checkbox("push_enabled", False), + normalize_notification_channel(request.form.get("notification_channel"), "in_app"), + parse_checkbox("remind_before_shopping", True), + parse_checkbox("remind_on_shopping_day", True), + parse_checkbox("show_missing_for_upcoming_week", True), + parse_checkbox("show_planned_not_shopped", True), + parse_checkbox("remind_tomorrow_if_sparse", True), + parse_checkbox("remind_week_if_sparse", True), + parse_checkbox("suggest_home_for_today", True), + parse_checkbox("remind_small_snack", False), + parse_checkbox("remind_nuts", False), + parse_checkbox("show_meal_balancing", True), + parse_checkbox("suggest_templates", True), + parse_checkbox("suggest_patterns", True), + g.user["id"], + ), + ) + get_db().commit() + flash("Deine Erinnerungen und Hinweise wurden gespeichert.", "success") + elif form_name == "push_test": + subscription = get_db().execute( + """ + SELECT endpoint, p256dh, auth + FROM push_subscriptions + WHERE user_id = ? AND is_active = 1 + ORDER BY updated_at DESC + LIMIT 1 + """, + (g.user["id"],), + ).fetchone() + if subscription is None: + flash("Bitte aktiviere zuerst Push im Browser oder auf dem Home-Bildschirm.", "error") + else: + ok, error = send_push_message( + { + "endpoint": subscription["endpoint"], + "keys": {"p256dh": subscription["p256dh"], "auth": subscription["auth"]}, + }, + title="Nouri", + body="Push ist bereit. Erinnerungen können später darüber kommen.", + url=url_for("main.settings_view", _external=True), + ) + if ok: + get_db().execute( + "UPDATE push_subscriptions SET last_test_at = CURRENT_TIMESTAMP WHERE endpoint = ?", + (subscription["endpoint"],), + ) + get_db().commit() + flash("Die Test-Mitteilung wurde versendet.", "success") + else: + flash(error or "Die Test-Mitteilung konnte gerade nicht gesendet werden.", "error") + return redirect(url_for("main.settings_view")) + + household_settings = get_household_settings() + user_settings = get_user_settings() + push_subscription = get_db().execute( + """ + SELECT COUNT(*) AS count + FROM push_subscriptions + WHERE user_id = ? AND is_active = 1 + """, + (g.user["id"],), + ).fetchone() + return render_template( + "settings.html", + household_settings=household_settings, + user_settings=user_settings, + push_subscription_count=int(push_subscription["count"]), + push_ready=push_is_configured(), + push_public_key_value=push_public_key(), + ) + + +@main_bp.post("/push/subscribe") +@login_required +def push_subscribe(): + if not push_is_configured(): + return jsonify({"ok": False, "error": "Push ist noch nicht konfiguriert."}), 400 + endpoint = request.form.get("endpoint", "").strip() + p256dh = request.form.get("p256dh", "").strip() + auth_key = request.form.get("auth", "").strip() + if not endpoint or not p256dh or not auth_key: + return jsonify({"ok": False, "error": "Unvollständige Push-Daten."}), 400 + + get_db().execute( + """ + INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, user_agent, is_active) + VALUES (?, ?, ?, ?, ?, 1) + ON CONFLICT(endpoint) DO UPDATE SET + user_id = excluded.user_id, + p256dh = excluded.p256dh, + auth = excluded.auth, + user_agent = excluded.user_agent, + is_active = 1, + updated_at = CURRENT_TIMESTAMP + """, + (g.user["id"], endpoint, p256dh, auth_key, request.headers.get("User-Agent", "")), + ) + ensure_user_settings_row() + get_db().execute( + "UPDATE user_settings SET push_enabled = 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?", + (g.user["id"],), + ) + get_db().commit() + return jsonify({"ok": True}) + + +@main_bp.post("/push/unsubscribe") +@login_required +def push_unsubscribe(): + endpoint = request.form.get("endpoint", "").strip() + if endpoint: + get_db().execute( + "UPDATE push_subscriptions SET is_active = 0, updated_at = CURRENT_TIMESTAMP WHERE endpoint = ? AND user_id = ?", + (endpoint, g.user["id"]), + ) + else: + get_db().execute( + "UPDATE push_subscriptions SET is_active = 0, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?", + (g.user["id"],), + ) + get_db().commit() + return jsonify({"ok": True}) + + @main_bp.route("/items/") @login_required def item_list(kind: str): @@ -1884,7 +2622,7 @@ def item_create(kind: str): return redirect(url_for("main.dashboard")) form_data = { - "name": "", + "name": request.args.get("name", "").strip(), "category": "", "note": "", "visibility": "shared", @@ -1892,7 +2630,7 @@ def item_create(kind: str): "target_user_raw": TARGET_USER_OPTIONS_DEFAULT, "food_search": "", "daypart_ids": [], - "component_ids": [], + "component_ids": [int(value) for value in request.args.getlist("component_ids") if value.isdigit()], "quick_food_name": "", "quick_food_category": "", "quick_food_note": "", @@ -2074,6 +2812,8 @@ def item_add_to_shopping(item_id: int): flash(f"Für „{item['name']}“ wurden fehlende Lebensmittel auf die Einkaufsliste gesetzt.", "success") else: flash(f"{item['name']} steht jetzt auf der Einkaufsliste.", "success") + elif result["scheduled_count"]: + flash(f"Für „{item['name']}“ sind fehlende Lebensmittel für einen späteren Einkauf vorgemerkt.", "info") else: flash(f"Für „{item['name']}“ ist gerade nichts zusätzlich nötig.", "info") return redirect(request.referrer or url_for("main.shopping_list")) @@ -2153,6 +2893,8 @@ def shopping_list(): ) if result["count"]: flash(f"Die Einkaufsliste wurde ergänzt: {', '.join(result['names'][:4])}.", "success") + elif result["scheduled_count"]: + flash("Ein paar Dinge sind für einen späteren Einkauf vorgemerkt.", "info") else: flash("Dafür ist gerade nichts zusätzlich nötig.", "info") except ValueError as exc: @@ -2160,9 +2902,19 @@ def shopping_list(): return redirect(url_for("main.shopping_list")) entries = fetch_shopping_entries() + upcoming_entries = fetch_upcoming_shopping_needs() addable_items = fetch_items(include_archived=False) addable_items = [item for item in addable_items if not item["is_on_shopping_list"]] - return render_template("shopping/list.html", entries=entries, addable_items=addable_items) + household_settings = get_household_settings() + shopping_weekday_label = dict(WEEKDAY_OPTIONS).get(household_settings["shopping_weekday"], "gesetzt") + return render_template( + "shopping/list.html", + entries=entries, + upcoming_entries=upcoming_entries, + addable_items=addable_items, + household_settings=household_settings, + shopping_weekday_label=shopping_weekday_label, + ) @main_bp.post("/shopping//check") @@ -2249,6 +3001,7 @@ def home_view(): return render_template( "home/list.html", sections=build_home_sections(items, dayparts, daypart_id), + recipe_suggestions=build_home_recipe_suggestions(daypart_id, limit=4), query=query, dayparts=dayparts, selected_daypart_id=daypart_id, @@ -2298,6 +3051,8 @@ def planner(): today=date.today(), week_templates=fetch_week_templates()[:6], week_hints=build_week_hints(week_start), + upcoming_entries=fetch_upcoming_shopping_needs(limit=8), + household_settings=get_household_settings(), ) @@ -2321,32 +3076,17 @@ def planner_day(): daypart_id = int(daypart_id_raw) try: get_item(item_id) - get_db().execute( - """ - INSERT INTO plan_entries (household_id, owner_user_id, visibility, plan_date, daypart_id, item_id, note, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - current_household_id(), - g.user["id"], - visibility, - selected_date.isoformat(), - daypart_id, - item_id, - note, - g.user["id"], - ), - ) - get_db().commit() - shopping_result = ensure_item_or_missing_components_are_shopped( - item_id, - g.user["id"], - visibility, - needed_for_date=selected_date.isoformat(), - needed_for_daypart_id=daypart_id, + shopping_result = insert_plan_entry( + item_id=item_id, + daypart_id=daypart_id, + plan_date=selected_date, + visibility=visibility, + note=note, ) if shopping_result["count"]: - flash("Fehlende Dinge wurden zusätzlich auf die Einkaufsliste gesetzt.", "info") + flash("Fehlende Lebensmittel wurden für den nächsten Einkauf ergänzt.", "info") + elif shopping_result["scheduled_count"]: + flash("Fehlende Lebensmittel wurden für einen späteren Einkauf vorgemerkt.", "info") flash("Der Eintrag wurde in den Tagesplan gelegt.", "success") return redirect( f"{url_for('main.planner_day', date=selected_date.isoformat(), daypart_id=daypart_id)}#daypart-{daypart_id}" @@ -2371,6 +3111,39 @@ def planner_day(): ) +@main_bp.post("/planner/day/generated-meal") +@login_required +def planner_generated_meal(): + selected_date = parse_plan_date(request.form.get("plan_date")) + daypart_raw = request.form.get("daypart_id", "").strip() + meal_name = request.form.get("meal_name", "").strip() + component_ids = [int(value) for value in request.form.getlist("component_ids") if value.isdigit()] + visibility = normalize_visibility(request.form.get("visibility"), "shared") + if not daypart_raw.isdigit() or not meal_name or not component_ids: + flash("Die vorgeschlagene Kombination konnte gerade nicht übernommen werden.", "error") + return redirect(url_for("main.planner_day", date=selected_date.isoformat())) + + daypart_id = int(daypart_raw) + meal_id = create_or_get_generated_meal( + name=meal_name, + component_ids=component_ids, + daypart_id=daypart_id, + visibility=visibility, + ) + shopping_result = insert_plan_entry( + item_id=meal_id, + daypart_id=daypart_id, + plan_date=selected_date, + visibility=visibility, + ) + if shopping_result["count"]: + flash("Die Kombination wurde eingeplant und fehlende Lebensmittel wurden ergänzt.", "info") + elif shopping_result["scheduled_count"]: + flash("Die Kombination wurde eingeplant. Fehlende Lebensmittel sind für später vorgemerkt.", "info") + flash("Die Kombination wurde als Mahlzeitenidee übernommen.", "success") + return redirect(f"{url_for('main.planner_day', date=selected_date.isoformat(), daypart_id=daypart_id)}#daypart-{daypart_id}") + + @main_bp.post("/planner//remove") @login_required def planner_remove(entry_id: int): @@ -2440,9 +3213,12 @@ def planner_move(entry_id: int): entry["visibility"], needed_for_date=target_date.isoformat(), needed_for_daypart_id=target_daypart_id, + source_item_id=entry["item_id"], ) if shopping_result["count"]: - flash("Fehlende Dinge wurden nach dem Verschieben auf die Einkaufsliste gesetzt.", "info") + flash("Fehlende Lebensmittel wurden nach dem Verschieben ergänzt.", "info") + elif shopping_result["scheduled_count"]: + flash("Fehlende Lebensmittel sind für den passenden Einkauf vorgemerkt.", "info") return jsonify( { "ok": True, diff --git a/nouri/push.py b/nouri/push.py new file mode 100644 index 0000000..f6f3369 --- /dev/null +++ b/nouri/push.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import json +from typing import Any + +from flask import current_app + + +def push_is_configured() -> bool: + return bool( + current_app.config.get("VAPID_PUBLIC_KEY") + and current_app.config.get("VAPID_PRIVATE_KEY") + and current_app.config.get("VAPID_SUBJECT") + ) + + +def push_public_key() -> str | None: + return current_app.config.get("VAPID_PUBLIC_KEY") or None + + +def send_push_message(subscription: dict[str, Any], *, title: str, body: str, url: str) -> tuple[bool, str | None]: + if not push_is_configured(): + return False, "Push ist noch nicht konfiguriert." + + try: + from pywebpush import WebPushException, webpush + except Exception: + return False, "Die Push-Bibliothek ist noch nicht installiert." + + payload = json.dumps( + { + "title": title, + "body": body, + "url": url, + "icon": "/static/brand/pwa-192.png", + "badge": "/static/brand/pwa-badge.png", + } + ) + + try: + webpush( + subscription_info=subscription, + data=payload, + vapid_private_key=current_app.config["VAPID_PRIVATE_KEY"], + vapid_claims={"sub": current_app.config["VAPID_SUBJECT"]}, + ) + except WebPushException as exc: # pragma: no cover - depends on live push endpoint + return False, str(exc) + + return True, None diff --git a/nouri/schema.sql b/nouri/schema.sql index 09d888f..c712481 100644 --- a/nouri/schema.sql +++ b/nouri/schema.sql @@ -3,6 +3,9 @@ PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS households ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, + shopping_weekday INTEGER NOT NULL DEFAULT 5, + shopping_prep_days INTEGER NOT NULL DEFAULT 1, + shopping_reminder_time TEXT NOT NULL DEFAULT '18:00', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -28,6 +31,7 @@ CREATE TABLE IF NOT EXISTS household_categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, household_id INTEGER NOT NULL, name TEXT NOT NULL, + builder_key TEXT NOT NULL DEFAULT 'neutral', sort_order INTEGER NOT NULL DEFAULT 100, is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -35,6 +39,41 @@ CREATE TABLE IF NOT EXISTS household_categories ( FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS user_settings ( + user_id INTEGER PRIMARY KEY, + reminders_enabled INTEGER NOT NULL DEFAULT 1, + push_enabled INTEGER NOT NULL DEFAULT 0, + notification_channel TEXT NOT NULL DEFAULT 'in_app', + remind_before_shopping INTEGER NOT NULL DEFAULT 1, + remind_on_shopping_day INTEGER NOT NULL DEFAULT 1, + show_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1, + show_planned_not_shopped INTEGER NOT NULL DEFAULT 1, + remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1, + remind_week_if_sparse INTEGER NOT NULL DEFAULT 1, + suggest_home_for_today INTEGER NOT NULL DEFAULT 1, + remind_small_snack INTEGER NOT NULL DEFAULT 0, + remind_nuts INTEGER NOT NULL DEFAULT 0, + show_meal_balancing INTEGER NOT NULL DEFAULT 1, + suggest_templates INTEGER NOT NULL DEFAULT 1, + suggest_patterns INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + user_agent TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_test_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS dayparts ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL UNIQUE, @@ -106,6 +145,29 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_entries_open_item ON shopping_entries (item_id) WHERE is_checked = 0; +CREATE TABLE IF NOT EXISTS shopping_needs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + household_id INTEGER NOT NULL, + owner_user_id INTEGER, + visibility TEXT NOT NULL DEFAULT 'shared', + item_id INTEGER NOT NULL, + source_item_id INTEGER, + needed_for_date TEXT NOT NULL, + needed_for_daypart_id INTEGER, + activation_date TEXT NOT NULL, + is_activated INTEGER NOT NULL DEFAULT 0, + activated_at TEXT, + created_by INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (item_id, source_item_id, needed_for_date, needed_for_daypart_id, visibility), + FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, + FOREIGN KEY (source_item_id) REFERENCES items(id) ON DELETE SET NULL, + FOREIGN KEY (needed_for_daypart_id) REFERENCES dayparts(id) ON DELETE SET NULL, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +); + CREATE TABLE IF NOT EXISTS plan_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, household_id INTEGER, @@ -218,6 +280,9 @@ ON plan_entries (household_id, visibility, plan_date); CREATE INDEX IF NOT EXISTS idx_shopping_entries_household_visibility ON shopping_entries (household_id, visibility, is_checked); +CREATE INDEX IF NOT EXISTS idx_shopping_needs_household_activation +ON shopping_needs (household_id, activation_date, is_activated); + CREATE INDEX IF NOT EXISTS idx_day_templates_household_visibility ON day_templates (household_id, visibility, name); diff --git a/nouri/static/brand/pwa-192.png b/nouri/static/brand/pwa-192.png new file mode 100644 index 0000000..24f0a95 Binary files /dev/null and b/nouri/static/brand/pwa-192.png differ diff --git a/nouri/static/brand/pwa-512.png b/nouri/static/brand/pwa-512.png new file mode 100644 index 0000000..145a5cf Binary files /dev/null and b/nouri/static/brand/pwa-512.png differ diff --git a/nouri/static/brand/pwa-badge.png b/nouri/static/brand/pwa-badge.png new file mode 100644 index 0000000..7f962fb Binary files /dev/null and b/nouri/static/brand/pwa-badge.png differ diff --git a/nouri/static/css/styles.css b/nouri/static/css/styles.css index ba33131..f3e5bdd 100644 --- a/nouri/static/css/styles.css +++ b/nouri/static/css/styles.css @@ -128,6 +128,29 @@ button.secondary:hover, margin: 1rem auto 2rem; } +.site-footer { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.75rem; + padding: 1rem 0.35rem 0; + color: var(--muted); + font-size: 0.92rem; +} + +.footer-copy { + display: inline-flex; + align-items: center; + gap: 0.55rem; + flex-wrap: wrap; +} + +.footer-copy .ui-icon { + width: 0.95rem; + height: 0.95rem; + color: var(--accent-strong); +} + .site-header { position: sticky; top: 1rem; @@ -445,6 +468,7 @@ h3 { justify-content: space-between; gap: 1rem; align-items: center; + padding: 1rem 1.1rem; } .stacked-mobile { @@ -616,6 +640,7 @@ input[type="text"], input[type="email"], input[type="password"], input[type="date"], +input[type="time"], input[type="file"], select, textarea { @@ -862,6 +887,111 @@ legend { margin-bottom: 1rem; } +.planner-subsection { + display: grid; + gap: 0.8rem; + margin-bottom: 1rem; +} + +.planner-subsection h3 { + font-size: 1rem; +} + +.planner-search { + display: grid; + gap: 0.45rem; + color: var(--muted); +} + +.compact-picker-list { + display: grid; + gap: 0.55rem; +} + +.compact-picker-list form[hidden] { + display: none; +} + +.picker-row { + width: 100%; + justify-content: space-between; + padding: 0.85rem 1rem; + border-radius: 16px; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--surface-strong) 86%, #fff 14%); + color: var(--text); +} + +.picker-row small { + color: var(--muted); +} + +.compact-quick-row { + margin-bottom: 0; +} + +.compact-button { + min-width: 150px; + padding: 0.78rem 0.9rem; +} + +.settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.pwa-card { + padding: 1rem; + border-radius: 18px; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--surface-strong) 84%, #fff 16%); +} + +.card-link-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} + +.menu-card { + display: grid; + justify-items: start; + gap: 0.5rem; + padding: 1rem; + border-radius: 18px; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--surface-strong) 86%, #fff 14%); +} + +.menu-card .ui-icon { + width: 1.15rem; + height: 1.15rem; + color: var(--accent-strong); +} + +.roomy-row { + padding: 1rem 1.2rem; +} + +.inline-form-tight { + grid-template-columns: 1fr auto; +} + +.inline-form-tight > :first-child { + grid-column: auto; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + .planner-entry-top { align-items: flex-start; } @@ -1018,6 +1148,41 @@ legend { mask-image: url("../icons/fa/ellipsis.svg"); } +.icon-heart { + -webkit-mask-image: url("../icons/fa/heart.svg"); + mask-image: url("../icons/fa/heart.svg"); +} + +.icon-sliders { + -webkit-mask-image: url("../icons/fa/sliders.svg"); + mask-image: url("../icons/fa/sliders.svg"); +} + +.icon-seedling { + -webkit-mask-image: url("../icons/fa/seedling.svg"); + mask-image: url("../icons/fa/seedling.svg"); +} + +.icon-bell { + -webkit-mask-image: url("../icons/fa/bell.svg"); + mask-image: url("../icons/fa/bell.svg"); +} + +.icon-mobile-screen-button { + -webkit-mask-image: url("../icons/fa/mobile-screen-button.svg"); + mask-image: url("../icons/fa/mobile-screen-button.svg"); +} + +.icon-apple-whole { + -webkit-mask-image: url("../icons/fa/apple-whole.svg"); + mask-image: url("../icons/fa/apple-whole.svg"); +} + +.icon-leaf { + -webkit-mask-image: url("../icons/fa/leaf.svg"); + mask-image: url("../icons/fa/leaf.svg"); +} + .mobile-sheet-backdrop { position: fixed; inset: 0; @@ -1054,17 +1219,10 @@ legend { .mobile-sheet-links { display: grid; - gap: 0.45rem; + gap: 0.75rem; margin: 1rem 0; } -.mobile-sheet-links a { - padding: 0.9rem 1rem; - border-radius: 16px; - border: 1px solid var(--line); - background: color-mix(in srgb, var(--surface-strong) 86%, #fff 14%); -} - .mobile-sheet-actions { flex-wrap: wrap; } @@ -1091,6 +1249,7 @@ legend { .stats-grid, .two-column, .template-library-grid, + .settings-grid, .inline-form, .planner-entry-form, .planner-entry-form-wide, @@ -1158,6 +1317,10 @@ legend { padding: 1rem; } + .site-footer { + padding-bottom: 5.6rem; + } + h1 { font-size: clamp(1.6rem, 7vw, 2rem); } @@ -1174,7 +1337,9 @@ legend { .week-mini-grid, .week-overview-grid, .more-link-grid, - .template-library-grid { + .template-library-grid, + .settings-grid, + .card-link-grid { grid-template-columns: 1fr; } diff --git a/nouri/static/icons/fa/apple-whole.svg b/nouri/static/icons/fa/apple-whole.svg new file mode 100644 index 0000000..dd184e4 --- /dev/null +++ b/nouri/static/icons/fa/apple-whole.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/icons/fa/bell.svg b/nouri/static/icons/fa/bell.svg new file mode 100644 index 0000000..63352d3 --- /dev/null +++ b/nouri/static/icons/fa/bell.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/icons/fa/heart.svg b/nouri/static/icons/fa/heart.svg new file mode 100644 index 0000000..c029a4b --- /dev/null +++ b/nouri/static/icons/fa/heart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/icons/fa/leaf.svg b/nouri/static/icons/fa/leaf.svg new file mode 100644 index 0000000..bfe8bef --- /dev/null +++ b/nouri/static/icons/fa/leaf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/icons/fa/mobile-screen-button.svg b/nouri/static/icons/fa/mobile-screen-button.svg new file mode 100644 index 0000000..3a2eb40 --- /dev/null +++ b/nouri/static/icons/fa/mobile-screen-button.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/icons/fa/seedling.svg b/nouri/static/icons/fa/seedling.svg new file mode 100644 index 0000000..514a73c --- /dev/null +++ b/nouri/static/icons/fa/seedling.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/icons/fa/sliders.svg b/nouri/static/icons/fa/sliders.svg new file mode 100644 index 0000000..3ce0f97 --- /dev/null +++ b/nouri/static/icons/fa/sliders.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/js/pwa.js b/nouri/static/js/pwa.js new file mode 100644 index 0000000..8d629ff --- /dev/null +++ b/nouri/static/js/pwa.js @@ -0,0 +1,96 @@ +(() => { + const getCsrfToken = () => { + const meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.getAttribute("content") : ""; + }; + + const getPushPublicKey = () => { + const meta = document.querySelector('meta[name="nouri-push-public-key"]'); + return meta ? meta.getAttribute("content") : ""; + }; + + const urlBase64ToUint8Array = (base64String) => { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + const rawData = window.atob(base64); + return Uint8Array.from([...rawData].map((character) => character.charCodeAt(0))); + }; + + const registerServiceWorker = async () => { + if (!("serviceWorker" in navigator)) return null; + return navigator.serviceWorker.register("/service-worker.js"); + }; + + const subscribeToPush = async () => { + const publicKey = getPushPublicKey(); + if (!publicKey || !("serviceWorker" in navigator) || !("PushManager" in window)) return; + const registration = await navigator.serviceWorker.ready; + const permission = await Notification.requestPermission(); + if (permission !== "granted") return; + + const existing = await registration.pushManager.getSubscription(); + const subscription = existing || await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }); + const subscriptionJson = subscription.toJSON(); + const payload = new URLSearchParams({ + csrf_token: getCsrfToken(), + endpoint: subscription.endpoint, + p256dh: subscriptionJson.keys && subscriptionJson.keys.p256dh ? subscriptionJson.keys.p256dh : "", + auth: subscriptionJson.keys && subscriptionJson.keys.auth ? subscriptionJson.keys.auth : "", + }); + await fetch("/push/subscribe", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + "X-Requested-With": "XMLHttpRequest", + }, + body: payload.toString(), + }); + window.location.reload(); + }; + + const unsubscribeFromPush = async () => { + if (!("serviceWorker" in navigator)) return; + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + const payload = new URLSearchParams({ csrf_token: getCsrfToken() }); + if (subscription) { + payload.set("endpoint", subscription.endpoint); + await subscription.unsubscribe(); + } + await fetch("/push/unsubscribe", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + "X-Requested-With": "XMLHttpRequest", + }, + body: payload.toString(), + }); + window.location.reload(); + }; + + document.addEventListener("DOMContentLoaded", () => { + registerServiceWorker(); + + const enableButton = document.querySelector("[data-push-enable]"); + const disableButton = document.querySelector("[data-push-disable]"); + + if (enableButton) { + enableButton.addEventListener("click", () => { + subscribeToPush().catch(() => { + window.location.reload(); + }); + }); + } + + if (disableButton) { + disableButton.addEventListener("click", () => { + unsubscribeFromPush().catch(() => { + window.location.reload(); + }); + }); + } + }); +})(); diff --git a/nouri/static/pwa/app.webmanifest b/nouri/static/pwa/app.webmanifest new file mode 100644 index 0000000..4a89c98 --- /dev/null +++ b/nouri/static/pwa/app.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "Nouri", + "short_name": "Nouri", + "description": "einfach essen planen", + "lang": "de", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#fff6ef", + "theme_color": "#efab72", + "icons": [ + { + "src": "/static/brand/pwa-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/static/brand/pwa-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/nouri/static/pwa/service-worker.js b/nouri/static/pwa/service-worker.js new file mode 100644 index 0000000..669a146 --- /dev/null +++ b/nouri/static/pwa/service-worker.js @@ -0,0 +1,74 @@ +const CACHE_NAME = "nouri-v0-5-0"; +const APP_SHELL = [ + "/", + "/static/css/styles.css", + "/static/js/theme.js", + "/static/js/ui.js", + "/static/js/planner.js", + "/static/js/pwa.js", + "/static/brand/pwa-192.png", + "/static/brand/pwa-512.png", + "/static/brand/favicon.svg", +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") return; + event.respondWith( + caches.match(event.request).then((cached) => { + if (cached) return cached; + return fetch(event.request).then((response) => { + if (!response || response.status !== 200 || response.type !== "basic") { + return response; + } + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); + return response; + }); + }) + ); +}); + +self.addEventListener("push", (event) => { + if (!event.data) return; + const data = event.data.json(); + event.waitUntil( + self.registration.showNotification(data.title || "Nouri", { + body: data.body || "", + icon: data.icon || "/static/brand/pwa-192.png", + badge: data.badge || "/static/brand/pwa-badge.png", + data: { url: data.url || "/" }, + }) + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const targetUrl = event.notification.data && event.notification.data.url ? event.notification.data.url : "/"; + event.waitUntil( + clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => { + for (const client of clientList) { + if (client.url.includes(targetUrl) && "focus" in client) { + return client.focus(); + } + } + if (clients.openWindow) { + return clients.openWindow(targetUrl); + } + return null; + }) + ); +}); diff --git a/nouri/templates/admin/categories.html b/nouri/templates/admin/categories.html index f6cb5f4..db7f2e3 100644 --- a/nouri/templates/admin/categories.html +++ b/nouri/templates/admin/categories.html @@ -17,6 +17,14 @@ Neue Kategorie + @@ -26,8 +34,12 @@
{{ category.name }} -

{% if category.name in default_categories %}Teil der ruhigen Standardauswahl{% else %}Eigene Haushaltskategorie{% endif %}

+

+ {% if category.name in default_categories %}Teil der ruhigen Standardauswahl{% else %}Eigene Haushaltskategorie{% endif %} + · {{ builder_descriptions[category.builder_key] }} +

+ {{ builder_descriptions[category.builder_key].split('.')[0] }} {% if category.is_active %} Aktiv {% else %} @@ -36,6 +48,18 @@
+
+ {{ csrf_input() }} + + +
{{ csrf_input() }}
+
+
+

Optionen

+ Zu den Einstellungen +
+
+
diff --git a/nouri/templates/base.html b/nouri/templates/base.html index f3d55f0..3142b0f 100644 --- a/nouri/templates/base.html +++ b/nouri/templates/base.html @@ -4,12 +4,20 @@ {% block title %}Nouri{% endblock %} + + + + + + + +
-