Compare commits
1 Commits
b0d1cee5f5
...
V1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 325101da99 |
@@ -4,8 +4,8 @@
|
||||
"author": "Florian Heinz",
|
||||
"description": "Private Flask app for meals, shopping and gentle food planning",
|
||||
"tagline": "einfach essen planen",
|
||||
"version": "0.6.0",
|
||||
"upstreamVersion": "0.6.0",
|
||||
"version": "1.0.0",
|
||||
"upstreamVersion": "1.0.0",
|
||||
"healthCheckPath": "/",
|
||||
"httpPort": 8000,
|
||||
"manifestVersion": 2,
|
||||
|
||||
@@ -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.5
|
||||
## Merkmale in Version 1.0.0
|
||||
|
||||
- Lebensmittel und Mahlzeitenideen anlegen
|
||||
- Fotos lokal hochladen
|
||||
@@ -22,12 +22,8 @@ Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Ein
|
||||
- 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
|
||||
- PWA-Grundlage mit Web App Manifest, Service Worker und optionalem Web Push
|
||||
|
||||
## Lokal starten
|
||||
|
||||
@@ -47,28 +43,104 @@ Die App legt Daten standardmäßig unter `./data` ab.
|
||||
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_DATA_DIR`: Pfad für Datenbank und Uploads, lokal standardmäßig `./data`, auf Cloudron `/app/data`
|
||||
- `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5`
|
||||
- `NOURI_SECURE_COOKIES`: bei HTTPS in Produktion auf `1` setzen
|
||||
- `NOURI_TIMEZONE`: lokale Zeitzone, z. B. `Europe/Berlin`
|
||||
- `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.4 auf 0.5
|
||||
## Migration und Datenhaltung
|
||||
|
||||
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.
|
||||
Beim Start erweitert Nouri das SQLite-Schema pragmatisch direkt weiter. Vorhandene Daten bleiben dabei erhalten und werden weiterverwendet.
|
||||
|
||||
## Cloudron-Hinweis
|
||||
|
||||
Für Cloudron ist die App jetzt so vorbereitet, dass Datenbank und Uploads unter `/app/data` liegen. Das Startskript setzt `NOURI_DATA_DIR=/app/data`, legt die SQLite-Datei dort an und startet die App per `gunicorn`.
|
||||
|
||||
Lokale Testdaten und produktive Cloudron-Daten bleiben bewusst getrennt:
|
||||
Wichtig für die Trennung zwischen lokal und Produktion:
|
||||
|
||||
- lokal nutzt Nouri ohne gesetzte Variable standardmäßig `./data`
|
||||
- auf Cloudron nutzt Nouri `/app/data`
|
||||
- `data/` ist in `.gitignore` und `.dockerignore` ausgeschlossen und wird weder eingecheckt noch ins Image kopiert
|
||||
- `/app/data` ist auf Cloudron persistent und bleibt bei App-Updates erhalten
|
||||
- `data/` ist nicht für Git oder das Paket gedacht
|
||||
- produktive Daten unter `/app/data` bleiben bei 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.
|
||||
## Cloudron
|
||||
|
||||
Nouri ist so vorbereitet, dass Code und persistente Daten sauber getrennt bleiben:
|
||||
|
||||
- Code liegt im Container unter `/app/code`
|
||||
- persistente Daten liegen unter `/app/data`
|
||||
- Datenbank und Uploads werden nicht aus dem lokalen `./data` nach Produktion übernommen
|
||||
- Updates ersetzen den Code, aber nicht die produktiven Inhalte in `/app/data`
|
||||
|
||||
### Neu installieren
|
||||
|
||||
1. Paket oder Image bauen und nach Cloudron hochladen.
|
||||
2. Die App einmal per Cloudron installieren.
|
||||
3. Nach dem ersten Start Nouri öffnen.
|
||||
4. Den ersten Haushalt-Zugang unter `/auth/setup` anlegen.
|
||||
5. Danach Push, Erinnerungen und Einkaufstag in den Optionen einrichten.
|
||||
|
||||
### Bestehende Installation aktualisieren
|
||||
|
||||
Wenn Nouri bereits installiert ist, bitte **kein neues `cloudron install`** ausführen.
|
||||
Stattdessen die bestehende App aktualisieren, zum Beispiel mit:
|
||||
|
||||
```bash
|
||||
cloudron update --no-backup --app <deine-app> --server <dein-server> --token <dein-token>
|
||||
```
|
||||
|
||||
Dabei gilt:
|
||||
|
||||
- produktive Daten unter `/app/data` bleiben erhalten
|
||||
- lokale Testdaten aus `./data` werden nicht mit hochgeladen
|
||||
- die bestehende Installation läuft mit demselben persistenten Datenordner weiter
|
||||
|
||||
### Wichtige Cloudron-Variablen
|
||||
|
||||
Für eine saubere produktive Installation sind diese Werte sinnvoll:
|
||||
|
||||
- `NOURI_DATA_DIR=/app/data`
|
||||
- `NOURI_SECURE_COOKIES=1`
|
||||
- `NOURI_TIMEZONE=Europe/Berlin`
|
||||
- `NOURI_SECRET_KEY=<eigenes-secret>`
|
||||
- `NOURI_VAPID_PUBLIC_KEY=<public-key>`
|
||||
- `NOURI_VAPID_PRIVATE_KEY=<private-key>`
|
||||
- `NOURI_VAPID_SUBJECT=mailto:mail@hnz.io`
|
||||
|
||||
## Push einrichten
|
||||
|
||||
### 1. VAPID-Schlüssel erzeugen
|
||||
|
||||
Die Schritte dafür stehen kompakt in [PUSH_SETUP.md](PUSH_SETUP.md).
|
||||
|
||||
### 2. VAPID-Werte in Cloudron setzen
|
||||
|
||||
Zum Beispiel so:
|
||||
|
||||
```bash
|
||||
cloudron env set --app <deine-app> \
|
||||
NOURI_VAPID_PUBLIC_KEY='...' \
|
||||
NOURI_VAPID_PRIVATE_KEY='...' \
|
||||
NOURI_VAPID_SUBJECT='mailto:mail@hnz.io'
|
||||
```
|
||||
|
||||
Danach die App neu starten:
|
||||
|
||||
```bash
|
||||
cloudron restart --app <deine-app>
|
||||
```
|
||||
|
||||
### 3. Push auf dem Gerät aktivieren
|
||||
|
||||
1. Nouri im Browser oder auf dem iPhone öffnen.
|
||||
2. In `Optionen` auf **Push erlauben** tippen.
|
||||
3. Optional eine Test-Mitteilung senden.
|
||||
4. Auf dem iPhone Nouri am besten zusätzlich zum Home-Bildschirm hinzufügen.
|
||||
|
||||
Wichtig:
|
||||
|
||||
- Push funktioniert nur auf Geräten, die sich einmal aktiv registriert haben.
|
||||
- Ohne VAPID-Werte bleibt Push bewusst deaktiviert.
|
||||
- Die Browser- oder iPhone-Freigabe allein reicht nicht: Hinweise müssen zusätzlich in Nouri eingeschaltet sein.
|
||||
|
||||
## Lizenz
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Nouri 1.0.0
|
||||
|
||||
Nouri 1.0.0 fasst die ruhige Alltagsplanung, gemeinsame Nutzung im Haushalt und die ersten echten Push-Erinnerungen in einen stabileren Release-Stand zusammen. Der Fokus dieser Version liegt auf Verlässlichkeit, klareren Vorschlägen und einem saubereren produktiven Betrieb auf Cloudron.
|
||||
|
||||
## Highlights
|
||||
|
||||
- Vorschläge lassen sich jetzt gezielter über drei Modi steuern:
|
||||
- `Eher ausgewogen`
|
||||
- `Fitness`
|
||||
- `Proteinbetont`
|
||||
- Lebensmittel können zusätzlich als `Eher leicht`, `Neutral` oder `Eher gehaltvoll` markiert werden.
|
||||
- Push-Erinnerungen für fehlendes Frühstück, Mittagessen und Abendessen sind vorbereitet und in den Optionen pro Nutzer schaltbar.
|
||||
- Push öffnet direkt den passenden Tagesplan und kann eine schon vorbereitete Mahlzeitenidee mitbringen.
|
||||
- Saaten sind in der Kategorien-Logik jetzt separat unter `Passt eher zu` auswählbar.
|
||||
|
||||
## Planung und Vorschläge
|
||||
|
||||
- Nouri bevorzugt bei Vorschlägen weiterhin zuerst vorhandene Mahlzeitenideen.
|
||||
- Wenn noch keine passende Mahlzeitenidee da ist, werden Kombinationen aus den zuhause vorhandenen Lebensmitteln vorgeschlagen.
|
||||
- Für `Fitness` werden proteinbetonte und eher leichte Kombinationen bevorzugt.
|
||||
- Für `Proteinbetont` werden proteinreiche Kombinationen priorisiert, ohne zusätzlich auf „leicht“ zu ziehen.
|
||||
- Im Tagesplan können vorbereitete Vorschläge direkter übernommen werden.
|
||||
|
||||
## Erinnerungen und Push
|
||||
|
||||
- Neue Push-Schalter in den Optionen:
|
||||
- Frühstück um `08:00`
|
||||
- Mittagessen um `12:00`
|
||||
- Abendessen um `18:00`
|
||||
- Die Push-Nachricht enthält nach Möglichkeit direkt einen passenden Vorschlag.
|
||||
- Für den Versand wurde ein kleiner Reminder-Worker ergänzt.
|
||||
- Push bleibt weiterhin optional und funktioniert nur auf Geräten, die sich aktiv registriert haben.
|
||||
|
||||
## Sicherheit und Stabilität
|
||||
|
||||
- Passwortprüfung wurde angezogen.
|
||||
- Sichere Cookie-Konfiguration für HTTPS ist vorbereitet.
|
||||
- Zusätzliche Sicherheitsheader werden gesetzt.
|
||||
- Die Schema-Upgrades wurden weitergezogen, damit neue Einstellungen und Reminder-Daten sauber in bestehende Installationen einlaufen.
|
||||
|
||||
## Cloudron und Betrieb
|
||||
|
||||
- README wurde für Cloudron klarer dokumentiert:
|
||||
- Neuinstallation
|
||||
- Update statt Neuinstallation
|
||||
- persistente Daten unter `/app/data`
|
||||
- wichtige Umgebungsvariablen
|
||||
- VAPID- und Push-Einrichtung
|
||||
- Cloudron-Paketversion und App-Version stehen jetzt auf `1.0.0`.
|
||||
- Der Service Worker nutzt einen neuen Cache-Namen für den Release-Stand `1.0.0`.
|
||||
|
||||
## Upgrade-Hinweis
|
||||
|
||||
- Bestehende SQLite-Daten bleiben erhalten.
|
||||
- Neue Felder und Tabellen werden beim Start ergänzt.
|
||||
- Nach dem Update lohnt sich ein kurzer Blick in `Optionen`, damit Vorschlagsstil, Push und Erinnerungen einmal bewusst gesetzt sind.
|
||||
+14
-1
@@ -16,10 +16,14 @@ from .constants import (
|
||||
BUILDER_OPTIONS,
|
||||
DAYPARTS,
|
||||
DEFAULT_CATEGORIES,
|
||||
ENERGY_DENSITY_LABELS,
|
||||
ENERGY_DENSITY_OPTIONS,
|
||||
ITEM_KIND_LABELS,
|
||||
ITEM_KIND_SINGULAR_LABELS,
|
||||
NOTIFICATION_CHANNEL_OPTIONS,
|
||||
ROLE_LABELS,
|
||||
SUGGESTION_STYLE_LABELS,
|
||||
SUGGESTION_STYLE_OPTIONS,
|
||||
VISIBILITY_DESCRIPTIONS,
|
||||
VISIBILITY_LABELS,
|
||||
WEEKDAY_OPTIONS,
|
||||
@@ -69,7 +73,9 @@ def create_app() -> Flask:
|
||||
PERMANENT_SESSION_LIFETIME=timedelta(days=30),
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE="Lax",
|
||||
APP_VERSION="0.6.0",
|
||||
SESSION_COOKIE_SECURE=os.environ.get("NOURI_SECURE_COOKIES", "0") == "1",
|
||||
APP_VERSION="1.0.0",
|
||||
TIMEZONE=os.environ.get("NOURI_TIMEZONE", "Europe/Berlin"),
|
||||
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"),
|
||||
@@ -97,6 +103,10 @@ def create_app() -> Flask:
|
||||
"builder_descriptions": BUILDER_DESCRIPTIONS,
|
||||
"builder_options": BUILDER_OPTIONS,
|
||||
"daypart_suggestions": DAYPARTS,
|
||||
"energy_density_options": ENERGY_DENSITY_OPTIONS,
|
||||
"energy_density_labels": ENERGY_DENSITY_LABELS,
|
||||
"suggestion_style_options": SUGGESTION_STYLE_OPTIONS,
|
||||
"suggestion_style_labels": SUGGESTION_STYLE_LABELS,
|
||||
"visibility_labels": VISIBILITY_LABELS,
|
||||
"visibility_descriptions": VISIBILITY_DESCRIPTIONS,
|
||||
"role_labels": ROLE_LABELS,
|
||||
@@ -159,6 +169,9 @@ def create_app() -> Flask:
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
if content_type.startswith("text/html"):
|
||||
response.headers.setdefault("Cache-Control", "no-store, max-age=0")
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("Referrer-Policy", "same-origin")
|
||||
response.headers.setdefault("X-Frame-Options", "SAMEORIGIN")
|
||||
return response
|
||||
|
||||
@app.errorhandler(413)
|
||||
|
||||
@@ -57,6 +57,12 @@ def normalize_login_value(raw: str) -> str:
|
||||
return raw.strip().lower()
|
||||
|
||||
|
||||
def validate_password_strength(password: str) -> str | None:
|
||||
if len(password or "") < 10:
|
||||
return "Bitte ein etwas längeres Passwort wählen."
|
||||
return None
|
||||
|
||||
|
||||
def validate_identity_fields(database, username: str, email: str | None, current_user_id: int | None = None) -> str | None:
|
||||
if not username:
|
||||
return "Bitte einen Benutzernamen eintragen."
|
||||
@@ -140,6 +146,8 @@ def setup():
|
||||
error = "Bitte ein Passwort vergeben."
|
||||
elif error is None and password != password_repeat:
|
||||
error = "Die Passwörter stimmen nicht überein."
|
||||
elif error is None:
|
||||
error = validate_password_strength(password)
|
||||
|
||||
if error is None:
|
||||
database.execute(
|
||||
@@ -244,6 +252,8 @@ def change_password():
|
||||
error = "Bitte ein neues Passwort eintragen."
|
||||
elif new_password != new_password_repeat:
|
||||
error = "Die neuen Passwörter stimmen nicht überein."
|
||||
else:
|
||||
error = validate_password_strength(new_password)
|
||||
|
||||
if error is None:
|
||||
get_db().execute(
|
||||
@@ -289,6 +299,10 @@ def validate_admin_user_form(
|
||||
return "Bitte ein Passwort vergeben."
|
||||
if password and password != password_repeat:
|
||||
return "Die Passwörter stimmen nicht überein."
|
||||
if password:
|
||||
password_error = validate_password_strength(password)
|
||||
if password_error:
|
||||
return password_error
|
||||
if current_user_id == g.user["id"] and not is_active:
|
||||
return "Du kannst deinen eigenen Zugang hier nicht deaktivieren."
|
||||
return None
|
||||
|
||||
+28
-2
@@ -38,7 +38,8 @@ BUILDER_LABELS = {
|
||||
"protein": "Proteinquelle",
|
||||
"carb": "Kohlenhydratquelle",
|
||||
"veg": "Gemüse / Ballaststoffquelle",
|
||||
"nuts": "Nüsse / Samen",
|
||||
"nuts": "Nüsse",
|
||||
"seeds": "Saaten",
|
||||
"fruit": "Obst",
|
||||
"dairy": "Milchprodukt",
|
||||
"neutral": "Neutral / sonstiges",
|
||||
@@ -48,7 +49,8 @@ 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.",
|
||||
"nuts": "Passt eher zu Nüssen.",
|
||||
"seeds": "Passt eher zu Saaten.",
|
||||
"fruit": "Passt eher zu Obst.",
|
||||
"dairy": "Passt eher zu Joghurt, Milch, Käse oder ähnlichem.",
|
||||
"neutral": "Ohne feste Zuordnung, aber weiterhin gut nutzbar.",
|
||||
@@ -56,6 +58,30 @@ BUILDER_DESCRIPTIONS = {
|
||||
|
||||
BUILDER_OPTIONS = [(key, label) for key, label in BUILDER_LABELS.items()]
|
||||
|
||||
ENERGY_DENSITY_OPTIONS = [
|
||||
("low", "Eher leicht"),
|
||||
("neutral", "Neutral"),
|
||||
("high", "Eher gehaltvoll"),
|
||||
]
|
||||
|
||||
ENERGY_DENSITY_LABELS = {
|
||||
"low": "Eher leicht",
|
||||
"neutral": "Neutral",
|
||||
"high": "Eher gehaltvoll",
|
||||
}
|
||||
|
||||
SUGGESTION_STYLE_OPTIONS = [
|
||||
("balanced", "Eher ausgewogen"),
|
||||
("fitness", "Fitness"),
|
||||
("protein", "Proteinbetont"),
|
||||
]
|
||||
|
||||
SUGGESTION_STYLE_LABELS = {
|
||||
"balanced": "Eher ausgewogen",
|
||||
"fitness": "Fitness",
|
||||
"protein": "Proteinbetont",
|
||||
}
|
||||
|
||||
WEEKDAY_OPTIONS = [
|
||||
(0, "Montag"),
|
||||
(1, "Dienstag"),
|
||||
|
||||
+39
-1
@@ -10,7 +10,7 @@ from werkzeug.security import generate_password_hash
|
||||
|
||||
from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS
|
||||
|
||||
CURRENT_SCHEMA_VERSION = "0.6.0"
|
||||
CURRENT_SCHEMA_VERSION = "1.0.0"
|
||||
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
@@ -127,6 +127,7 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
||||
add_column_if_missing(database, "items", "owner_user_id INTEGER")
|
||||
add_column_if_missing(database, "items", "target_user_id INTEGER")
|
||||
add_column_if_missing(database, "items", "visibility TEXT NOT NULL DEFAULT 'shared'")
|
||||
add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
|
||||
|
||||
if table_exists(database, "shopping_entries"):
|
||||
add_column_if_missing(database, "shopping_entries", "household_id INTEGER")
|
||||
@@ -148,12 +149,17 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
||||
reminders_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
push_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
notification_channel TEXT NOT NULL DEFAULT 'in_app',
|
||||
suggestion_style TEXT NOT NULL DEFAULT 'balanced',
|
||||
energy_preference TEXT NOT NULL DEFAULT 'neutral',
|
||||
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,
|
||||
push_missing_breakfast INTEGER NOT NULL DEFAULT 0,
|
||||
push_missing_lunch INTEGER NOT NULL DEFAULT 0,
|
||||
push_missing_dinner INTEGER NOT NULL DEFAULT 0,
|
||||
suggest_home_for_today INTEGER NOT NULL DEFAULT 1,
|
||||
remind_small_snack INTEGER NOT NULL DEFAULT 0,
|
||||
remind_nuts INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -184,6 +190,19 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
||||
"""
|
||||
)
|
||||
|
||||
database.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS reminder_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
event_key TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, event_key),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
database.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS shopping_needs (
|
||||
@@ -216,6 +235,13 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
||||
add_column_if_missing(database, "plan_entries", "owner_user_id INTEGER")
|
||||
add_column_if_missing(database, "plan_entries", "visibility TEXT NOT NULL DEFAULT 'shared'")
|
||||
|
||||
if table_exists(database, "user_settings"):
|
||||
add_column_if_missing(database, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'")
|
||||
add_column_if_missing(database, "user_settings", "energy_preference TEXT NOT NULL DEFAULT 'neutral'")
|
||||
add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0")
|
||||
add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0")
|
||||
add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
|
||||
def ensure_default_household(database: sqlite3.Connection) -> int:
|
||||
household = database.execute(
|
||||
@@ -332,8 +358,14 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
||||
add_column_if_missing(database, table_name, "owner_user_id INTEGER")
|
||||
add_column_if_missing(database, table_name, "visibility TEXT NOT NULL DEFAULT 'shared'")
|
||||
add_column_if_missing(database, "items", "target_user_id INTEGER")
|
||||
add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
|
||||
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, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'")
|
||||
add_column_if_missing(database, "user_settings", "energy_preference TEXT NOT NULL DEFAULT 'neutral'")
|
||||
add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0")
|
||||
add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0")
|
||||
add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
if default_owner_id is not None:
|
||||
database.execute(
|
||||
@@ -378,6 +410,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
||||
SELECT id FROM users
|
||||
"""
|
||||
)
|
||||
database.execute("UPDATE items SET energy_density = 'neutral' WHERE energy_density IS NULL OR energy_density = ''")
|
||||
database.execute("UPDATE user_settings SET suggestion_style = 'balanced' WHERE suggestion_style IS NULL OR suggestion_style = ''")
|
||||
database.execute("UPDATE user_settings SET energy_preference = 'neutral' WHERE energy_preference IS NULL OR energy_preference = ''")
|
||||
database.execute("UPDATE user_settings SET push_missing_breakfast = 0 WHERE push_missing_breakfast IS NULL")
|
||||
database.execute("UPDATE user_settings SET push_missing_lunch = 0 WHERE push_missing_lunch IS NULL")
|
||||
database.execute("UPDATE user_settings SET push_missing_dinner = 0 WHERE push_missing_dinner IS NULL")
|
||||
|
||||
database.execute(
|
||||
"""
|
||||
|
||||
+179
-9
@@ -27,10 +27,14 @@ from .constants import (
|
||||
DEFAULT_CATEGORY_BUILDERS,
|
||||
DAY_TEMPLATE_NAME_SUGGESTIONS,
|
||||
DEFAULT_CATEGORIES,
|
||||
ENERGY_DENSITY_LABELS,
|
||||
ENERGY_DENSITY_OPTIONS,
|
||||
ITEM_KIND_LABELS,
|
||||
ITEM_KIND_SINGULAR_LABELS,
|
||||
ITEM_SET_NAME_SUGGESTIONS,
|
||||
NOTIFICATION_CHANNEL_OPTIONS,
|
||||
SUGGESTION_STYLE_LABELS,
|
||||
SUGGESTION_STYLE_OPTIONS,
|
||||
VISIBILITY_DESCRIPTIONS,
|
||||
VISIBILITY_LABELS,
|
||||
WEEKDAY_OPTIONS,
|
||||
@@ -190,6 +194,9 @@ def get_user_settings() -> dict:
|
||||
"show_planned_not_shopped",
|
||||
"remind_tomorrow_if_sparse",
|
||||
"remind_week_if_sparse",
|
||||
"push_missing_breakfast",
|
||||
"push_missing_lunch",
|
||||
"push_missing_dinner",
|
||||
"suggest_home_for_today",
|
||||
"remind_small_snack",
|
||||
"remind_nuts",
|
||||
@@ -200,6 +207,8 @@ def get_user_settings() -> dict:
|
||||
for field in boolean_fields:
|
||||
settings[field] = bool(settings.get(field))
|
||||
settings["notification_channel"] = settings.get("notification_channel") or "in_app"
|
||||
settings["suggestion_style"] = normalize_suggestion_style(settings.get("suggestion_style"), "balanced")
|
||||
settings["energy_preference"] = suggestion_style_energy_preference(settings["suggestion_style"])
|
||||
return settings
|
||||
|
||||
|
||||
@@ -220,6 +229,24 @@ def normalize_notification_channel(raw: str | None, default: str = "in_app") ->
|
||||
return raw if raw in allowed else default
|
||||
|
||||
|
||||
def normalize_suggestion_style(raw: str | None, default: str = "balanced") -> str:
|
||||
allowed = {value for value, _label in SUGGESTION_STYLE_OPTIONS}
|
||||
if raw == "easy" or raw == "snack":
|
||||
return "balanced"
|
||||
return raw if raw in allowed else default
|
||||
|
||||
|
||||
def normalize_energy_density(raw: str | None, default: str = "neutral") -> str:
|
||||
allowed = {value for value, _label in ENERGY_DENSITY_OPTIONS}
|
||||
return raw if raw in allowed else default
|
||||
|
||||
|
||||
def suggestion_style_energy_preference(style: str) -> str:
|
||||
if style == "fitness":
|
||||
return "low"
|
||||
return "neutral"
|
||||
|
||||
|
||||
def visible_clause(table_alias: str) -> str:
|
||||
return (
|
||||
f"{table_alias}.household_id = ? "
|
||||
@@ -284,6 +311,8 @@ def describe_record(entry: dict) -> dict:
|
||||
target_name = user_display_name(entry.get("target_display_name"), entry.get("target_username")) if entry.get("target_user_id") else None
|
||||
entry["owner_name"] = owner_name
|
||||
entry["target_name"] = target_name
|
||||
entry["energy_density"] = normalize_energy_density(entry.get("energy_density"), "neutral")
|
||||
entry["energy_density_label"] = ENERGY_DENSITY_LABELS.get(entry["energy_density"], ENERGY_DENSITY_LABELS["neutral"])
|
||||
entry["is_personal"] = entry.get("visibility") == "personal"
|
||||
entry["is_shared"] = entry.get("visibility") == "shared"
|
||||
entry["is_mine"] = entry.get("owner_user_id") == g.user["id"]
|
||||
@@ -501,6 +530,44 @@ def fetch_builder_keys_for_item_ids(item_ids: list[int]) -> dict[int, set[str]]:
|
||||
return builder_map
|
||||
|
||||
|
||||
def score_suggestion_components(component_items: list[dict], daypart_slug: str, settings: dict) -> int:
|
||||
builder_keys = {key for item in component_items for key in item.get("builder_keys", ["neutral"])}
|
||||
energy_values = [normalize_energy_density(item.get("energy_density"), "neutral") for item in component_items]
|
||||
score = 0
|
||||
|
||||
style = settings.get("suggestion_style", "balanced")
|
||||
if style == "fitness":
|
||||
score += 9 if "protein" in builder_keys else 0
|
||||
score += 4 if daypart_slug in {"lunch", "dinner"} and "veg" in builder_keys else 0
|
||||
score += 2 if "carb" in builder_keys else 0
|
||||
elif style == "protein":
|
||||
score += 8 if "protein" in builder_keys else 0
|
||||
score += 3 if daypart_slug in {"lunch", "dinner"} and "veg" in builder_keys else 0
|
||||
else:
|
||||
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}:
|
||||
score += 5 if "carb" in builder_keys else 0
|
||||
score += 4 if builder_keys & {"dairy", "fruit", "nuts", "seeds"} else 0
|
||||
else:
|
||||
score += 5 if "protein" in builder_keys else 0
|
||||
score += 4 if "carb" in builder_keys else 0
|
||||
score += 4 if "veg" in builder_keys else 0
|
||||
|
||||
energy_preference = settings.get("energy_preference", "neutral")
|
||||
if style == "fitness":
|
||||
score += energy_values.count("low") * 4
|
||||
score -= energy_values.count("high") * 2
|
||||
elif energy_preference == "high":
|
||||
score += energy_values.count("high") * 3
|
||||
score -= energy_values.count("low")
|
||||
elif energy_preference == "low":
|
||||
score += energy_values.count("low") * 3
|
||||
score -= energy_values.count("high")
|
||||
else:
|
||||
score += energy_values.count("neutral")
|
||||
|
||||
return score
|
||||
|
||||
|
||||
def fetch_items(
|
||||
*,
|
||||
kind: str | None = None,
|
||||
@@ -589,6 +656,7 @@ def extract_item_form_data(existing: dict | None = None) -> dict:
|
||||
{
|
||||
"name": request.form.get("name", "").strip(),
|
||||
"category": request.form.get("category", "").strip(),
|
||||
"energy_density": normalize_energy_density(request.form.get("energy_density"), form_data.get("energy_density", "neutral")),
|
||||
"note": request.form.get("note", "").strip(),
|
||||
"visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")),
|
||||
"target_user_id": normalize_target_user_id(request.form.get("target_user_id")),
|
||||
@@ -598,6 +666,7 @@ def extract_item_form_data(existing: dict | None = None) -> dict:
|
||||
"component_ids": [int(value) for value in request.form.getlist("component_ids") if value.isdigit()],
|
||||
"quick_food_name": request.form.get("quick_food_name", "").strip(),
|
||||
"quick_food_category": request.form.get("quick_food_category", "").strip(),
|
||||
"quick_food_energy_density": normalize_energy_density(request.form.get("quick_food_energy_density"), form_data.get("quick_food_energy_density", "neutral")),
|
||||
"quick_food_note": request.form.get("quick_food_note", "").strip(),
|
||||
}
|
||||
)
|
||||
@@ -608,9 +677,9 @@ def create_quick_food_from_form(form_data: dict) -> int:
|
||||
cursor = get_db().execute(
|
||||
"""
|
||||
INSERT INTO items (
|
||||
household_id, owner_user_id, target_user_id, visibility, kind, name, category, note, created_by, updated_by
|
||||
household_id, owner_user_id, target_user_id, visibility, kind, name, category, energy_density, note, created_by, updated_by
|
||||
)
|
||||
VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
current_household_id(),
|
||||
@@ -619,6 +688,7 @@ def create_quick_food_from_form(form_data: dict) -> int:
|
||||
form_data["visibility"],
|
||||
form_data["quick_food_name"],
|
||||
form_data["quick_food_category"],
|
||||
form_data["quick_food_energy_density"],
|
||||
form_data["quick_food_note"],
|
||||
g.user["id"],
|
||||
g.user["id"],
|
||||
@@ -1178,11 +1248,13 @@ def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, li
|
||||
target_patterns = [
|
||||
("carb", "dairy", "fruit"),
|
||||
("carb", "dairy", "nuts"),
|
||||
("carb", "dairy", "seeds"),
|
||||
("carb", "fruit", "dairy"),
|
||||
]
|
||||
reasons = {
|
||||
("carb", "dairy", "fruit"): "Passt gut zu Frühstück oder Snack",
|
||||
("carb", "dairy", "nuts"): "Lässt sich gut für einen Snack vormerken",
|
||||
("carb", "dairy", "seeds"): "Lässt sich gut für einen Snack vormerken",
|
||||
("carb", "fruit", "dairy"): "Zuhause gut kombinierbar",
|
||||
}
|
||||
else:
|
||||
@@ -1222,12 +1294,15 @@ def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, li
|
||||
|
||||
|
||||
def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) -> list[dict]:
|
||||
settings = get_user_settings()
|
||||
daypart_slug = (get_daypart_by_id(daypart_id)["slug"] if daypart_id and get_daypart_by_id(daypart_id) else "")
|
||||
home_foods = [
|
||||
item
|
||||
for item in fetch_items(kind="food", availability="home")
|
||||
if item_matches_daypart(item, daypart_id)
|
||||
]
|
||||
home_food_ids = {item["id"] for item in home_foods}
|
||||
home_food_map = {int(item["id"]): item for item in home_foods}
|
||||
|
||||
suggestions: list[dict] = []
|
||||
seen_signatures: set[tuple[int, ...]] = set()
|
||||
@@ -1238,26 +1313,37 @@ def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4)
|
||||
if signature in seen_signatures:
|
||||
continue
|
||||
seen_signatures.add(signature)
|
||||
component_items = [home_food_map[component_id] for component_id in meal["component_ids"] if component_id in home_food_map]
|
||||
suggestions.append(
|
||||
{
|
||||
"title": meal["name"],
|
||||
"reason": "Zuhause vorhanden",
|
||||
"component_ids": meal["component_ids"],
|
||||
"existing_item_id": meal["id"],
|
||||
"score": score_suggestion_components(component_items, daypart_slug=daypart_slug, settings=settings) + 40,
|
||||
}
|
||||
)
|
||||
|
||||
daypart_slug = (get_daypart_by_id(daypart_id)["slug"] if daypart_id and get_daypart_by_id(daypart_id) else "")
|
||||
for suggestion in build_dynamic_meal_suggestions(home_foods, daypart_slug, limit=limit * 2):
|
||||
signature = normalized_component_signature(suggestion["component_ids"])
|
||||
if signature in seen_signatures:
|
||||
continue
|
||||
seen_signatures.add(signature)
|
||||
component_items = [home_food_map[component_id] for component_id in suggestion["component_ids"] if component_id in home_food_map]
|
||||
suggestion["score"] = score_suggestion_components(component_items, daypart_slug=daypart_slug, settings=settings)
|
||||
suggestions.append(suggestion)
|
||||
|
||||
deduped: list[dict] = []
|
||||
seen = set()
|
||||
for suggestion in suggestions:
|
||||
ranked_suggestions = sorted(
|
||||
suggestions,
|
||||
key=lambda suggestion: (
|
||||
-int(suggestion.get("score", 0)),
|
||||
0 if suggestion.get("existing_item_id") else 1,
|
||||
suggestion["title"].lower(),
|
||||
),
|
||||
)
|
||||
for suggestion in ranked_suggestions:
|
||||
if suggestion["title"] in seen:
|
||||
continue
|
||||
seen.add(suggestion["title"])
|
||||
@@ -1287,6 +1373,10 @@ def build_balance_suggestion(daypart_id: int, item_ids: list[int]) -> dict | Non
|
||||
item for item in fetch_items(kind="food", availability="home", daypart_id=daypart_id)
|
||||
if first_missing in item.get("builder_keys", [])
|
||||
]
|
||||
home_matches = sorted(
|
||||
home_matches,
|
||||
key=lambda item: -score_suggestion_components([item], daypart["slug"], settings),
|
||||
)
|
||||
text_map = {
|
||||
"protein": "Dazu könnte noch eine Proteinquelle gut passen.",
|
||||
"carb": "Das lässt sich gut mit einer Kohlenhydratquelle ergänzen.",
|
||||
@@ -1362,7 +1452,7 @@ def build_dashboard_hints(today: date) -> list[str]:
|
||||
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", [])]
|
||||
nut_items = [item for item in fetch_items(kind="food", availability="home") if {"nuts", "seeds"} & set(item.get("builder_keys", []))]
|
||||
if nut_items:
|
||||
hints.append("Heute schon an Nüsse gedacht?")
|
||||
|
||||
@@ -1567,7 +1657,52 @@ def dedupe_items(items: list[dict], limit: int = 6) -> list[dict]:
|
||||
return result
|
||||
|
||||
|
||||
def build_day_planner_sections(selected_date: date, selected_item_id: int | None, selected_daypart_id: int | None):
|
||||
def build_selected_quick_action(
|
||||
*,
|
||||
daypart_id: int,
|
||||
selected_item_id: int | None,
|
||||
selected_meal_name: str,
|
||||
selected_component_ids: list[int],
|
||||
candidates: list[dict],
|
||||
) -> dict | None:
|
||||
if selected_item_id:
|
||||
selected_item = next((item for item in candidates if int(item["id"]) == int(selected_item_id)), None)
|
||||
if selected_item is None:
|
||||
try:
|
||||
selected_item = get_item(selected_item_id)
|
||||
except ValueError:
|
||||
selected_item = None
|
||||
if selected_item is not None:
|
||||
return {
|
||||
"type": "existing",
|
||||
"title": selected_item["name"],
|
||||
"subtitle": "Bereit zum Eintragen",
|
||||
"item_id": int(selected_item["id"]),
|
||||
"visibility": selected_item["visibility"],
|
||||
"daypart_id": daypart_id,
|
||||
}
|
||||
|
||||
if selected_meal_name and selected_component_ids:
|
||||
return {
|
||||
"type": "generated",
|
||||
"title": selected_meal_name,
|
||||
"subtitle": "Vorgeschlagen aus dem, was zuhause da ist",
|
||||
"component_ids": selected_component_ids,
|
||||
"visibility": "shared",
|
||||
"daypart_id": daypart_id,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def build_day_planner_sections(
|
||||
selected_date: date,
|
||||
selected_item_id: int | None,
|
||||
selected_daypart_id: int | None,
|
||||
selected_meal_name: str = "",
|
||||
selected_component_ids: list[int] | None = None,
|
||||
):
|
||||
selected_component_ids = selected_component_ids or []
|
||||
sections = []
|
||||
day_entries = fetch_day_plan_entries(selected_date)
|
||||
for daypart in get_dayparts():
|
||||
@@ -1596,6 +1731,13 @@ def build_day_planner_sections(selected_date: date, selected_item_id: int | None
|
||||
"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,
|
||||
"selected_quick_action": build_selected_quick_action(
|
||||
daypart_id=int(daypart["id"]),
|
||||
selected_item_id=selected_item_id if selected_daypart_id == daypart["id"] else None,
|
||||
selected_meal_name=selected_meal_name if selected_daypart_id == daypart["id"] else "",
|
||||
selected_component_ids=selected_component_ids if selected_daypart_id == daypart["id"] else [],
|
||||
candidates=candidates,
|
||||
),
|
||||
"is_open": selected_daypart_id == daypart["id"],
|
||||
"summary_items": [entry["item_name"] for entry in entries][:2],
|
||||
"default_visibility": "shared",
|
||||
@@ -2082,6 +2224,7 @@ def render_item_form(kind: str, *, item: dict | None, form_data: dict):
|
||||
form_data.get("category") or form_data.get("quick_food_category")
|
||||
),
|
||||
form_data=form_data,
|
||||
energy_density_options=ENERGY_DENSITY_OPTIONS,
|
||||
visibility_options=VISIBILITY_FORM_OPTIONS,
|
||||
target_user_options=get_target_user_options(),
|
||||
)
|
||||
@@ -2588,18 +2731,24 @@ def settings_view():
|
||||
flash("Die Einkaufsrhythmus-Einstellungen wurden gespeichert.", "success")
|
||||
elif form_name == "reminders":
|
||||
ensure_user_settings_row()
|
||||
suggestion_style = normalize_suggestion_style(request.form.get("suggestion_style"), "balanced")
|
||||
get_db().execute(
|
||||
"""
|
||||
UPDATE user_settings
|
||||
SET reminders_enabled = ?,
|
||||
push_enabled = ?,
|
||||
notification_channel = ?,
|
||||
suggestion_style = ?,
|
||||
energy_preference = ?,
|
||||
remind_before_shopping = ?,
|
||||
remind_on_shopping_day = ?,
|
||||
show_missing_for_upcoming_week = ?,
|
||||
show_planned_not_shopped = ?,
|
||||
remind_tomorrow_if_sparse = ?,
|
||||
remind_week_if_sparse = ?,
|
||||
push_missing_breakfast = ?,
|
||||
push_missing_lunch = ?,
|
||||
push_missing_dinner = ?,
|
||||
suggest_home_for_today = ?,
|
||||
remind_small_snack = ?,
|
||||
remind_nuts = ?,
|
||||
@@ -2613,12 +2762,17 @@ def settings_view():
|
||||
parse_checkbox("reminders_enabled", True),
|
||||
parse_checkbox("push_enabled", False),
|
||||
normalize_notification_channel(request.form.get("notification_channel"), "in_app"),
|
||||
suggestion_style,
|
||||
suggestion_style_energy_preference(suggestion_style),
|
||||
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("push_missing_breakfast", False),
|
||||
parse_checkbox("push_missing_lunch", False),
|
||||
parse_checkbox("push_missing_dinner", False),
|
||||
parse_checkbox("suggest_home_for_today", True),
|
||||
parse_checkbox("remind_small_snack", False),
|
||||
parse_checkbox("remind_nuts", False),
|
||||
@@ -2831,6 +2985,7 @@ def item_create(kind: str):
|
||||
form_data = {
|
||||
"name": request.args.get("name", "").strip(),
|
||||
"category": "",
|
||||
"energy_density": "neutral",
|
||||
"note": "",
|
||||
"visibility": "shared",
|
||||
"target_user_id": None,
|
||||
@@ -2840,6 +2995,7 @@ def item_create(kind: str):
|
||||
"component_ids": [int(value) for value in request.args.getlist("component_ids") if value.isdigit()],
|
||||
"quick_food_name": "",
|
||||
"quick_food_category": "",
|
||||
"quick_food_energy_density": "neutral",
|
||||
"quick_food_note": "",
|
||||
}
|
||||
|
||||
@@ -2878,9 +3034,9 @@ def item_create(kind: str):
|
||||
cursor = get_db().execute(
|
||||
"""
|
||||
INSERT INTO items (
|
||||
household_id, owner_user_id, target_user_id, visibility, kind, name, category, note, photo_filename, created_by, updated_by
|
||||
household_id, owner_user_id, target_user_id, visibility, kind, name, category, energy_density, note, photo_filename, created_by, updated_by
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
current_household_id(),
|
||||
@@ -2890,6 +3046,7 @@ def item_create(kind: str):
|
||||
kind,
|
||||
form_data["name"],
|
||||
form_data["category"],
|
||||
form_data["energy_density"],
|
||||
form_data["note"],
|
||||
photo_filename,
|
||||
g.user["id"],
|
||||
@@ -2921,6 +3078,7 @@ def item_edit(item_id: int):
|
||||
form_data = {
|
||||
"name": item["name"],
|
||||
"category": item["category"] or "",
|
||||
"energy_density": item.get("energy_density") or "neutral",
|
||||
"note": item["note"] or "",
|
||||
"visibility": item["visibility"],
|
||||
"target_user_id": item["target_user_id"],
|
||||
@@ -2930,6 +3088,7 @@ def item_edit(item_id: int):
|
||||
"component_ids": get_meal_component_ids(item_id) if item["kind"] == "meal" else [],
|
||||
"quick_food_name": "",
|
||||
"quick_food_category": "",
|
||||
"quick_food_energy_density": "neutral",
|
||||
"quick_food_note": "",
|
||||
}
|
||||
|
||||
@@ -2970,6 +3129,7 @@ def item_edit(item_id: int):
|
||||
UPDATE items
|
||||
SET name = ?,
|
||||
category = ?,
|
||||
energy_density = ?,
|
||||
note = ?,
|
||||
visibility = ?,
|
||||
target_user_id = ?,
|
||||
@@ -2981,6 +3141,7 @@ def item_edit(item_id: int):
|
||||
(
|
||||
form_data["name"],
|
||||
form_data["category"],
|
||||
form_data["energy_density"],
|
||||
form_data["note"],
|
||||
form_data["visibility"],
|
||||
form_data["target_user_id"],
|
||||
@@ -3303,14 +3464,23 @@ def planner_day():
|
||||
|
||||
selected_item_raw = request.args.get("item_id", "").strip()
|
||||
selected_daypart_raw = request.args.get("daypart_id", "").strip()
|
||||
selected_meal_name = request.args.get("meal_name", "").strip()
|
||||
selected_components_raw = request.args.get("component_ids", "").strip()
|
||||
selected_item_id = int(selected_item_raw) if selected_item_raw.isdigit() else None
|
||||
selected_daypart_id = int(selected_daypart_raw) if selected_daypart_raw.isdigit() else None
|
||||
selected_component_ids = [int(value) for value in selected_components_raw.split(",") if value.isdigit()]
|
||||
return render_template(
|
||||
"planner/day.html",
|
||||
selected_date=selected_date,
|
||||
previous_day=selected_date - timedelta(days=1),
|
||||
next_day=selected_date + timedelta(days=1),
|
||||
sections=build_day_planner_sections(selected_date, selected_item_id, selected_daypart_id),
|
||||
sections=build_day_planner_sections(
|
||||
selected_date,
|
||||
selected_item_id,
|
||||
selected_daypart_id,
|
||||
selected_meal_name=selected_meal_name,
|
||||
selected_component_ids=selected_component_ids,
|
||||
),
|
||||
today=date.today(),
|
||||
visibility_options=VISIBILITY_FORM_OPTIONS,
|
||||
day_templates=fetch_day_templates()[:6],
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import date, datetime
|
||||
from urllib.parse import quote
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from flask import current_app, g
|
||||
|
||||
from .db import get_db
|
||||
from .main import build_home_recipe_suggestions, get_user_settings
|
||||
from .push import send_push_message
|
||||
|
||||
|
||||
MEAL_PUSH_RULES = [
|
||||
{"slug": "breakfast", "setting": "push_missing_breakfast", "hour": 8, "minute": 0, "label": "Frühstück"},
|
||||
{"slug": "lunch", "setting": "push_missing_lunch", "hour": 12, "minute": 0, "label": "Mittagessen"},
|
||||
{"slug": "dinner", "setting": "push_missing_dinner", "hour": 18, "minute": 0, "label": "Abendessen"},
|
||||
]
|
||||
|
||||
|
||||
def current_local_time() -> datetime:
|
||||
timezone_name = current_app.config.get("TIMEZONE", "Europe/Berlin")
|
||||
try:
|
||||
timezone = ZoneInfo(timezone_name)
|
||||
except Exception:
|
||||
timezone = ZoneInfo("Europe/Berlin")
|
||||
return datetime.now(timezone)
|
||||
|
||||
|
||||
def push_delivery_channel_enabled(settings: dict) -> bool:
|
||||
return (
|
||||
settings.get("reminders_enabled")
|
||||
and settings.get("push_enabled")
|
||||
and settings.get("notification_channel") in {"push", "both"}
|
||||
)
|
||||
|
||||
|
||||
def fetch_push_ready_users() -> list:
|
||||
return get_db().execute(
|
||||
"""
|
||||
SELECT users.*
|
||||
FROM users
|
||||
JOIN user_settings ON user_settings.user_id = users.id
|
||||
WHERE users.is_active = 1
|
||||
AND user_settings.reminders_enabled = 1
|
||||
AND user_settings.push_enabled = 1
|
||||
AND user_settings.notification_channel IN ('push', 'both')
|
||||
ORDER BY users.id
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
|
||||
def fetch_daypart_map() -> dict[str, dict]:
|
||||
return {
|
||||
row["slug"]: {"id": int(row["id"]), "name": row["name"]}
|
||||
for row in get_db().execute("SELECT id, slug, name FROM dayparts").fetchall()
|
||||
}
|
||||
|
||||
|
||||
def plan_exists_for_daypart(user, *, planned_date: date, daypart_id: int) -> bool:
|
||||
row = get_db().execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS count
|
||||
FROM plan_entries
|
||||
WHERE household_id = ?
|
||||
AND plan_date = ?
|
||||
AND daypart_id = ?
|
||||
AND (visibility = 'shared' OR owner_user_id = ?)
|
||||
""",
|
||||
(int(user["household_id"]), planned_date.isoformat(), daypart_id, int(user["id"])),
|
||||
).fetchone()
|
||||
return bool(int(row["count"] or 0))
|
||||
|
||||
|
||||
def reminder_event_exists(user_id: int, event_key: str) -> bool:
|
||||
row = get_db().execute(
|
||||
"SELECT 1 FROM reminder_events WHERE user_id = ? AND event_key = ? LIMIT 1",
|
||||
(user_id, event_key),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def mark_reminder_event(user_id: int, event_key: str) -> None:
|
||||
get_db().execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO reminder_events (user_id, event_key)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(user_id, event_key),
|
||||
)
|
||||
get_db().commit()
|
||||
|
||||
|
||||
def due_for_rule(now: datetime, *, hour: int, minute: int) -> bool:
|
||||
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
delta = (now - target).total_seconds()
|
||||
return 0 <= delta < 180
|
||||
|
||||
|
||||
def build_push_target_url(*, planned_date: date, daypart_id: int, suggestion: dict | None) -> str:
|
||||
base = f"/planner/day?date={planned_date.isoformat()}&daypart_id={daypart_id}"
|
||||
if not suggestion:
|
||||
return f"{base}#daypart-{daypart_id}"
|
||||
if suggestion.get("existing_item_id"):
|
||||
return f"{base}&item_id={int(suggestion['existing_item_id'])}#daypart-{daypart_id}"
|
||||
component_ids = ",".join(str(component_id) for component_id in suggestion.get("component_ids", []))
|
||||
if suggestion.get("title") and component_ids:
|
||||
meal_name = quote(str(suggestion["title"]))
|
||||
return f"{base}&meal_name={meal_name}&component_ids={component_ids}#daypart-{daypart_id}"
|
||||
return f"{base}#daypart-{daypart_id}"
|
||||
|
||||
|
||||
def build_push_message(label: str, suggestion: dict | None) -> tuple[str, str]:
|
||||
title = f"Nouri · {label}"
|
||||
if suggestion and suggestion.get("title"):
|
||||
return title, f"Für {label.lower()} ist noch nichts geplant. Möglich wäre gerade: {suggestion['title']}."
|
||||
return title, f"Für {label.lower()} ist noch nichts geplant."
|
||||
|
||||
|
||||
def best_suggestion_for_user(user, daypart_id: int) -> dict | None:
|
||||
previous_user = getattr(g, "user", None)
|
||||
g.user = user
|
||||
try:
|
||||
suggestions = build_home_recipe_suggestions(daypart_id, limit=1)
|
||||
finally:
|
||||
g.user = previous_user
|
||||
return suggestions[0] if suggestions else None
|
||||
|
||||
|
||||
def send_due_meal_pushes(now: datetime | None = None) -> int:
|
||||
now = now or current_local_time()
|
||||
planned_date = now.date()
|
||||
sent_count = 0
|
||||
dayparts = fetch_daypart_map()
|
||||
|
||||
for user in fetch_push_ready_users():
|
||||
g.user = user
|
||||
settings = get_user_settings()
|
||||
if not push_delivery_channel_enabled(settings):
|
||||
continue
|
||||
|
||||
subscriptions = get_db().execute(
|
||||
"""
|
||||
SELECT endpoint, p256dh, auth
|
||||
FROM push_subscriptions
|
||||
WHERE user_id = ? AND is_active = 1
|
||||
ORDER BY updated_at DESC
|
||||
""",
|
||||
(int(user["id"]),),
|
||||
).fetchall()
|
||||
if not subscriptions:
|
||||
continue
|
||||
|
||||
for rule in MEAL_PUSH_RULES:
|
||||
if not settings.get(rule["setting"]):
|
||||
continue
|
||||
if not due_for_rule(now, hour=rule["hour"], minute=rule["minute"]):
|
||||
continue
|
||||
|
||||
daypart = dayparts.get(rule["slug"])
|
||||
if not daypart:
|
||||
continue
|
||||
if plan_exists_for_daypart(user, planned_date=planned_date, daypart_id=daypart["id"]):
|
||||
continue
|
||||
|
||||
event_key = f"meal-push:{planned_date.isoformat()}:{rule['slug']}"
|
||||
if reminder_event_exists(int(user["id"]), event_key):
|
||||
continue
|
||||
|
||||
suggestion = best_suggestion_for_user(user, daypart["id"])
|
||||
title, body = build_push_message(rule["label"], suggestion)
|
||||
url = build_push_target_url(planned_date=planned_date, daypart_id=daypart["id"], suggestion=suggestion)
|
||||
|
||||
delivered = False
|
||||
for subscription in subscriptions:
|
||||
ok, _error = send_push_message(
|
||||
{
|
||||
"endpoint": subscription["endpoint"],
|
||||
"keys": {"p256dh": subscription["p256dh"], "auth": subscription["auth"]},
|
||||
},
|
||||
title=title,
|
||||
body=body,
|
||||
url=url,
|
||||
)
|
||||
delivered = delivered or ok
|
||||
|
||||
if delivered:
|
||||
mark_reminder_event(int(user["id"]), event_key)
|
||||
sent_count += 1
|
||||
|
||||
return sent_count
|
||||
|
||||
|
||||
def reminder_worker_loop(sleep_seconds: int = 60) -> None:
|
||||
while True:
|
||||
try:
|
||||
send_due_meal_pushes()
|
||||
except Exception as exc: # pragma: no cover - background worker fallback
|
||||
current_app.logger.warning("Reminder worker skipped one cycle: %s", exc)
|
||||
time.sleep(sleep_seconds)
|
||||
@@ -50,12 +50,17 @@ CREATE TABLE IF NOT EXISTS user_settings (
|
||||
reminders_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
push_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
notification_channel TEXT NOT NULL DEFAULT 'in_app',
|
||||
suggestion_style TEXT NOT NULL DEFAULT 'balanced',
|
||||
energy_preference TEXT NOT NULL DEFAULT 'neutral',
|
||||
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,
|
||||
push_missing_breakfast INTEGER NOT NULL DEFAULT 0,
|
||||
push_missing_lunch INTEGER NOT NULL DEFAULT 0,
|
||||
push_missing_dinner INTEGER NOT NULL DEFAULT 0,
|
||||
suggest_home_for_today INTEGER NOT NULL DEFAULT 1,
|
||||
remind_small_snack INTEGER NOT NULL DEFAULT 0,
|
||||
remind_nuts INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -80,6 +85,15 @@ CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reminder_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
event_key TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, event_key),
|
||||
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,
|
||||
@@ -96,6 +110,7 @@ CREATE TABLE IF NOT EXISTS items (
|
||||
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')),
|
||||
name TEXT NOT NULL,
|
||||
category TEXT,
|
||||
energy_density TEXT NOT NULL DEFAULT 'neutral',
|
||||
note TEXT,
|
||||
photo_filename TEXT,
|
||||
availability_state TEXT NOT NULL DEFAULT 'idea' CHECK (availability_state IN ('idea', 'home', 'archived')),
|
||||
|
||||
@@ -260,32 +260,54 @@ h3,
|
||||
|
||||
@media (min-width: 1081px) {
|
||||
.site-header {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
column-gap: 1.5rem;
|
||||
row-gap: 0.9rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.desktop-header-main {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 1.4rem;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.desktop-header-sub {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.desktop-nav {
|
||||
width: 100%;
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.desktop-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.desktop-actions {
|
||||
width: 100%;
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.brand {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / span 2;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.nav-link-inner,
|
||||
.desktop-actions > * {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.user-chip,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = "nouri-v0-6-0";
|
||||
const CACHE_NAME = "nouri-v1-0-0";
|
||||
const OFFLINE_URL = "/static/pwa/offline.html";
|
||||
const STATIC_ASSETS = [
|
||||
"/static/css/styles.css",
|
||||
|
||||
@@ -55,6 +55,16 @@
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Energiedichte
|
||||
<select name="energy_density">
|
||||
{% for value, label in energy_density_options %}
|
||||
<option value="{{ value }}" {% if form_data.energy_density == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="helper-text">Hilft Nouri dabei, passende Ideen etwas ruhiger und persönlicher zu sortieren.</small>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Notiz
|
||||
<textarea name="note" rows="4" placeholder="Optional, wenn eine kleine Erinnerung hilfreich ist.">{{ form_data.note }}</textarea>
|
||||
@@ -149,6 +159,14 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Energiedichte
|
||||
<select name="quick_food_energy_density">
|
||||
{% for value, label in energy_density_options %}
|
||||
<option value="{{ value }}" {% if form_data.quick_food_energy_density == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="wide">
|
||||
Notiz
|
||||
<input type="text" name="quick_food_note" value="{{ form_data.quick_food_note }}" placeholder="Optional">
|
||||
|
||||
@@ -72,6 +72,35 @@
|
||||
</summary>
|
||||
|
||||
<div class="day-tile-body">
|
||||
{% if section.selected_quick_action %}
|
||||
<div class="suggestion-card">
|
||||
<strong>{{ section.selected_quick_action.title }}</strong>
|
||||
<p class="muted">{{ section.selected_quick_action.subtitle }}</p>
|
||||
{% if section.selected_quick_action.type == 'existing' %}
|
||||
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}">
|
||||
{{ 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="{{ section.selected_quick_action.item_id }}">
|
||||
<input type="hidden" name="visibility" value="{{ section.selected_quick_action.visibility }}">
|
||||
<button class="secondary" type="submit">Jetzt nur noch speichern</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<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="{{ section.selected_quick_action.title }}">
|
||||
<input type="hidden" name="visibility" value="{{ section.selected_quick_action.visibility }}">
|
||||
{% for component_id in section.selected_quick_action.component_ids %}
|
||||
<input type="hidden" name="component_ids" value="{{ component_id }}">
|
||||
{% endfor %}
|
||||
<button class="secondary" type="submit">Jetzt nur noch speichern</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if section.balance_suggestion %}
|
||||
<div class="suggestion-card">
|
||||
<strong>{{ section.balance_suggestion.text }}</strong>
|
||||
@@ -143,7 +172,7 @@
|
||||
<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 }}">
|
||||
<input type="text" placeholder="Lebensmittel oder Mahlzeiten suchen" data-filter-input data-filter-target="#planner-list-{{ section.daypart.id }}" data-filter-limit="3">
|
||||
</label>
|
||||
<div class="compact-picker-list" id="planner-list-{{ section.daypart.id }}">
|
||||
{% for item in section.food_candidates %}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<div class="pwa-card">
|
||||
<strong>Push-Mitteilungen</strong>
|
||||
{% if push_ready %}
|
||||
<p class="muted">Push ist vorbereitet. Wenn du möchtest, kannst du es auf diesem Gerät freigeben und mit einer Test-Mitteilung prüfen.</p>
|
||||
<p class="muted">Push ist vorbereitet. Wenn du möchtest, kannst du es auf diesem Gerät freigeben, testen und später für Frühstück, Mittagessen oder Abendessen nutzen.</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>
|
||||
@@ -98,6 +98,17 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Vorschläge eher
|
||||
<select name="suggestion_style">
|
||||
{% for value, label in suggestion_style_options %}
|
||||
<option value="{{ value }}" {% if user_settings.suggestion_style == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="helper-text">
|
||||
Ausgewogen bleibt offen. Fitness denkt stärker in proteinbetont und eher leicht. Proteinbetont priorisiert Eiweiß, ohne extra leicht zu werden.
|
||||
</small>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
@@ -112,8 +123,12 @@
|
||||
<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="push_missing_breakfast" value="1" {% if user_settings.push_missing_breakfast %}checked{% endif %}><span>Um 8:00 erinnern, wenn noch kein Frühstück geplant ist</span></label>
|
||||
<label class="inline-check"><input type="checkbox" name="push_missing_lunch" value="1" {% if user_settings.push_missing_lunch %}checked{% endif %}><span>Um 12:00 erinnern, wenn noch kein Mittagessen geplant ist</span></label>
|
||||
<label class="inline-check"><input type="checkbox" name="push_missing_dinner" value="1" {% if user_settings.push_missing_dinner %}checked{% endif %}><span>Um 18:00 erinnern, wenn noch kein Abendessen geplant 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>
|
||||
<small class="helper-text">Für Push nimmt Nouri zuerst vorhandene Mahlzeitenideen. Wenn nichts passt, kommt eine Kombination aus dem, was zuhause da ist.</small>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from nouri import create_app
|
||||
from nouri.reminders import reminder_worker_loop, send_due_meal_pushes
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Run Nouri meal reminder pushes.")
|
||||
parser.add_argument("--once", action="store_true", help="Run one reminder cycle and exit.")
|
||||
parser.add_argument("--sleep", type=int, default=60, help="Seconds between reminder cycles.")
|
||||
args = parser.parse_args()
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
if args.once:
|
||||
send_due_meal_pushes()
|
||||
return
|
||||
reminder_worker_loop(sleep_seconds=max(15, args.sleep))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -13,4 +13,11 @@ if [ -d /app/bootstrap-data/uploads ] && [ -z "$(ls -A /app/data/uploads 2>/dev/
|
||||
cp -a /app/bootstrap-data/uploads/. /app/data/uploads/
|
||||
fi
|
||||
|
||||
if [ "${NOURI_RUN_REMINDER_WORKER:-1}" = "1" ]; then
|
||||
(
|
||||
sleep "${NOURI_REMINDER_START_DELAY:-25}"
|
||||
python /app/code/scripts/reminder_worker.py --sleep 60
|
||||
) &
|
||||
fi
|
||||
|
||||
exec gunicorn --bind 0.0.0.0:8000 --workers 2 --threads 4 wsgi:app
|
||||
Reference in New Issue
Block a user