release nouri 1.0.0
This commit is contained in:
@@ -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.6.0",
|
"version": "1.0.0",
|
||||||
"upstreamVersion": "0.6.0",
|
"upstreamVersion": "1.0.0",
|
||||||
"healthCheckPath": "/",
|
"healthCheckPath": "/",
|
||||||
"httpPort": 8000,
|
"httpPort": 8000,
|
||||||
"manifestVersion": 2,
|
"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.
|
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
|
- Lebensmittel und Mahlzeitenideen anlegen
|
||||||
- Fotos lokal hochladen
|
- 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
|
- 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
|
|
||||||
- Einkaufsrhythmus mit geplantem Einkaufstag und später aktivierten Bedarfen
|
- Einkaufsrhythmus mit geplantem Einkaufstag und später aktivierten Bedarfen
|
||||||
- ausgewogene Ergänzungsvorschläge auf Basis ruhiger Bausteine
|
- PWA-Grundlage mit Web App Manifest, Service Worker und optionalem Web Push
|
||||||
- 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
|
||||||
|
|
||||||
@@ -47,28 +43,104 @@ Die App legt Daten standardmäßig unter `./data` ab.
|
|||||||
Wichtige Umgebungsvariablen:
|
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, lokal standardmäßig `./data`, auf Cloudron `/app/data`
|
||||||
- `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5`
|
- `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_PUBLIC_KEY`: öffentlicher VAPID-Schlüssel für Web Push
|
||||||
- `NOURI_VAPID_PRIVATE_KEY`: privater 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`
|
- `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
|
Wichtig für die Trennung zwischen lokal und Produktion:
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
- lokal nutzt Nouri ohne gesetzte Variable standardmäßig `./data`
|
- lokal nutzt Nouri ohne gesetzte Variable standardmäßig `./data`
|
||||||
- auf Cloudron nutzt Nouri `/app/data`
|
- auf Cloudron nutzt Nouri `/app/data`
|
||||||
- `data/` ist in `.gitignore` und `.dockerignore` ausgeschlossen und wird weder eingecheckt noch ins Image kopiert
|
- `data/` ist nicht für Git oder das Paket gedacht
|
||||||
- `/app/data` ist auf Cloudron persistent und bleibt bei App-Updates erhalten
|
- 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
|
## 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,
|
BUILDER_OPTIONS,
|
||||||
DAYPARTS,
|
DAYPARTS,
|
||||||
DEFAULT_CATEGORIES,
|
DEFAULT_CATEGORIES,
|
||||||
|
ENERGY_DENSITY_LABELS,
|
||||||
|
ENERGY_DENSITY_OPTIONS,
|
||||||
ITEM_KIND_LABELS,
|
ITEM_KIND_LABELS,
|
||||||
ITEM_KIND_SINGULAR_LABELS,
|
ITEM_KIND_SINGULAR_LABELS,
|
||||||
NOTIFICATION_CHANNEL_OPTIONS,
|
NOTIFICATION_CHANNEL_OPTIONS,
|
||||||
ROLE_LABELS,
|
ROLE_LABELS,
|
||||||
|
SUGGESTION_STYLE_LABELS,
|
||||||
|
SUGGESTION_STYLE_OPTIONS,
|
||||||
VISIBILITY_DESCRIPTIONS,
|
VISIBILITY_DESCRIPTIONS,
|
||||||
VISIBILITY_LABELS,
|
VISIBILITY_LABELS,
|
||||||
WEEKDAY_OPTIONS,
|
WEEKDAY_OPTIONS,
|
||||||
@@ -69,7 +73,9 @@ 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.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_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""),
|
||||||
VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""),
|
VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""),
|
||||||
VAPID_SUBJECT=os.environ.get("NOURI_VAPID_SUBJECT", "mailto:mail@hnz.io"),
|
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_descriptions": BUILDER_DESCRIPTIONS,
|
||||||
"builder_options": BUILDER_OPTIONS,
|
"builder_options": BUILDER_OPTIONS,
|
||||||
"daypart_suggestions": DAYPARTS,
|
"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_labels": VISIBILITY_LABELS,
|
||||||
"visibility_descriptions": VISIBILITY_DESCRIPTIONS,
|
"visibility_descriptions": VISIBILITY_DESCRIPTIONS,
|
||||||
"role_labels": ROLE_LABELS,
|
"role_labels": ROLE_LABELS,
|
||||||
@@ -159,6 +169,9 @@ def create_app() -> Flask:
|
|||||||
content_type = response.headers.get("Content-Type", "")
|
content_type = response.headers.get("Content-Type", "")
|
||||||
if content_type.startswith("text/html"):
|
if content_type.startswith("text/html"):
|
||||||
response.headers.setdefault("Cache-Control", "no-store, max-age=0")
|
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
|
return response
|
||||||
|
|
||||||
@app.errorhandler(413)
|
@app.errorhandler(413)
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ def normalize_login_value(raw: str) -> str:
|
|||||||
return raw.strip().lower()
|
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:
|
def validate_identity_fields(database, username: str, email: str | None, current_user_id: int | None = None) -> str | None:
|
||||||
if not username:
|
if not username:
|
||||||
return "Bitte einen Benutzernamen eintragen."
|
return "Bitte einen Benutzernamen eintragen."
|
||||||
@@ -140,6 +146,8 @@ def setup():
|
|||||||
error = "Bitte ein Passwort vergeben."
|
error = "Bitte ein Passwort vergeben."
|
||||||
elif error is None and password != password_repeat:
|
elif error is None and password != password_repeat:
|
||||||
error = "Die Passwörter stimmen nicht überein."
|
error = "Die Passwörter stimmen nicht überein."
|
||||||
|
elif error is None:
|
||||||
|
error = validate_password_strength(password)
|
||||||
|
|
||||||
if error is None:
|
if error is None:
|
||||||
database.execute(
|
database.execute(
|
||||||
@@ -244,6 +252,8 @@ def change_password():
|
|||||||
error = "Bitte ein neues Passwort eintragen."
|
error = "Bitte ein neues Passwort eintragen."
|
||||||
elif new_password != new_password_repeat:
|
elif new_password != new_password_repeat:
|
||||||
error = "Die neuen Passwörter stimmen nicht überein."
|
error = "Die neuen Passwörter stimmen nicht überein."
|
||||||
|
else:
|
||||||
|
error = validate_password_strength(new_password)
|
||||||
|
|
||||||
if error is None:
|
if error is None:
|
||||||
get_db().execute(
|
get_db().execute(
|
||||||
@@ -289,6 +299,10 @@ def validate_admin_user_form(
|
|||||||
return "Bitte ein Passwort vergeben."
|
return "Bitte ein Passwort vergeben."
|
||||||
if password and password != password_repeat:
|
if password and password != password_repeat:
|
||||||
return "Die Passwörter stimmen nicht überein."
|
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:
|
if current_user_id == g.user["id"] and not is_active:
|
||||||
return "Du kannst deinen eigenen Zugang hier nicht deaktivieren."
|
return "Du kannst deinen eigenen Zugang hier nicht deaktivieren."
|
||||||
return None
|
return None
|
||||||
|
|||||||
+28
-2
@@ -38,7 +38,8 @@ BUILDER_LABELS = {
|
|||||||
"protein": "Proteinquelle",
|
"protein": "Proteinquelle",
|
||||||
"carb": "Kohlenhydratquelle",
|
"carb": "Kohlenhydratquelle",
|
||||||
"veg": "Gemüse / Ballaststoffquelle",
|
"veg": "Gemüse / Ballaststoffquelle",
|
||||||
"nuts": "Nüsse / Samen",
|
"nuts": "Nüsse",
|
||||||
|
"seeds": "Saaten",
|
||||||
"fruit": "Obst",
|
"fruit": "Obst",
|
||||||
"dairy": "Milchprodukt",
|
"dairy": "Milchprodukt",
|
||||||
"neutral": "Neutral / sonstiges",
|
"neutral": "Neutral / sonstiges",
|
||||||
@@ -48,7 +49,8 @@ BUILDER_DESCRIPTIONS = {
|
|||||||
"protein": "Passt eher zu sättigenden Eiweißquellen.",
|
"protein": "Passt eher zu sättigenden Eiweißquellen.",
|
||||||
"carb": "Passt eher zu Brot, Getreide, Reis, Kartoffeln oder ähnlichem.",
|
"carb": "Passt eher zu Brot, Getreide, Reis, Kartoffeln oder ähnlichem.",
|
||||||
"veg": "Passt eher zu Gemüse oder ballaststoffreichen Begleitern.",
|
"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.",
|
"fruit": "Passt eher zu Obst.",
|
||||||
"dairy": "Passt eher zu Joghurt, Milch, Käse oder ähnlichem.",
|
"dairy": "Passt eher zu Joghurt, Milch, Käse oder ähnlichem.",
|
||||||
"neutral": "Ohne feste Zuordnung, aber weiterhin gut nutzbar.",
|
"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()]
|
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 = [
|
WEEKDAY_OPTIONS = [
|
||||||
(0, "Montag"),
|
(0, "Montag"),
|
||||||
(1, "Dienstag"),
|
(1, "Dienstag"),
|
||||||
|
|||||||
+39
-1
@@ -10,7 +10,7 @@ from werkzeug.security import generate_password_hash
|
|||||||
|
|
||||||
from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS
|
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:
|
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", "owner_user_id INTEGER")
|
||||||
add_column_if_missing(database, "items", "target_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", "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"):
|
if table_exists(database, "shopping_entries"):
|
||||||
add_column_if_missing(database, "shopping_entries", "household_id INTEGER")
|
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,
|
reminders_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
push_enabled INTEGER NOT NULL DEFAULT 0,
|
push_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
notification_channel TEXT NOT NULL DEFAULT 'in_app',
|
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_before_shopping INTEGER NOT NULL DEFAULT 1,
|
||||||
remind_on_shopping_day 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_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1,
|
||||||
show_planned_not_shopped INTEGER NOT NULL DEFAULT 1,
|
show_planned_not_shopped INTEGER NOT NULL DEFAULT 1,
|
||||||
remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1,
|
remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1,
|
||||||
remind_week_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,
|
suggest_home_for_today INTEGER NOT NULL DEFAULT 1,
|
||||||
remind_small_snack INTEGER NOT NULL DEFAULT 0,
|
remind_small_snack INTEGER NOT NULL DEFAULT 0,
|
||||||
remind_nuts 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(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS shopping_needs (
|
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", "owner_user_id INTEGER")
|
||||||
add_column_if_missing(database, "plan_entries", "visibility TEXT NOT NULL DEFAULT 'shared'")
|
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:
|
def ensure_default_household(database: sqlite3.Connection) -> int:
|
||||||
household = database.execute(
|
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, "owner_user_id INTEGER")
|
||||||
add_column_if_missing(database, table_name, "visibility TEXT NOT NULL DEFAULT 'shared'")
|
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", "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_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")
|
||||||
|
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:
|
if default_owner_id is not None:
|
||||||
database.execute(
|
database.execute(
|
||||||
@@ -378,6 +410,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
|||||||
SELECT id FROM users
|
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(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
|
|||||||
+179
-9
@@ -27,10 +27,14 @@ from .constants import (
|
|||||||
DEFAULT_CATEGORY_BUILDERS,
|
DEFAULT_CATEGORY_BUILDERS,
|
||||||
DAY_TEMPLATE_NAME_SUGGESTIONS,
|
DAY_TEMPLATE_NAME_SUGGESTIONS,
|
||||||
DEFAULT_CATEGORIES,
|
DEFAULT_CATEGORIES,
|
||||||
|
ENERGY_DENSITY_LABELS,
|
||||||
|
ENERGY_DENSITY_OPTIONS,
|
||||||
ITEM_KIND_LABELS,
|
ITEM_KIND_LABELS,
|
||||||
ITEM_KIND_SINGULAR_LABELS,
|
ITEM_KIND_SINGULAR_LABELS,
|
||||||
ITEM_SET_NAME_SUGGESTIONS,
|
ITEM_SET_NAME_SUGGESTIONS,
|
||||||
NOTIFICATION_CHANNEL_OPTIONS,
|
NOTIFICATION_CHANNEL_OPTIONS,
|
||||||
|
SUGGESTION_STYLE_LABELS,
|
||||||
|
SUGGESTION_STYLE_OPTIONS,
|
||||||
VISIBILITY_DESCRIPTIONS,
|
VISIBILITY_DESCRIPTIONS,
|
||||||
VISIBILITY_LABELS,
|
VISIBILITY_LABELS,
|
||||||
WEEKDAY_OPTIONS,
|
WEEKDAY_OPTIONS,
|
||||||
@@ -190,6 +194,9 @@ def get_user_settings() -> dict:
|
|||||||
"show_planned_not_shopped",
|
"show_planned_not_shopped",
|
||||||
"remind_tomorrow_if_sparse",
|
"remind_tomorrow_if_sparse",
|
||||||
"remind_week_if_sparse",
|
"remind_week_if_sparse",
|
||||||
|
"push_missing_breakfast",
|
||||||
|
"push_missing_lunch",
|
||||||
|
"push_missing_dinner",
|
||||||
"suggest_home_for_today",
|
"suggest_home_for_today",
|
||||||
"remind_small_snack",
|
"remind_small_snack",
|
||||||
"remind_nuts",
|
"remind_nuts",
|
||||||
@@ -200,6 +207,8 @@ def get_user_settings() -> dict:
|
|||||||
for field in boolean_fields:
|
for field in boolean_fields:
|
||||||
settings[field] = bool(settings.get(field))
|
settings[field] = bool(settings.get(field))
|
||||||
settings["notification_channel"] = settings.get("notification_channel") or "in_app"
|
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
|
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
|
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:
|
def visible_clause(table_alias: str) -> str:
|
||||||
return (
|
return (
|
||||||
f"{table_alias}.household_id = ? "
|
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
|
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["owner_name"] = owner_name
|
||||||
entry["target_name"] = target_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_personal"] = entry.get("visibility") == "personal"
|
||||||
entry["is_shared"] = entry.get("visibility") == "shared"
|
entry["is_shared"] = entry.get("visibility") == "shared"
|
||||||
entry["is_mine"] = entry.get("owner_user_id") == g.user["id"]
|
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
|
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(
|
def fetch_items(
|
||||||
*,
|
*,
|
||||||
kind: str | None = None,
|
kind: str | None = None,
|
||||||
@@ -589,6 +656,7 @@ def extract_item_form_data(existing: dict | None = None) -> dict:
|
|||||||
{
|
{
|
||||||
"name": request.form.get("name", "").strip(),
|
"name": request.form.get("name", "").strip(),
|
||||||
"category": request.form.get("category", "").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(),
|
"note": request.form.get("note", "").strip(),
|
||||||
"visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")),
|
"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")),
|
"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()],
|
"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_name": request.form.get("quick_food_name", "").strip(),
|
||||||
"quick_food_category": request.form.get("quick_food_category", "").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(),
|
"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(
|
cursor = get_db().execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO items (
|
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(),
|
current_household_id(),
|
||||||
@@ -619,6 +688,7 @@ def create_quick_food_from_form(form_data: dict) -> int:
|
|||||||
form_data["visibility"],
|
form_data["visibility"],
|
||||||
form_data["quick_food_name"],
|
form_data["quick_food_name"],
|
||||||
form_data["quick_food_category"],
|
form_data["quick_food_category"],
|
||||||
|
form_data["quick_food_energy_density"],
|
||||||
form_data["quick_food_note"],
|
form_data["quick_food_note"],
|
||||||
g.user["id"],
|
g.user["id"],
|
||||||
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 = [
|
target_patterns = [
|
||||||
("carb", "dairy", "fruit"),
|
("carb", "dairy", "fruit"),
|
||||||
("carb", "dairy", "nuts"),
|
("carb", "dairy", "nuts"),
|
||||||
|
("carb", "dairy", "seeds"),
|
||||||
("carb", "fruit", "dairy"),
|
("carb", "fruit", "dairy"),
|
||||||
]
|
]
|
||||||
reasons = {
|
reasons = {
|
||||||
("carb", "dairy", "fruit"): "Passt gut zu Frühstück oder Snack",
|
("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", "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",
|
("carb", "fruit", "dairy"): "Zuhause gut kombinierbar",
|
||||||
}
|
}
|
||||||
else:
|
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]:
|
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 = [
|
home_foods = [
|
||||||
item
|
item
|
||||||
for item in fetch_items(kind="food", availability="home")
|
for item in fetch_items(kind="food", availability="home")
|
||||||
if item_matches_daypart(item, daypart_id)
|
if item_matches_daypart(item, daypart_id)
|
||||||
]
|
]
|
||||||
home_food_ids = {item["id"] for item in home_foods}
|
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] = []
|
suggestions: list[dict] = []
|
||||||
seen_signatures: set[tuple[int, ...]] = set()
|
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:
|
if signature in seen_signatures:
|
||||||
continue
|
continue
|
||||||
seen_signatures.add(signature)
|
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(
|
suggestions.append(
|
||||||
{
|
{
|
||||||
"title": meal["name"],
|
"title": meal["name"],
|
||||||
"reason": "Zuhause vorhanden",
|
"reason": "Zuhause vorhanden",
|
||||||
"component_ids": meal["component_ids"],
|
"component_ids": meal["component_ids"],
|
||||||
"existing_item_id": meal["id"],
|
"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):
|
for suggestion in build_dynamic_meal_suggestions(home_foods, daypart_slug, limit=limit * 2):
|
||||||
signature = normalized_component_signature(suggestion["component_ids"])
|
signature = normalized_component_signature(suggestion["component_ids"])
|
||||||
if signature in seen_signatures:
|
if signature in seen_signatures:
|
||||||
continue
|
continue
|
||||||
seen_signatures.add(signature)
|
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)
|
suggestions.append(suggestion)
|
||||||
|
|
||||||
deduped: list[dict] = []
|
deduped: list[dict] = []
|
||||||
seen = set()
|
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:
|
if suggestion["title"] in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(suggestion["title"])
|
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)
|
item for item in fetch_items(kind="food", availability="home", daypart_id=daypart_id)
|
||||||
if first_missing in item.get("builder_keys", [])
|
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 = {
|
text_map = {
|
||||||
"protein": "Dazu könnte noch eine Proteinquelle gut passen.",
|
"protein": "Dazu könnte noch eine Proteinquelle gut passen.",
|
||||||
"carb": "Das lässt sich gut mit einer Kohlenhydratquelle ergänzen.",
|
"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.")
|
hints.append("Für den nächsten Einkauf sind schon ein paar Dinge vorgemerkt.")
|
||||||
|
|
||||||
if settings.get("remind_nuts"):
|
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:
|
if nut_items:
|
||||||
hints.append("Heute schon an Nüsse gedacht?")
|
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
|
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 = []
|
sections = []
|
||||||
day_entries = fetch_day_plan_entries(selected_date)
|
day_entries = fetch_day_plan_entries(selected_date)
|
||||||
for daypart in get_dayparts():
|
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"]),
|
"suggestions": build_daypart_suggestions(daypart["id"]),
|
||||||
"balance_suggestion": build_balance_suggestion(int(daypart["id"]), entry_item_ids),
|
"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_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"],
|
"is_open": selected_daypart_id == daypart["id"],
|
||||||
"summary_items": [entry["item_name"] for entry in entries][:2],
|
"summary_items": [entry["item_name"] for entry in entries][:2],
|
||||||
"default_visibility": "shared",
|
"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.get("category") or form_data.get("quick_food_category")
|
||||||
),
|
),
|
||||||
form_data=form_data,
|
form_data=form_data,
|
||||||
|
energy_density_options=ENERGY_DENSITY_OPTIONS,
|
||||||
visibility_options=VISIBILITY_FORM_OPTIONS,
|
visibility_options=VISIBILITY_FORM_OPTIONS,
|
||||||
target_user_options=get_target_user_options(),
|
target_user_options=get_target_user_options(),
|
||||||
)
|
)
|
||||||
@@ -2588,18 +2731,24 @@ def settings_view():
|
|||||||
flash("Die Einkaufsrhythmus-Einstellungen wurden gespeichert.", "success")
|
flash("Die Einkaufsrhythmus-Einstellungen wurden gespeichert.", "success")
|
||||||
elif form_name == "reminders":
|
elif form_name == "reminders":
|
||||||
ensure_user_settings_row()
|
ensure_user_settings_row()
|
||||||
|
suggestion_style = normalize_suggestion_style(request.form.get("suggestion_style"), "balanced")
|
||||||
get_db().execute(
|
get_db().execute(
|
||||||
"""
|
"""
|
||||||
UPDATE user_settings
|
UPDATE user_settings
|
||||||
SET reminders_enabled = ?,
|
SET reminders_enabled = ?,
|
||||||
push_enabled = ?,
|
push_enabled = ?,
|
||||||
notification_channel = ?,
|
notification_channel = ?,
|
||||||
|
suggestion_style = ?,
|
||||||
|
energy_preference = ?,
|
||||||
remind_before_shopping = ?,
|
remind_before_shopping = ?,
|
||||||
remind_on_shopping_day = ?,
|
remind_on_shopping_day = ?,
|
||||||
show_missing_for_upcoming_week = ?,
|
show_missing_for_upcoming_week = ?,
|
||||||
show_planned_not_shopped = ?,
|
show_planned_not_shopped = ?,
|
||||||
remind_tomorrow_if_sparse = ?,
|
remind_tomorrow_if_sparse = ?,
|
||||||
remind_week_if_sparse = ?,
|
remind_week_if_sparse = ?,
|
||||||
|
push_missing_breakfast = ?,
|
||||||
|
push_missing_lunch = ?,
|
||||||
|
push_missing_dinner = ?,
|
||||||
suggest_home_for_today = ?,
|
suggest_home_for_today = ?,
|
||||||
remind_small_snack = ?,
|
remind_small_snack = ?,
|
||||||
remind_nuts = ?,
|
remind_nuts = ?,
|
||||||
@@ -2613,12 +2762,17 @@ def settings_view():
|
|||||||
parse_checkbox("reminders_enabled", True),
|
parse_checkbox("reminders_enabled", True),
|
||||||
parse_checkbox("push_enabled", False),
|
parse_checkbox("push_enabled", False),
|
||||||
normalize_notification_channel(request.form.get("notification_channel"), "in_app"),
|
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_before_shopping", True),
|
||||||
parse_checkbox("remind_on_shopping_day", True),
|
parse_checkbox("remind_on_shopping_day", True),
|
||||||
parse_checkbox("show_missing_for_upcoming_week", True),
|
parse_checkbox("show_missing_for_upcoming_week", True),
|
||||||
parse_checkbox("show_planned_not_shopped", True),
|
parse_checkbox("show_planned_not_shopped", True),
|
||||||
parse_checkbox("remind_tomorrow_if_sparse", True),
|
parse_checkbox("remind_tomorrow_if_sparse", True),
|
||||||
parse_checkbox("remind_week_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("suggest_home_for_today", True),
|
||||||
parse_checkbox("remind_small_snack", False),
|
parse_checkbox("remind_small_snack", False),
|
||||||
parse_checkbox("remind_nuts", False),
|
parse_checkbox("remind_nuts", False),
|
||||||
@@ -2831,6 +2985,7 @@ def item_create(kind: str):
|
|||||||
form_data = {
|
form_data = {
|
||||||
"name": request.args.get("name", "").strip(),
|
"name": request.args.get("name", "").strip(),
|
||||||
"category": "",
|
"category": "",
|
||||||
|
"energy_density": "neutral",
|
||||||
"note": "",
|
"note": "",
|
||||||
"visibility": "shared",
|
"visibility": "shared",
|
||||||
"target_user_id": None,
|
"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()],
|
"component_ids": [int(value) for value in request.args.getlist("component_ids") if value.isdigit()],
|
||||||
"quick_food_name": "",
|
"quick_food_name": "",
|
||||||
"quick_food_category": "",
|
"quick_food_category": "",
|
||||||
|
"quick_food_energy_density": "neutral",
|
||||||
"quick_food_note": "",
|
"quick_food_note": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2878,9 +3034,9 @@ def item_create(kind: str):
|
|||||||
cursor = get_db().execute(
|
cursor = get_db().execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO items (
|
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(),
|
current_household_id(),
|
||||||
@@ -2890,6 +3046,7 @@ def item_create(kind: str):
|
|||||||
kind,
|
kind,
|
||||||
form_data["name"],
|
form_data["name"],
|
||||||
form_data["category"],
|
form_data["category"],
|
||||||
|
form_data["energy_density"],
|
||||||
form_data["note"],
|
form_data["note"],
|
||||||
photo_filename,
|
photo_filename,
|
||||||
g.user["id"],
|
g.user["id"],
|
||||||
@@ -2921,6 +3078,7 @@ def item_edit(item_id: int):
|
|||||||
form_data = {
|
form_data = {
|
||||||
"name": item["name"],
|
"name": item["name"],
|
||||||
"category": item["category"] or "",
|
"category": item["category"] or "",
|
||||||
|
"energy_density": item.get("energy_density") or "neutral",
|
||||||
"note": item["note"] or "",
|
"note": item["note"] or "",
|
||||||
"visibility": item["visibility"],
|
"visibility": item["visibility"],
|
||||||
"target_user_id": item["target_user_id"],
|
"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 [],
|
"component_ids": get_meal_component_ids(item_id) if item["kind"] == "meal" else [],
|
||||||
"quick_food_name": "",
|
"quick_food_name": "",
|
||||||
"quick_food_category": "",
|
"quick_food_category": "",
|
||||||
|
"quick_food_energy_density": "neutral",
|
||||||
"quick_food_note": "",
|
"quick_food_note": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2970,6 +3129,7 @@ def item_edit(item_id: int):
|
|||||||
UPDATE items
|
UPDATE items
|
||||||
SET name = ?,
|
SET name = ?,
|
||||||
category = ?,
|
category = ?,
|
||||||
|
energy_density = ?,
|
||||||
note = ?,
|
note = ?,
|
||||||
visibility = ?,
|
visibility = ?,
|
||||||
target_user_id = ?,
|
target_user_id = ?,
|
||||||
@@ -2981,6 +3141,7 @@ def item_edit(item_id: int):
|
|||||||
(
|
(
|
||||||
form_data["name"],
|
form_data["name"],
|
||||||
form_data["category"],
|
form_data["category"],
|
||||||
|
form_data["energy_density"],
|
||||||
form_data["note"],
|
form_data["note"],
|
||||||
form_data["visibility"],
|
form_data["visibility"],
|
||||||
form_data["target_user_id"],
|
form_data["target_user_id"],
|
||||||
@@ -3303,14 +3464,23 @@ def planner_day():
|
|||||||
|
|
||||||
selected_item_raw = request.args.get("item_id", "").strip()
|
selected_item_raw = request.args.get("item_id", "").strip()
|
||||||
selected_daypart_raw = request.args.get("daypart_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_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_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(
|
return render_template(
|
||||||
"planner/day.html",
|
"planner/day.html",
|
||||||
selected_date=selected_date,
|
selected_date=selected_date,
|
||||||
previous_day=selected_date - timedelta(days=1),
|
previous_day=selected_date - timedelta(days=1),
|
||||||
next_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(),
|
today=date.today(),
|
||||||
visibility_options=VISIBILITY_FORM_OPTIONS,
|
visibility_options=VISIBILITY_FORM_OPTIONS,
|
||||||
day_templates=fetch_day_templates()[:6],
|
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,
|
reminders_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
push_enabled INTEGER NOT NULL DEFAULT 0,
|
push_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
notification_channel TEXT NOT NULL DEFAULT 'in_app',
|
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_before_shopping INTEGER NOT NULL DEFAULT 1,
|
||||||
remind_on_shopping_day 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_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1,
|
||||||
show_planned_not_shopped INTEGER NOT NULL DEFAULT 1,
|
show_planned_not_shopped INTEGER NOT NULL DEFAULT 1,
|
||||||
remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1,
|
remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1,
|
||||||
remind_week_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,
|
suggest_home_for_today INTEGER NOT NULL DEFAULT 1,
|
||||||
remind_small_snack INTEGER NOT NULL DEFAULT 0,
|
remind_small_snack INTEGER NOT NULL DEFAULT 0,
|
||||||
remind_nuts 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
|
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 (
|
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,
|
||||||
@@ -96,6 +110,7 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')),
|
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')),
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
category TEXT,
|
category TEXT,
|
||||||
|
energy_density TEXT NOT NULL DEFAULT 'neutral',
|
||||||
note TEXT,
|
note TEXT,
|
||||||
photo_filename TEXT,
|
photo_filename TEXT,
|
||||||
availability_state TEXT NOT NULL DEFAULT 'idea' CHECK (availability_state IN ('idea', 'home', 'archived')),
|
availability_state TEXT NOT NULL DEFAULT 'idea' CHECK (availability_state IN ('idea', 'home', 'archived')),
|
||||||
|
|||||||
@@ -260,32 +260,54 @@ h3,
|
|||||||
|
|
||||||
@media (min-width: 1081px) {
|
@media (min-width: 1081px) {
|
||||||
.site-header {
|
.site-header {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
column-gap: 1.5rem;
|
||||||
row-gap: 0.9rem;
|
row-gap: 0.9rem;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop-header-main {
|
.desktop-header-main {
|
||||||
display: grid;
|
display: contents;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
|
||||||
align-items: center;
|
|
||||||
gap: 1.4rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop-header-sub {
|
.desktop-header-sub {
|
||||||
display: flex;
|
display: contents;
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop-nav {
|
.desktop-nav {
|
||||||
width: 100%;
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-nav::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop-actions {
|
.desktop-actions {
|
||||||
width: 100%;
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
align-self: center;
|
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,
|
.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 OFFLINE_URL = "/static/pwa/offline.html";
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
"/static/css/styles.css",
|
"/static/css/styles.css",
|
||||||
|
|||||||
@@ -55,6 +55,16 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
<label>
|
||||||
Notiz
|
Notiz
|
||||||
<textarea name="note" rows="4" placeholder="Optional, wenn eine kleine Erinnerung hilfreich ist.">{{ form_data.note }}</textarea>
|
<textarea name="note" rows="4" placeholder="Optional, wenn eine kleine Erinnerung hilfreich ist.">{{ form_data.note }}</textarea>
|
||||||
@@ -149,6 +159,14 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<label class="wide">
|
||||||
Notiz
|
Notiz
|
||||||
<input type="text" name="quick_food_note" value="{{ form_data.quick_food_note }}" placeholder="Optional">
|
<input type="text" name="quick_food_note" value="{{ form_data.quick_food_note }}" placeholder="Optional">
|
||||||
|
|||||||
@@ -72,6 +72,35 @@
|
|||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div class="day-tile-body">
|
<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 %}
|
{% if section.balance_suggestion %}
|
||||||
<div class="suggestion-card">
|
<div class="suggestion-card">
|
||||||
<strong>{{ section.balance_suggestion.text }}</strong>
|
<strong>{{ section.balance_suggestion.text }}</strong>
|
||||||
@@ -143,7 +172,7 @@
|
|||||||
<div class="planner-subsection">
|
<div class="planner-subsection">
|
||||||
<label class="planner-search">
|
<label class="planner-search">
|
||||||
<span>Suche</span>
|
<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>
|
</label>
|
||||||
<div class="compact-picker-list" id="planner-list-{{ section.daypart.id }}">
|
<div class="compact-picker-list" id="planner-list-{{ section.daypart.id }}">
|
||||||
{% for item in section.food_candidates %}
|
{% for item in section.food_candidates %}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
<div class="pwa-card">
|
<div class="pwa-card">
|
||||||
<strong>Push-Mitteilungen</strong>
|
<strong>Push-Mitteilungen</strong>
|
||||||
{% if push_ready %}
|
{% 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">
|
<div class="row-actions">
|
||||||
<button class="secondary" type="button" data-push-enable>Push erlauben</button>
|
<button class="secondary" type="button" data-push-enable>Push erlauben</button>
|
||||||
<button class="ghost-button" type="button" data-push-disable>Push beenden</button>
|
<button class="ghost-button" type="button" data-push-disable>Push beenden</button>
|
||||||
@@ -98,6 +98,17 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -112,8 +123,12 @@
|
|||||||
<legend>Planung</legend>
|
<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_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="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="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>
|
<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>
|
||||||
|
|
||||||
<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/
|
cp -a /app/bootstrap-data/uploads/. /app/data/uploads/
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec gunicorn --bind 0.0.0.0:8000 --workers 2 --threads 4 wsgi:app
|
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