4 Commits

41 changed files with 2292 additions and 286 deletions
+3
View File
@@ -9,3 +9,6 @@ __pycache__/
data/ data/
instance/ instance/
.cloudron-push.env
.env.local
.env.push.local
+2 -2
View File
@@ -4,8 +4,8 @@
"author": "Florian Heinz", "author": "Florian Heinz",
"description": "Private Flask app for meals, shopping and gentle food planning", "description": "Private Flask app for meals, shopping and gentle food planning",
"tagline": "einfach essen planen", "tagline": "einfach essen planen",
"version": "0.4.0", "version": "0.5.0",
"upstreamVersion": "0.4.0", "upstreamVersion": "0.5.0",
"healthCheckPath": "/", "healthCheckPath": "/",
"httpPort": 8000, "httpPort": 8000,
"manifestVersion": 2, "manifestVersion": 2,
+18 -6
View File
@@ -2,19 +2,31 @@ FROM python:3.13-slim
WORKDIR /app/code WORKDIR /app/code
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1
PYTHONUNBUFFERED=1 \ ENV PYTHONUNBUFFERED=1
PIP_NO_CACHE_DIR=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/* && rm -rf /var/lib/apt/lists/*
RUN groupadd --gid 1000 cloudron \
&& useradd --uid 1000 --gid 1000 --create-home --home-dir /home/cloudron cloudron
COPY requirements.txt /app/code/ 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 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 EXPOSE 8000
+52
View File
@@ -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/
+41
View File
@@ -0,0 +1,41 @@
# Push-Setup für Nouri
## 1. VAPID-Schlüssel erzeugen
```bash
. .venv/bin/activate
python scripts/generate_vapid_keys.py
```
Das Script gibt drei Zeilen aus:
- `NOURI_VAPID_PUBLIC_KEY`
- `NOURI_VAPID_PRIVATE_KEY`
- `NOURI_VAPID_SUBJECT`
## 2. In Cloudron eintragen
In der bestehenden Nouri-App unter `Settings``Environment Variables` diese drei Werte anlegen:
```text
NOURI_VAPID_PUBLIC_KEY=...
NOURI_VAPID_PRIVATE_KEY=...
NOURI_VAPID_SUBJECT=mailto:mail@hnz.io
```
Danach die App neu starten.
## 3. Auf dem iPhone aktivieren
1. `nouri.heinz.media` in Safari öffnen
2. `Teilen``Zum Home-Bildschirm`
3. die installierte Web-App öffnen
4. in Nouri `Optionen` öffnen
5. `Push erlauben` tippen
6. danach optional `Test-Mitteilung senden`
## 4. Bereits vorbereitete lokale Datei
Wenn lokal bereits eine Datei `.cloudron-push.env` liegt, kannst du deren Werte direkt nach Cloudron übernehmen.
Die Datei ist absichtlich in `.gitignore`, damit keine geheimen Schlüssel committed werden.
+16 -4
View File
@@ -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. 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 - Lebensmittel und Mahlzeitenideen anlegen
- Fotos lokal hochladen - 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 - kompaktere mobile Navigation mit Bottom-Bar
- Tagesvorlagen und Wochenvorlagen - Tagesvorlagen und Wochenvorlagen
- kleine Pakete für wiederkehrende Einkaufs- oder Planungsbausteine - 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 - globale Kategorien pro Haushalt
- „Für wen?“ direkt an Lebensmitteln und Mahlzeiten - „Für wen?“ direkt an Lebensmitteln und Mahlzeiten
- Mobile-Mehr-Menü als Sheet statt eigener Seite - 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 ## Lokal starten
@@ -44,10 +49,13 @@ Wichtige Umgebungsvariablen:
- `NOURI_SECRET_KEY`: Session-Secret für Produktion - `NOURI_SECRET_KEY`: Session-Secret für Produktion
- `NOURI_DATA_DIR`: Pfad für Datenbank und Uploads, z. B. `/app/data` auf Cloudron - `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_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 ## 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 - `/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. 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).
+47
View File
@@ -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.
+25
View File
@@ -11,13 +11,18 @@ from . import db
from .admin import admin_bp from .admin import admin_bp
from .auth import auth_bp from .auth import auth_bp
from .constants import ( from .constants import (
BUILDER_DESCRIPTIONS,
BUILDER_LABELS,
BUILDER_OPTIONS,
DAYPARTS, DAYPARTS,
DEFAULT_CATEGORIES, DEFAULT_CATEGORIES,
ITEM_KIND_LABELS, ITEM_KIND_LABELS,
ITEM_KIND_SINGULAR_LABELS, ITEM_KIND_SINGULAR_LABELS,
NOTIFICATION_CHANNEL_OPTIONS,
ROLE_LABELS, ROLE_LABELS,
VISIBILITY_DESCRIPTIONS, VISIBILITY_DESCRIPTIONS,
VISIBILITY_LABELS, VISIBILITY_LABELS,
WEEKDAY_OPTIONS,
) )
from .main import main_bp from .main import main_bp
@@ -63,6 +68,10 @@ def create_app() -> Flask:
PERMANENT_SESSION_LIFETIME=timedelta(days=30), PERMANENT_SESSION_LIFETIME=timedelta(days=30),
SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax", 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) db.init_app(app)
@@ -78,11 +87,19 @@ def create_app() -> Flask:
"item_kind_labels": ITEM_KIND_LABELS, "item_kind_labels": ITEM_KIND_LABELS,
"item_kind_singular_labels": ITEM_KIND_SINGULAR_LABELS, "item_kind_singular_labels": ITEM_KIND_SINGULAR_LABELS,
"category_suggestions": DEFAULT_CATEGORIES, "category_suggestions": DEFAULT_CATEGORIES,
"builder_labels": BUILDER_LABELS,
"builder_descriptions": BUILDER_DESCRIPTIONS,
"builder_options": BUILDER_OPTIONS,
"daypart_suggestions": DAYPARTS, "daypart_suggestions": DAYPARTS,
"visibility_labels": VISIBILITY_LABELS, "visibility_labels": VISIBILITY_LABELS,
"visibility_descriptions": VISIBILITY_DESCRIPTIONS, "visibility_descriptions": VISIBILITY_DESCRIPTIONS,
"role_labels": ROLE_LABELS, "role_labels": ROLE_LABELS,
"weekday_options": WEEKDAY_OPTIONS,
"notification_channel_options": NOTIFICATION_CHANNEL_OPTIONS,
"today": date.today(), "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_name": lambda value: WEEKDAY_NAMES[value.weekday()],
"weekday_short_name": lambda value: WEEKDAY_SHORT_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", "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): def uploaded_file(filename: str):
return send_from_directory(app.config["UPLOAD_FOLDER"], filename) 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 return app
+35 -6
View File
@@ -4,7 +4,7 @@ from flask import Blueprint, flash, g, redirect, render_template, request, url_f
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from .auth import admin_required, can_remove_last_admin, validate_admin_user_form 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 from .db import get_db
@@ -197,6 +197,7 @@ def user_edit(user_id: int):
def category_settings(): def category_settings():
if request.method == "POST": if request.method == "POST":
name = request.form.get("name", "").strip() name = request.form.get("name", "").strip()
builder_key = request.form.get("builder_key", "neutral").strip()
if not name: if not name:
flash("Bitte einen Kategorienamen eintragen.", "error") flash("Bitte einen Kategorienamen eintragen.", "error")
else: else:
@@ -210,8 +211,8 @@ def category_settings():
).fetchone() ).fetchone()
if existing: if existing:
get_db().execute( get_db().execute(
"UPDATE household_categories SET is_active = 1 WHERE id = ?", "UPDATE household_categories SET is_active = 1, builder_key = ? WHERE id = ?",
(existing["id"],), (builder_key, existing["id"]),
) )
flash("Die Kategorie ist wieder aktiv.", "success") flash("Die Kategorie ist wieder aktiv.", "success")
else: else:
@@ -221,10 +222,10 @@ def category_settings():
).fetchone() ).fetchone()
get_db().execute( get_db().execute(
""" """
INSERT INTO household_categories (household_id, name, sort_order, is_active) INSERT INTO household_categories (household_id, name, builder_key, sort_order, is_active)
VALUES (?, ?, ?, 1) 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") flash("Die Kategorie wurde ergänzt.", "success")
get_db().commit() get_db().commit()
@@ -234,6 +235,9 @@ def category_settings():
"admin/categories.html", "admin/categories.html",
categories=fetch_household_categories(), categories=fetch_household_categories(),
default_categories=DEFAULT_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() get_db().commit()
flash("Die Kategorie wurde aktualisiert.", "success") flash("Die Kategorie wurde aktualisiert.", "success")
return redirect(url_for("admin.category_settings")) return redirect(url_for("admin.category_settings"))
@admin_bp.post("/categories/<int:category_id>/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"))
+51
View File
@@ -20,6 +20,57 @@ DEFAULT_CATEGORIES = [
"Kleines Essen", "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 = { ITEM_KIND_LABELS = {
"food": "Lebensmittel", "food": "Lebensmittel",
"meal": "Mahlzeitenideen", "meal": "Mahlzeitenideen",
+117 -4
View File
@@ -8,7 +8,7 @@ from flask import Flask, current_app, g
from flask.cli import with_appcontext from flask.cli import with_appcontext
from werkzeug.security import generate_password_hash 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: def get_db() -> sqlite3.Connection:
@@ -53,6 +53,9 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
CREATE TABLE IF NOT EXISTS households ( CREATE TABLE IF NOT EXISTS households (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, 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 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, id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL, household_id INTEGER NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
builder_key TEXT NOT NULL DEFAULT 'neutral',
sort_order INTEGER NOT NULL DEFAULT 100, sort_order INTEGER NOT NULL DEFAULT 100,
is_active INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 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"): if table_exists(database, "users"):
add_column_if_missing(database, "users", "household_id INTEGER") add_column_if_missing(database, "users", "household_id INTEGER")
add_column_if_missing(database, "users", "email TEXT") 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_date TEXT")
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER") 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"): if table_exists(database, "plan_entries"):
add_column_if_missing(database, "plan_entries", "household_id INTEGER") add_column_if_missing(database, "plan_entries", "household_id INTEGER")
add_column_if_missing(database, "plan_entries", "owner_user_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): for sort_order, name in enumerate(DEFAULT_CATEGORIES, start=10):
database.execute( database.execute(
""" """
INSERT OR IGNORE INTO household_categories (household_id, name, sort_order, is_active) INSERT OR IGNORE INTO household_categories (household_id, name, builder_key, sort_order, is_active)
VALUES (?, ?, ?, 1) 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") add_column_if_missing(database, "users", "updated_at TEXT")
default_household_id = ensure_default_household(database) 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( database.execute(
"UPDATE users SET household_id = ? WHERE household_id IS NULL", "UPDATE users SET household_id = ? WHERE household_id IS NULL",
(default_household_id,), (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 = ''") database.execute("UPDATE plan_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''")
sync_default_categories(database) sync_default_categories(database)
database.execute(
"""
INSERT OR IGNORE INTO user_settings (user_id)
SELECT id FROM users
"""
)
database.execute( database.execute(
""" """
@@ -236,6 +343,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
ON shopping_entries (household_id, visibility, is_checked) 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: def apply_schema(database: sqlite3.Connection) -> None:
+871 -95
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -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
+65
View File
@@ -3,6 +3,9 @@ PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS households ( CREATE TABLE IF NOT EXISTS households (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, 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 created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -28,6 +31,7 @@ CREATE TABLE IF NOT EXISTS household_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL, household_id INTEGER NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
builder_key TEXT NOT NULL DEFAULT 'neutral',
sort_order INTEGER NOT NULL DEFAULT 100, sort_order INTEGER NOT NULL DEFAULT 100,
is_active INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 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 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 ( CREATE TABLE IF NOT EXISTS dayparts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE, 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) ON shopping_entries (item_id)
WHERE is_checked = 0; 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 ( CREATE TABLE IF NOT EXISTS plan_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER, 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 CREATE INDEX IF NOT EXISTS idx_shopping_entries_household_visibility
ON shopping_entries (household_id, visibility, is_checked); 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 CREATE INDEX IF NOT EXISTS idx_day_templates_household_visibility
ON day_templates (household_id, visibility, name); ON day_templates (household_id, visibility, name);
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+174 -9
View File
@@ -128,6 +128,29 @@ button.secondary:hover,
margin: 1rem auto 2rem; 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 { .site-header {
position: sticky; position: sticky;
top: 1rem; top: 1rem;
@@ -445,6 +468,7 @@ h3 {
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
align-items: center; align-items: center;
padding: 1rem 1.1rem;
} }
.stacked-mobile { .stacked-mobile {
@@ -616,6 +640,7 @@ input[type="text"],
input[type="email"], input[type="email"],
input[type="password"], input[type="password"],
input[type="date"], input[type="date"],
input[type="time"],
input[type="file"], input[type="file"],
select, select,
textarea { textarea {
@@ -862,6 +887,111 @@ legend {
margin-bottom: 1rem; 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 { .planner-entry-top {
align-items: flex-start; align-items: flex-start;
} }
@@ -1018,6 +1148,41 @@ legend {
mask-image: url("../icons/fa/ellipsis.svg"); 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 { .mobile-sheet-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -1054,17 +1219,10 @@ legend {
.mobile-sheet-links { .mobile-sheet-links {
display: grid; display: grid;
gap: 0.45rem; gap: 0.75rem;
margin: 1rem 0; 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 { .mobile-sheet-actions {
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -1091,6 +1249,7 @@ legend {
.stats-grid, .stats-grid,
.two-column, .two-column,
.template-library-grid, .template-library-grid,
.settings-grid,
.inline-form, .inline-form,
.planner-entry-form, .planner-entry-form,
.planner-entry-form-wide, .planner-entry-form-wide,
@@ -1158,6 +1317,10 @@ legend {
padding: 1rem; padding: 1rem;
} }
.site-footer {
padding-bottom: 5.6rem;
}
h1 { h1 {
font-size: clamp(1.6rem, 7vw, 2rem); font-size: clamp(1.6rem, 7vw, 2rem);
} }
@@ -1174,7 +1337,9 @@ legend {
.week-mini-grid, .week-mini-grid,
.week-overview-grid, .week-overview-grid,
.more-link-grid, .more-link-grid,
.template-library-grid { .template-library-grid,
.settings-grid,
.card-link-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M224 112c-8.8 0-16-7.2-16-16l0-16c0-44.2 35.8-80 80-80l16 0c8.8 0 16 7.2 16 16l0 16c0 44.2-35.8 80-80 80l-16 0zM0 288c0-76.3 35.7-160 112-160 27.3 0 59.7 10.3 82.7 19.3 18.8 7.3 39.9 7.3 58.7 0 22.9-8.9 55.4-19.3 82.7-19.3 76.3 0 112 83.7 112 160 0 128-80 224-160 224-16.5 0-38.1-6.6-51.5-11.3-8.1-2.8-16.9-2.8-25 0-13.4 4.7-35 11.3-51.5 11.3-80 0-160-96-160-224z"/></svg>

After

Width:  |  Height:  |  Size: 631 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M224 0c-17.7 0-32 14.3-32 32l0 3.2C119 50 64 114.6 64 192l0 21.7c0 48.1-16.4 94.8-46.4 132.4L7.8 358.3C2.7 364.6 0 372.4 0 380.5 0 400.1 15.9 416 35.5 416l376.9 0c19.6 0 35.5-15.9 35.5-35.5 0-8.1-2.7-15.9-7.8-22.2l-9.8-12.2C400.4 308.5 384 261.8 384 213.7l0-21.7c0-77.4-55-142-128-156.8l0-3.2c0-17.7-14.3-32-32-32zM162 464c7.1 27.6 32.2 48 62 48s54.9-20.4 62-48l-124 0z"/></svg>

After

Width:  |  Height:  |  Size: 637 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M241 87.1l15 20.7 15-20.7C296 52.5 336.2 32 378.9 32 452.4 32 512 91.6 512 165.1l0 2.6c0 112.2-139.9 242.5-212.9 298.2-12.4 9.4-27.6 14.1-43.1 14.1s-30.8-4.6-43.1-14.1C139.9 410.2 0 279.9 0 167.7l0-2.6C0 91.6 59.6 32 133.1 32 175.8 32 216 52.5 241 87.1z"/></svg>

After

Width:  |  Height:  |  Size: 521 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M471.3 6.7C477.7 .6 487-1.6 495.6 1.2 505.4 4.5 512 13.7 512 24l0 186.9c0 131.2-108.1 237.1-238.8 237.1-77 0-143.4-49.5-167.5-118.7-35.4 30.8-57.7 76.1-57.7 126.7 0 13.3-10.7 24-24 24S0 469.3 0 456C0 381.1 38.2 315.1 96.1 276.3 131.4 252.7 173.5 240 216 240l80 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-80 0c-39.7 0-77.3 8.8-111 24.5 23.3-70 89.2-120.5 167-120.5 66.4 0 115.8-22.1 148.7-44 19.2-12.8 35.5-28.1 50.7-45.3z"/></svg>

After

Width:  |  Height:  |  Size: 685 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M16 64C16 28.7 44.7 0 80 0L304 0c35.3 0 64 28.7 64 64l0 384c0 35.3-28.7 64-64 64L80 512c-35.3 0-64-28.7-64-64L16 64zm64 0l0 304 224 0 0-304-224 0zM192 472c17.7 0 32-14.3 32-32s-14.3-32-32-32-32 14.3-32 32 14.3 32 32 32z"/></svg>

After

Width:  |  Height:  |  Size: 487 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M512 32C512 140.1 435.4 230.3 333.6 251.4 325.7 193.3 299.6 141 261.1 100.5 301.2 40 369.9 0 448 0l32 0c17.7 0 32 14.3 32 32zM0 96C0 78.3 14.3 64 32 64l32 0c123.7 0 224 100.3 224 224l0 192c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-160C100.3 320 0 219.7 0 96z"/></svg>

After

Width:  |  Height:  |  Size: 522 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M32 64C14.3 64 0 78.3 0 96s14.3 32 32 32l86.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48L480 128c17.7 0 32-14.3 32-32s-14.3-32-32-32L265.3 64C253 35.7 224.8 16 192 16s-61 19.7-73.3 48L32 64zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l246.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48l54.7 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-54.7 0c-12.3-28.3-40.5-48-73.3-48s-61 19.7-73.3 48L32 224zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l54.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48L480 448c17.7 0 32-14.3 32-32s-14.3-32-32-32l-246.7 0c-12.3-28.3-40.5-48-73.3-48s-61 19.7-73.3 48L32 384z"/></svg>

After

Width:  |  Height:  |  Size: 850 B

+96
View File
@@ -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();
});
});
}
});
})();
+23
View File
@@ -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"
}
]
}
+96
View File
@@ -0,0 +1,96 @@
const CACHE_NAME = "nouri-v0-5-1";
const STATIC_ASSETS = [
"/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(STATIC_ASSETS)).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;
const requestUrl = new URL(event.request.url);
const isStaticAsset =
requestUrl.origin === self.location.origin &&
(
requestUrl.pathname.startsWith("/static/")
|| requestUrl.pathname === "/app.webmanifest"
|| requestUrl.pathname === "/service-worker.js"
);
if (event.request.mode === "navigate") {
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
return;
}
if (!isStaticAsset) {
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;
})
);
});
+25 -1
View File
@@ -17,6 +17,14 @@
Neue Kategorie Neue Kategorie
<input type="text" name="name" placeholder="z. B. Süßes, Vorrat, Unterwegs"> <input type="text" name="name" placeholder="z. B. Süßes, Vorrat, Unterwegs">
</label> </label>
<label>
Passt eher zu
<select name="builder_key">
{% for value, label in builder_options %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Kategorie ergänzen</button> <button type="submit">Kategorie ergänzen</button>
</form> </form>
</section> </section>
@@ -26,8 +34,12 @@
<article class="list-row stacked-mobile"> <article class="list-row stacked-mobile">
<div> <div>
<strong>{{ category.name }}</strong> <strong>{{ category.name }}</strong>
<p class="muted">{% if category.name in default_categories %}Teil der ruhigen Standardauswahl{% else %}Eigene Haushaltskategorie{% endif %}</p> <p class="muted">
{% if category.name in default_categories %}Teil der ruhigen Standardauswahl{% else %}Eigene Haushaltskategorie{% endif %}
· {{ builder_descriptions[category.builder_key] }}
</p>
<div class="chip-row"> <div class="chip-row">
<span class="chip">{{ builder_descriptions[category.builder_key].split('.')[0] }}</span>
{% if category.is_active %} {% if category.is_active %}
<span class="chip status-home">Aktiv</span> <span class="chip status-home">Aktiv</span>
{% else %} {% else %}
@@ -36,6 +48,18 @@
</div> </div>
</div> </div>
<div class="row-actions"> <div class="row-actions">
<form method="post" action="{{ url_for('admin.category_update', category_id=category.id) }}" class="inline-form inline-form-tight">
{{ csrf_input() }}
<label>
<span class="sr-only">Baustein</span>
<select name="builder_key">
{% for value, label in builder_options %}
<option value="{{ value }}" {% if category.builder_key == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<button class="ghost-button" type="submit">Zuordnung speichern</button>
</form>
<form method="post" action="{{ url_for('admin.category_toggle', category_id=category.id) }}"> <form method="post" action="{{ url_for('admin.category_toggle', category_id=category.id) }}">
{{ csrf_input() }} {{ csrf_input() }}
<button class="ghost-button" type="submit"> <button class="ghost-button" type="submit">
+7
View File
@@ -15,6 +15,13 @@
</div> </div>
</section> </section>
<section class="panel compact-form-panel">
<div class="panel-head">
<h2>Optionen</h2>
<a href="{{ url_for('main.settings_view') }}">Zu den Einstellungen</a>
</div>
</section>
<section class="two-column"> <section class="two-column">
<article class="panel"> <article class="panel">
<div class="panel-head"> <div class="panel-head">
+30 -10
View File
@@ -4,12 +4,20 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Nouri{% endblock %}</title> <title>{% block title %}Nouri{% endblock %}</title>
<meta name="theme-color" content="#efab72">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Nouri">
<meta name="csrf-token" content="{{ csrf_token_value }}"> <meta name="csrf-token" content="{{ csrf_token_value }}">
<meta name="nouri-push-public-key" content="{{ push_public_key }}">
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='brand/favicon.svg') }}"> <link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='brand/favicon.svg') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/pwa-192.png') }}">
<link rel="manifest" href="{{ url_for('webmanifest') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<script defer src="{{ url_for('static', filename='js/theme.js') }}"></script> <script defer src="{{ url_for('static', filename='js/theme.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/planner.js') }}"></script> <script defer src="{{ url_for('static', filename='js/planner.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/ui.js') }}"></script> <script defer src="{{ url_for('static', filename='js/ui.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/pwa.js') }}"></script>
</head> </head>
<body class="{% if g.user %}has-mobile-nav{% endif %}"> <body class="{% if g.user %}has-mobile-nav{% endif %}">
<div class="page-shell"> <div class="page-shell">
@@ -33,12 +41,13 @@
<a href="{{ url_for('main.home_view') }}" class="{{ 'active' if request.endpoint == 'main.home_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-house"></span><span>Zuhause</span></span></a> <a href="{{ url_for('main.home_view') }}" class="{{ 'active' if request.endpoint == 'main.home_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-house"></span><span>Zuhause</span></span></a>
<a href="{{ url_for('main.item_list', kind='food') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'food' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></span></a> <a href="{{ url_for('main.item_list', kind='food') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'food' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></span></a>
<a href="{{ url_for('main.item_list', kind='meal') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'meal' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></span></a> <a href="{{ url_for('main.item_list', kind='meal') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'meal' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></span></a>
<a href="{{ url_for('main.template_library') }}" class="{{ 'active' if (request.endpoint or '').startswith('main.day_template') or (request.endpoint or '').startswith('main.week_template') or (request.endpoint or '').startswith('main.item_set') or request.endpoint == 'main.template_library' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-layer-group"></span><span>Vorlagen</span></span></a> <a href="{{ url_for('main.template_library') }}" class="{{ 'active' if (request.endpoint or '').startswith('main.day_template') or (request.endpoint or '').startswith('main.week_template') or (request.endpoint or '').startswith('main.item_set') or request.endpoint == 'main.template_library' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-leaf"></span><span>Vorlagen</span></span></a>
<a href="{{ url_for('main.archive_view') }}" class="{{ 'active' if request.endpoint == 'main.archive_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-archive"></span><span>Archiv</span></span></a> <a href="{{ url_for('main.archive_view') }}" class="{{ 'active' if request.endpoint == 'main.archive_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-archive"></span><span>Archiv</span></span></a>
</nav> </nav>
<div class="header-actions desktop-actions"> <div class="header-actions desktop-actions">
<button class="theme-toggle ghost-button" type="button" data-theme-toggle>Modus</button> <button class="theme-toggle ghost-button" type="button" data-theme-toggle>Modus</button>
<a class="ghost-button" href="{{ url_for('main.settings_view') }}">Optionen</a>
<a class="user-chip" href="{{ url_for('auth.profile') }}"> <a class="user-chip" href="{{ url_for('auth.profile') }}">
<span class="user-chip-title">{{ g.user.display_name or g.user.username }}</span> <span class="user-chip-title">{{ g.user.display_name or g.user.username }}</span>
<small>{{ role_labels[g.user.role] }}</small> <small>{{ role_labels[g.user.role] }}</small>
@@ -71,6 +80,16 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<footer class="site-footer">
<div class="footer-copy">
<span>Version {{ app_version }}</span>
<span>Made with <span class="ui-icon icon-heart"></span> in Göttingen</span>
</div>
<div class="footer-copy">
<span>&copy; 2026 <a href="https://hnz.io" target="_blank" rel="noreferrer">@ hnz.io</a></span>
</div>
</footer>
</div> </div>
{% if g.user %} {% if g.user %}
@@ -83,16 +102,17 @@
</div> </div>
<button class="ghost-button" type="button" data-mobile-sheet-close>Schließen</button> <button class="ghost-button" type="button" data-mobile-sheet-close>Schließen</button>
</div> </div>
<nav class="mobile-sheet-links"> <nav class="mobile-sheet-links card-link-grid">
<a href="{{ url_for('main.item_list', kind='food') }}">Lebensmittel</a> <a class="menu-card" href="{{ url_for('main.item_list', kind='food') }}"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></a>
<a href="{{ url_for('main.item_list', kind='meal') }}">Mahlzeiten</a> <a class="menu-card" href="{{ url_for('main.item_list', kind='meal') }}"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></a>
<a href="{{ url_for('main.home_view') }}">Zuhause</a> <a class="menu-card" href="{{ url_for('main.home_view') }}"><span class="ui-icon icon-house"></span><span>Zuhause</span></a>
<a href="{{ url_for('main.archive_view') }}">Archiv</a> <a class="menu-card" href="{{ url_for('main.archive_view') }}"><span class="ui-icon icon-archive"></span><span>Archiv</span></a>
<a href="{{ url_for('main.template_library') }}">Vorlagen</a> <a class="menu-card" href="{{ url_for('main.template_library') }}"><span class="ui-icon icon-leaf"></span><span>Vorlagen</span></a>
<a href="{{ url_for('auth.profile') }}">Profil</a> <a class="menu-card" href="{{ url_for('main.settings_view') }}"><span class="ui-icon icon-sliders"></span><span>Optionen</span></a>
<a class="menu-card" href="{{ url_for('auth.profile') }}"><span class="ui-icon icon-heart"></span><span>Profil</span></a>
{% if g.user.role == 'admin' %} {% if g.user.role == 'admin' %}
<a href="{{ url_for('admin.user_list') }}">Nutzerverwaltung</a> <a class="menu-card" href="{{ url_for('admin.user_list') }}"><span class="ui-icon icon-sparkles"></span><span>Nutzer</span></a>
<a href="{{ url_for('admin.category_settings') }}">Kategorien</a> <a class="menu-card" href="{{ url_for('admin.category_settings') }}"><span class="ui-icon icon-seedling"></span><span>Kategorien</span></a>
{% endif %} {% endif %}
</nav> </nav>
<div class="mobile-sheet-actions"> <div class="mobile-sheet-actions">
+48 -3
View File
@@ -5,7 +5,7 @@
<div> <div>
<p class="eyebrow">Heute</p> <p class="eyebrow">Heute</p>
<h1>Ein ruhiger Blick auf euren Alltag</h1> <h1>Ein ruhiger Blick auf euren Alltag</h1>
<p class="lead">Du siehst schnell, was zuhause da ist, was schon geplant wurde, welche Vorlagen gut passen und wo sanfte Unterstützung hilfreich sein kann.</p> <p class="lead">Du siehst schnell, was zuhause da ist, was schon geplant wurde, welche Vorlagen gut passen und wo heute noch etwas ergänzt werden könnte.</p>
</div> </div>
<div class="hero-actions"> <div class="hero-actions">
<a class="button" href="{{ url_for('main.planner_day', date=today.isoformat()) }}">Heutigen Tagesplan öffnen</a> <a class="button" href="{{ url_for('main.planner_day', date=today.isoformat()) }}">Heutigen Tagesplan öffnen</a>
@@ -34,7 +34,7 @@
{% if dashboard_hints %} {% if dashboard_hints %}
<section class="panel"> <section class="panel">
<div class="panel-head"> <div class="panel-head">
<h2>Sanfte Hinweise</h2> <h2>Heute passend</h2>
</div> </div>
<div class="hint-list"> <div class="hint-list">
{% for hint in dashboard_hints %} {% for hint in dashboard_hints %}
@@ -105,6 +105,28 @@
</section> </section>
<section class="two-column"> <section class="two-column">
<article class="panel">
<div class="panel-head">
<h2>Was zuhause gut zusammenpasst</h2>
<a href="{{ url_for('main.home_view') }}">Zuhause öffnen</a>
</div>
{% if recipe_suggestions %}
<div class="stack-sections">
{% for suggestion in recipe_suggestions %}
<article class="template-list-card">
<div>
<strong>{{ suggestion.title }}</strong>
<small>{{ suggestion.reason }}</small>
</div>
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
</article>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Sobald ein paar Dinge unter Zuhause liegen, zeigt Nouri hier kleine Kombinationsideen.</p>
{% endif %}
</article>
<article class="panel"> <article class="panel">
<div class="panel-head"> <div class="panel-head">
<h2>Vorlagen für später</h2> <h2>Vorlagen für später</h2>
@@ -129,6 +151,29 @@
<p class="empty-state">Vorlagen helfen später beim Wiederverwenden. Du kannst sie direkt aus einem Tag oder einer Woche heraus anlegen.</p> <p class="empty-state">Vorlagen helfen später beim Wiederverwenden. Du kannst sie direkt aus einem Tag oder einer Woche heraus anlegen.</p>
{% endif %} {% endif %}
</article> </article>
</section>
<section class="two-column">
<article class="panel">
<div class="panel-head">
<h2>Nächster Einkauf</h2>
<a href="{{ url_for('main.shopping_list') }}">Zur Einkaufsliste</a>
</div>
{% if upcoming_entries %}
<div class="stack-sections">
{% for entry in upcoming_entries %}
<article class="template-list-card">
<div>
<strong>{{ entry.item_name }}</strong>
<small>Wird ab {{ entry.activation_label }} sichtbar · {{ entry.needed_for_label }}</small>
</div>
</article>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Gerade ist nichts für spätere Einkäufe vorgemerkt.</p>
{% endif %}
</article>
<article class="panel"> <article class="panel">
<div class="panel-head"> <div class="panel-head">
@@ -144,7 +189,7 @@
<small>{{ card.filled_dayparts | map(attribute='name') | join(', ') }}</small> <small>{{ card.filled_dayparts | map(attribute='name') | join(', ') }}</small>
{% else %} {% else %}
<span>Noch frei</span> <span>Noch frei</span>
<small>sanfter Einstieg für den Tag</small> <small>ruhiger Einstieg für den Tag</small>
{% endif %} {% endif %}
</a> </a>
{% endfor %} {% endfor %}
+20
View File
@@ -39,6 +39,26 @@
</form> </form>
</section> </section>
{% if recipe_suggestions %}
<section class="panel">
<div class="panel-head">
<h2>Passt gut dazu</h2>
<a href="{{ url_for('main.item_create', kind='meal') }}">Neue Mahlzeit</a>
</div>
<div class="stack-sections">
{% for suggestion in recipe_suggestions %}
<article class="template-list-card">
<div>
<strong>{{ suggestion.title }}</strong>
<small>{{ suggestion.reason }}</small>
</div>
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
</article>
{% endfor %}
</div>
</section>
{% endif %}
{% if sections %} {% if sections %}
<section class="stack-sections"> <section class="stack-sections">
{% for section in sections if section["items"] %} {% for section in sections if section["items"] %}
+1 -1
View File
@@ -38,7 +38,7 @@
{% if template_hints %} {% if template_hints %}
<section class="panel"> <section class="panel">
<div class="panel-head"> <div class="panel-head">
<h2>Sanfte Hinweise</h2> <h2>Gerade passend</h2>
</div> </div>
<div class="hint-list"> <div class="hint-list">
{% for hint in template_hints %} {% for hint in template_hints %}
+73 -43
View File
@@ -42,7 +42,7 @@
{% if day_hints %} {% if day_hints %}
<article class="panel"> <article class="panel">
<div class="panel-head"> <div class="panel-head">
<h2>Sanfte Hinweise</h2> <h2>Heute im Blick</h2>
</div> </div>
<div class="hint-list"> <div class="hint-list">
{% for hint in day_hints %} {% for hint in day_hints %}
@@ -72,65 +72,95 @@
</summary> </summary>
<div class="day-tile-body"> <div class="day-tile-body">
{% if section.suggestions %} {% if section.balance_suggestion %}
<div class="suggestion-row"> <div class="suggestion-card">
{% for suggestion in section.suggestions %} <strong>{{ section.balance_suggestion.text }}</strong>
<article class="suggestion-card"> {% if section.balance_suggestion["items"] %}
<strong>{{ suggestion.title }}</strong> <div class="quick-add-row compact-quick-row">
<small>{{ suggestion.reason }}</small> {% for item in section.balance_suggestion["items"] %}
<p>{{ suggestion.note }}</p>
</article>
{% endfor %}
</div>
{% endif %}
{% if section.quick_items %}
<div class="quick-add-row">
{% for item in section.quick_items %}
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}"> <form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}">
{{ csrf_input() }} {{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}"> <input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}"> <input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
<input type="hidden" name="item_id" value="{{ item.id }}"> <input type="hidden" name="item_id" value="{{ item.id }}">
<input type="hidden" name="visibility" value="{{ item.visibility }}"> <input type="hidden" name="visibility" value="{{ item.visibility }}">
<button class="quick-add-button" type="submit"> <button class="quick-add-button compact-button" type="submit">
<span>{{ item.name }}</span> <span>{{ item.name }}</span>
<small>{{ item_kind_labels[item.kind] }} · {{ item.visibility_label }} · {{ item.for_label }}{% if item.availability_state == 'home' %} · zuhause{% endif %}</small> <small>zuhause vorhanden</small>
</button> </button>
</form> </form>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
</div>
{% endif %}
<form method="post" class="planner-entry-form planner-entry-form-wide"> {% if section.meal_candidates %}
<div class="planner-subsection">
<h3>Mahlzeitenideen</h3>
<div class="quick-add-row compact-quick-row">
{% for item in section.meal_candidates %}
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}">
{{ csrf_input() }} {{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}"> <input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}"> <input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
<label class="wide"> <input type="hidden" name="item_id" value="{{ item.id }}">
Eintrag hinzufügen <input type="hidden" name="visibility" value="{{ item.visibility }}">
<select name="item_id"> <button class="quick-add-button compact-button" type="submit">
<option value="">Etwas für {{ section.daypart.name }} wählen</option> <span>{{ item.name }}</span>
{% for item in section.candidates %} {% if item.availability_state == 'home' %}<small>zuhause vorhanden</small>{% endif %}
<option value="{{ item.id }}" {% if section.selected_item_id == item.id %}selected{% endif %}> </button>
{{ item.name }} · {{ item_kind_labels[item.kind] }} · {{ item.visibility_label }} · {{ item.for_label }}{% if item.availability_state == 'home' %} · zuhause{% endif %}{% if item.dayparts and section.daypart.name not in item.dayparts %} · auch flexibel{% endif %}
</option>
{% endfor %}
</select>
</label>
<label>
Sichtbarkeit
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if section.default_visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="wide">
Notiz
<input type="text" name="note" placeholder="Optional, wenn eine kleine Erinnerung hilft">
</label>
<button type="submit">Eintragen</button>
</form> </form>
{% endfor %}
</div>
</div>
{% endif %}
{% if section.recipe_suggestions %}
<div class="planner-subsection">
<h3>Passt gut dazu</h3>
<div class="quick-add-row compact-quick-row">
{% for suggestion in section.recipe_suggestions %}
<form method="post" action="{{ url_for('main.planner_generated_meal') }}">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
<input type="hidden" name="meal_name" value="{{ suggestion.title }}">
<input type="hidden" name="visibility" value="shared">
{% for component_id in suggestion.component_ids %}
<input type="hidden" name="component_ids" value="{{ component_id }}">
{% endfor %}
<button class="quick-add-button compact-button" type="submit">
<span>{{ suggestion.title }}</span>
<small>{{ suggestion.reason }}</small>
</button>
</form>
{% endfor %}
</div>
</div>
{% endif %}
<div class="planner-subsection">
<label class="planner-search">
<span>Suche</span>
<input type="text" placeholder="Lebensmittel oder Mahlzeiten suchen" data-filter-input data-filter-target="#planner-list-{{ section.daypart.id }}">
</label>
<div class="compact-picker-list" id="planner-list-{{ section.daypart.id }}">
{% for item in section.food_candidates %}
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
<input type="hidden" name="item_id" value="{{ item.id }}">
<input type="hidden" name="visibility" value="{{ item.visibility }}">
<button class="picker-row" type="submit">
<span>{{ item.name }}</span>
{% if item.availability_state == 'home' %}<small>zuhause</small>{% endif %}
</button>
</form>
{% endfor %}
</div>
</div>
{% if section.entries %} {% if section.entries %}
<div class="planner-entry-list"> <div class="planner-entry-list">
+17 -3
View File
@@ -4,8 +4,8 @@
<section class="page-intro"> <section class="page-intro">
<div> <div>
<p class="eyebrow">Wochenansicht</p> <p class="eyebrow">Wochenansicht</p>
<h1>Ein sanfter Blick auf die nächsten sieben Tage</h1> <h1>Ein ruhiger Blick auf die nächsten sieben Tage</h1>
<p class="lead">Du kannst bestehende Einträge zwischen Tagen und Tageszeiten verschieben, Vorlagen anwenden und eine Woche bei Bedarf für später sichern.</p> <p class="lead">Du kannst bestehende Einträge zwischen Tagen und Tageszeiten verschieben, Vorlagen anwenden und gleichzeitig sehen, was erst später für den Einkauf relevant wird.</p>
</div> </div>
<div class="week-nav"> <div class="week-nav">
<a class="ghost-button" href="{{ url_for('main.planner', week=prev_week.isoformat()) }}">Vorige Woche</a> <a class="ghost-button" href="{{ url_for('main.planner', week=prev_week.isoformat()) }}">Vorige Woche</a>
@@ -42,7 +42,7 @@
{% if week_hints %} {% if week_hints %}
<article class="panel"> <article class="panel">
<div class="panel-head"> <div class="panel-head">
<h2>Sanfte Hinweise</h2> <h2>Für diese Woche</h2>
</div> </div>
<div class="hint-list"> <div class="hint-list">
{% for hint in week_hints %} {% for hint in week_hints %}
@@ -53,6 +53,20 @@
{% endif %} {% endif %}
</section> </section>
{% if upcoming_entries %}
<section class="panel">
<div class="panel-head">
<h2>Kommt später zum Einkauf dazu</h2>
<small>{{ household_settings.shopping_prep_days }} Tag{% if household_settings.shopping_prep_days != 1 %}e{% endif %} Vorlauf</small>
</div>
<div class="chip-row">
{% for entry in upcoming_entries %}
<span class="chip">{{ entry.item_name }} · ab {{ entry.activation_label }}</span>
{% endfor %}
</div>
</section>
{% endif %}
<section class="week-overview-grid week-board" data-csrf-token="{{ csrf_token_value }}"> <section class="week-overview-grid week-board" data-csrf-token="{{ csrf_token_value }}">
{% for card in week_cards %} {% for card in week_cards %}
<article class="week-card"> <article class="week-card">
+125
View File
@@ -0,0 +1,125 @@
{% extends "base.html" %}
{% block title %}Optionen | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Optionen</p>
<h1>Ruhige Einstellungen für Alltag, Einkauf und Erinnerungen</h1>
<p class="lead">Hier lässt sich festlegen, wann Einkäufe vorbereitet werden, welche Hinweise hilfreich sind und ob Nouri sich wie eine App auf dem Home-Bildschirm verhalten soll.</p>
</div>
</section>
<section class="two-column">
<article class="panel">
<div class="panel-head">
<h2>Einkaufstag</h2>
</div>
<form method="post" class="stack-form">
{{ csrf_input() }}
<input type="hidden" name="form_name" value="household">
<label>
Wochentag für den Großeinkauf
<select name="shopping_weekday">
{% for value, label in weekday_options %}
<option value="{{ value }}" {% if household_settings.shopping_weekday == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>
So viele Tage vorher vorbereiten
<input type="number" min="0" max="7" name="shopping_prep_days" value="{{ household_settings.shopping_prep_days }}">
</label>
<label>
Erinnerung ungefähr um
<input type="time" name="shopping_reminder_time" value="{{ household_settings.shopping_reminder_time }}">
</label>
<button type="submit">Speichern</button>
</form>
</article>
<article class="panel">
<div class="panel-head">
<h2>Home-Bildschirm & Push</h2>
</div>
<div class="stack-sections">
<div class="pwa-card">
<strong>Als Web-App nutzen</strong>
<p class="muted">Auf dem iPhone kannst du Nouri über Teilen → Zum Home-Bildschirm hinzufügen. Danach wirkt die App deutlich app-näher.</p>
</div>
<div class="pwa-card">
<strong>Push-Mitteilungen</strong>
{% if push_ready %}
<p class="muted">Push ist vorbereitet. Du kannst es auf diesem Gerät freigeben und später testweise prüfen.</p>
<div class="row-actions">
<button class="secondary" type="button" data-push-enable>Push erlauben</button>
<button class="ghost-button" type="button" data-push-disable>Push beenden</button>
</div>
<form method="post">
{{ csrf_input() }}
<input type="hidden" name="form_name" value="push_test">
<button class="ghost-button" type="submit">Test-Mitteilung senden</button>
</form>
<small class="helper-text">{{ push_subscription_count }} aktives Gerät{% if push_subscription_count != 1 %}e{% endif %}</small>
{% else %}
<p class="muted">Push wird sichtbar, sobald VAPID-Schlüssel für die App gesetzt sind.</p>
{% endif %}
</div>
</div>
</article>
</section>
<section class="panel">
<div class="panel-head">
<h2>Erinnerungen und Hinweise</h2>
</div>
<form method="post" class="stack-form">
{{ csrf_input() }}
<input type="hidden" name="form_name" value="reminders">
<div class="settings-grid">
<fieldset>
<legend>Grundsätzlich</legend>
<label class="inline-check"><input type="checkbox" name="reminders_enabled" value="1" {% if user_settings.reminders_enabled %}checked{% endif %}><span>Erinnerungen insgesamt nutzen</span></label>
<label class="inline-check"><input type="checkbox" name="push_enabled" value="1" {% if user_settings.push_enabled %}checked{% endif %}><span>Push-Mitteilungen erlauben</span></label>
<label>
Hinweise zeigen als
<select name="notification_channel">
{% for value, label in notification_channel_options %}
<option value="{{ value }}" {% if user_settings.notification_channel == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
</fieldset>
<fieldset>
<legend>Einkauf</legend>
<label class="inline-check"><input type="checkbox" name="remind_before_shopping" value="1" {% if user_settings.remind_before_shopping %}checked{% endif %}><span>Am Tag vor dem Einkauf erinnern</span></label>
<label class="inline-check"><input type="checkbox" name="remind_on_shopping_day" value="1" {% if user_settings.remind_on_shopping_day %}checked{% endif %}><span>Am Einkaufstag erinnern</span></label>
<label class="inline-check"><input type="checkbox" name="show_missing_for_upcoming_week" value="1" {% if user_settings.show_missing_for_upcoming_week %}checked{% endif %}><span>Fehlende Dinge für die kommende Woche zeigen</span></label>
<label class="inline-check"><input type="checkbox" name="show_planned_not_shopped" value="1" {% if user_settings.show_planned_not_shopped %}checked{% endif %}><span>Geplante, aber noch nicht eingekaufte Dinge zeigen</span></label>
</fieldset>
<fieldset>
<legend>Planung</legend>
<label class="inline-check"><input type="checkbox" name="remind_tomorrow_if_sparse" value="1" {% if user_settings.remind_tomorrow_if_sparse %}checked{% endif %}><span>Für morgen erinnern, wenn noch wenig geplant ist</span></label>
<label class="inline-check"><input type="checkbox" name="remind_week_if_sparse" value="1" {% if user_settings.remind_week_if_sparse %}checked{% endif %}><span>Für die Woche erinnern, wenn noch wenig eingeplant ist</span></label>
<label class="inline-check"><input type="checkbox" name="suggest_home_for_today" value="1" {% if user_settings.suggest_home_for_today %}checked{% endif %}><span>Passende Dinge aus Zuhause vorschlagen</span></label>
<label class="inline-check"><input type="checkbox" name="show_meal_balancing" value="1" {% if user_settings.show_meal_balancing %}checked{% endif %}><span>Zum Abrunden von Mahlzeiten kleine Vorschläge zeigen</span></label>
</fieldset>
<fieldset>
<legend>Alltag</legend>
<label class="inline-check"><input type="checkbox" name="remind_small_snack" value="1" {% if user_settings.remind_small_snack %}checked{% endif %}><span>An kleine Zwischenmahlzeiten erinnern</span></label>
<label class="inline-check"><input type="checkbox" name="remind_nuts" value="1" {% if user_settings.remind_nuts %}checked{% endif %}><span>Heute schon an Nüsse gedacht?</span></label>
<label class="inline-check"><input type="checkbox" name="suggest_templates" value="1" {% if user_settings.suggest_templates %}checked{% endif %}><span>Häufig genutzte Tages- und Wochenvorlagen vorschlagen</span></label>
<label class="inline-check"><input type="checkbox" name="suggest_patterns" value="1" {% if user_settings.suggest_patterns %}checked{% endif %}><span>Wiederkehrende Muster vorschlagen</span></label>
</fieldset>
</div>
<div class="form-actions">
<button type="submit">Speichern</button>
<a class="ghost-button" href="{{ url_for('auth.profile') }}">Zum Profil</a>
</div>
</form>
</section>
{% endblock %}
+34 -2
View File
@@ -5,7 +5,7 @@
<div> <div>
<p class="eyebrow">Einkaufsliste</p> <p class="eyebrow">Einkaufsliste</p>
<h1>Was noch mitkommen soll</h1> <h1>Was noch mitkommen soll</h1>
<p class="lead">Fehlende Lebensmittel aus einer Mahlzeit landen jetzt einzeln hier. So bleibt die Liste konkret und alltagsnah.</p> <p class="lead">Hier erscheint, was für den nächsten Einkauf wirklich relevant ist. Spätere Bedarfe bleiben erstmal ruhig vorgemerkt.</p>
</div> </div>
</section> </section>
@@ -26,9 +26,15 @@
</section> </section>
{% if entries %} {% if entries %}
<section class="panel compact-form-panel">
<div class="panel-head">
<h2>Für den nächsten Einkauf</h2>
<span>{{ entries|length }} Einträge</span>
</div>
</section>
<section class="stack-list"> <section class="stack-list">
{% for entry in entries %} {% for entry in entries %}
<article class="list-row stacked-mobile"> <article class="list-row stacked-mobile roomy-row">
<div> <div>
<strong>{{ entry.item_name }}</strong> <strong>{{ entry.item_name }}</strong>
<p class="muted">{{ item_kind_labels[entry.item_kind] }}</p> <p class="muted">{{ item_kind_labels[entry.item_kind] }}</p>
@@ -65,4 +71,30 @@
<p>Einträge aus Lebensmitteln, Vorlagen oder kleinen Paketen lassen sich jederzeit wieder hinzufügen.</p> <p>Einträge aus Lebensmitteln, Vorlagen oder kleinen Paketen lassen sich jederzeit wieder hinzufügen.</p>
</section> </section>
{% endif %} {% endif %}
{% if upcoming_entries %}
<section class="panel">
<div class="panel-head">
<h2>Später gebraucht</h2>
<small>Einkaufstag: {{ shopping_weekday_label }}</small>
</div>
<div class="stack-list">
{% for entry in upcoming_entries %}
<article class="list-row stacked-mobile roomy-row">
<div>
<strong>{{ entry.item_name }}</strong>
<p class="muted">Wird ab {{ entry.activation_label }} in die Einkaufsliste übernommen</p>
<div class="chip-row">
<span class="chip">{{ entry.for_label }}</span>
<span class="chip">{{ entry.needed_for_label }}</span>
{% if entry.needed_daypart_name %}
<span class="chip status-soft">{{ entry.needed_daypart_name }}</span>
{% endif %}
</div>
</div>
</article>
{% endfor %}
</div>
</section>
{% endif %}
{% endblock %} {% endblock %}
+1
View File
@@ -1,2 +1,3 @@
Flask==3.1.1 Flask==3.1.1
gunicorn==23.0.0 gunicorn==23.0.0
pywebpush==2.3.0
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from py_vapid import Vapid01, b64urlencode
from cryptography.hazmat.primitives import serialization
def main() -> None:
vapid = Vapid01()
vapid.generate_keys()
public_key = b64urlencode(
vapid.public_key.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
)
private_value = vapid.private_key.private_numbers().private_value
private_key = b64urlencode(private_value.to_bytes(32, "big"))
print(f"NOURI_VAPID_PUBLIC_KEY={public_key}")
print(f"NOURI_VAPID_PRIVATE_KEY={private_key}")
print("NOURI_VAPID_SUBJECT=mailto:mail@hnz.io")
if __name__ == "__main__":
main()
-1
View File
@@ -3,7 +3,6 @@ set -eu
export NOURI_DATA_DIR="${NOURI_DATA_DIR:-/app/data}" export NOURI_DATA_DIR="${NOURI_DATA_DIR:-/app/data}"
mkdir -p "${NOURI_DATA_DIR}" mkdir -p "${NOURI_DATA_DIR}"
touch "${NOURI_DATA_DIR}/nouri.sqlite3"
mkdir -p "${NOURI_DATA_DIR}/uploads" mkdir -p "${NOURI_DATA_DIR}/uploads"
exec gunicorn \ exec gunicorn \