Compare commits
4 Commits
V0.6.0
...
40bab48806
| Author | SHA1 | Date | |
|---|---|---|---|
| 40bab48806 | |||
| ed14dd4aef | |||
| 35e6a7b56e | |||
| 325101da99 |
@@ -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.1",
|
||||||
"upstreamVersion": "0.6.0",
|
"upstreamVersion": "1.0.1",
|
||||||
"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.
|
||||||
+45
-1
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
@@ -16,10 +17,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,
|
||||||
@@ -30,6 +35,7 @@ from .main import main_bp
|
|||||||
|
|
||||||
WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
|
WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
|
||||||
WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
||||||
|
DEFAULT_RELEASE_URL = "https://git.hnz.io/hnzio/nouri-App/releases"
|
||||||
|
|
||||||
|
|
||||||
def load_secret_key(data_dir: Path) -> str:
|
def load_secret_key(data_dir: Path) -> str:
|
||||||
@@ -50,11 +56,38 @@ def load_secret_key(data_dir: Path) -> str:
|
|||||||
return secret_value
|
return secret_value
|
||||||
|
|
||||||
|
|
||||||
|
def load_app_version(root_dir: Path) -> str:
|
||||||
|
env_version = os.environ.get("NOURI_APP_VERSION", "").strip()
|
||||||
|
if env_version:
|
||||||
|
return env_version
|
||||||
|
|
||||||
|
manifest_path = root_dir / "CloudronManifest.json"
|
||||||
|
if manifest_path.exists():
|
||||||
|
try:
|
||||||
|
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
manifest_data = {}
|
||||||
|
manifest_version = str(
|
||||||
|
manifest_data.get("upstreamVersion")
|
||||||
|
or manifest_data.get("version")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if manifest_version:
|
||||||
|
return manifest_version
|
||||||
|
return "1.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
def load_release_url() -> str:
|
||||||
|
return os.environ.get("NOURI_RELEASE_URL", DEFAULT_RELEASE_URL).strip() or DEFAULT_RELEASE_URL
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> Flask:
|
def create_app() -> Flask:
|
||||||
root_dir = Path(__file__).resolve().parent.parent
|
root_dir = Path(__file__).resolve().parent.parent
|
||||||
data_dir = Path(os.environ.get("NOURI_DATA_DIR", root_dir / "data")).resolve()
|
data_dir = Path(os.environ.get("NOURI_DATA_DIR", root_dir / "data")).resolve()
|
||||||
upload_dir = data_dir / "uploads"
|
upload_dir = data_dir / "uploads"
|
||||||
db_path = data_dir / "nouri.sqlite3"
|
db_path = data_dir / "nouri.sqlite3"
|
||||||
|
app_version = load_app_version(root_dir)
|
||||||
|
release_url = load_release_url()
|
||||||
|
|
||||||
data_dir.mkdir(parents=True, exist_ok=True)
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
ensure_upload_structure(upload_dir)
|
ensure_upload_structure(upload_dir)
|
||||||
@@ -69,7 +102,10 @@ def create_app() -> Flask:
|
|||||||
PERMANENT_SESSION_LIFETIME=timedelta(days=30),
|
PERMANENT_SESSION_LIFETIME=timedelta(days=30),
|
||||||
SESSION_COOKIE_HTTPONLY=True,
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
SESSION_COOKIE_SAMESITE="Lax",
|
SESSION_COOKIE_SAMESITE="Lax",
|
||||||
APP_VERSION="0.6.0",
|
SESSION_COOKIE_SECURE=os.environ.get("NOURI_SECURE_COOKIES", "0") == "1",
|
||||||
|
APP_VERSION=app_version,
|
||||||
|
RELEASE_URL=release_url,
|
||||||
|
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 +133,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,
|
||||||
@@ -104,6 +144,7 @@ def create_app() -> Flask:
|
|||||||
"notification_channel_options": NOTIFICATION_CHANNEL_OPTIONS,
|
"notification_channel_options": NOTIFICATION_CHANNEL_OPTIONS,
|
||||||
"today": date.today(),
|
"today": date.today(),
|
||||||
"app_version": app.config["APP_VERSION"],
|
"app_version": app.config["APP_VERSION"],
|
||||||
|
"app_release_url": app.config["RELEASE_URL"],
|
||||||
"push_public_key": app.config["VAPID_PUBLIC_KEY"],
|
"push_public_key": app.config["VAPID_PUBLIC_KEY"],
|
||||||
"push_available": bool(app.config["VAPID_PUBLIC_KEY"] and app.config["VAPID_PRIVATE_KEY"]),
|
"push_available": bool(app.config["VAPID_PUBLIC_KEY"] and app.config["VAPID_PRIVATE_KEY"]),
|
||||||
"weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()],
|
"weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()],
|
||||||
@@ -159,6 +200,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)
|
||||||
|
|||||||
@@ -289,3 +289,31 @@ def category_update(category_id: int):
|
|||||||
get_db().commit()
|
get_db().commit()
|
||||||
flash("Die Zuordnung wurde aktualisiert.", "success")
|
flash("Die Zuordnung wurde aktualisiert.", "success")
|
||||||
return redirect(url_for("admin.category_settings"))
|
return redirect(url_for("admin.category_settings"))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.post("/categories/<int:category_id>/delete")
|
||||||
|
@admin_required
|
||||||
|
def category_delete(category_id: int):
|
||||||
|
category = get_db().execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM household_categories
|
||||||
|
WHERE id = ? AND household_id = ?
|
||||||
|
""",
|
||||||
|
(category_id, g.user["household_id"]),
|
||||||
|
).fetchone()
|
||||||
|
if category is None:
|
||||||
|
flash("Die Kategorie wurde nicht gefunden.", "error")
|
||||||
|
return redirect(url_for("admin.category_settings"))
|
||||||
|
|
||||||
|
if category["name"] in DEFAULT_CATEGORIES:
|
||||||
|
flash("Standardkategorien bleiben erhalten. Du kannst sie bei Bedarf pausieren.", "info")
|
||||||
|
return redirect(url_for("admin.category_settings"))
|
||||||
|
|
||||||
|
get_db().execute(
|
||||||
|
"DELETE FROM household_categories WHERE id = ? AND household_id = ?",
|
||||||
|
(category_id, g.user["household_id"]),
|
||||||
|
)
|
||||||
|
get_db().commit()
|
||||||
|
flash("Die Kategorie wurde entfernt.", "success")
|
||||||
|
return redirect(url_for("admin.category_settings"))
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
+41
-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:
|
||||||
@@ -18,9 +18,11 @@ def get_db() -> sqlite3.Connection:
|
|||||||
g.db = sqlite3.connect(
|
g.db = sqlite3.connect(
|
||||||
current_app.config["DATABASE_PATH"],
|
current_app.config["DATABASE_PATH"],
|
||||||
detect_types=sqlite3.PARSE_DECLTYPES,
|
detect_types=sqlite3.PARSE_DECLTYPES,
|
||||||
|
timeout=30,
|
||||||
)
|
)
|
||||||
g.db.row_factory = sqlite3.Row
|
g.db.row_factory = sqlite3.Row
|
||||||
g.db.execute("PRAGMA foreign_keys = ON")
|
g.db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
g.db.execute("PRAGMA busy_timeout = 30000")
|
||||||
return g.db
|
return g.db
|
||||||
|
|
||||||
|
|
||||||
@@ -127,6 +129,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 +151,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 +192,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 +237,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 +360,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 +412,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(
|
||||||
"""
|
"""
|
||||||
|
|||||||
+271
-42
@@ -4,6 +4,7 @@ from collections import defaultdict
|
|||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from itertools import product
|
from itertools import product
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
@@ -27,10 +28,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,
|
||||||
@@ -75,8 +80,10 @@ def refresh_due_context():
|
|||||||
if getattr(g, "user", None) is None:
|
if getattr(g, "user", None) is None:
|
||||||
return None
|
return None
|
||||||
if request.method == "GET" and endpoint.startswith("main."):
|
if request.method == "GET" and endpoint.startswith("main."):
|
||||||
ensure_user_settings_row()
|
try:
|
||||||
activate_due_shopping_needs()
|
activate_due_shopping_needs()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
current_app.logger.warning("Due shopping needs could not be activated during this request.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -168,19 +175,54 @@ def get_household_settings() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def ensure_user_settings_row() -> None:
|
def default_user_settings() -> dict:
|
||||||
|
suggestion_style = "balanced"
|
||||||
|
return {
|
||||||
|
"user_id": int(g.user["id"]),
|
||||||
|
"reminders_enabled": True,
|
||||||
|
"push_enabled": False,
|
||||||
|
"notification_channel": "in_app",
|
||||||
|
"suggestion_style": suggestion_style,
|
||||||
|
"energy_preference": suggestion_style_energy_preference(suggestion_style),
|
||||||
|
"remind_before_shopping": True,
|
||||||
|
"remind_on_shopping_day": True,
|
||||||
|
"show_missing_for_upcoming_week": True,
|
||||||
|
"show_planned_not_shopped": True,
|
||||||
|
"remind_tomorrow_if_sparse": True,
|
||||||
|
"remind_week_if_sparse": True,
|
||||||
|
"push_missing_breakfast": False,
|
||||||
|
"push_missing_lunch": False,
|
||||||
|
"push_missing_dinner": False,
|
||||||
|
"suggest_home_for_today": True,
|
||||||
|
"remind_small_snack": False,
|
||||||
|
"remind_nuts": False,
|
||||||
|
"show_meal_balancing": True,
|
||||||
|
"suggest_templates": True,
|
||||||
|
"suggest_patterns": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_user_settings_row(*, commit: bool = False) -> None:
|
||||||
|
existing = get_db().execute(
|
||||||
|
"SELECT 1 FROM user_settings WHERE user_id = ? LIMIT 1",
|
||||||
|
(g.user["id"],),
|
||||||
|
).fetchone()
|
||||||
|
if existing is not None:
|
||||||
|
return
|
||||||
get_db().execute(
|
get_db().execute(
|
||||||
"INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)",
|
"INSERT INTO user_settings (user_id) VALUES (?)",
|
||||||
(g.user["id"],),
|
(g.user["id"],),
|
||||||
)
|
)
|
||||||
|
if commit:
|
||||||
|
get_db().commit()
|
||||||
|
|
||||||
|
|
||||||
def get_user_settings() -> dict:
|
def get_user_settings() -> dict:
|
||||||
ensure_user_settings_row()
|
settings = default_user_settings()
|
||||||
row = get_db().execute("SELECT * FROM user_settings WHERE user_id = ?", (g.user["id"],)).fetchone()
|
row = get_db().execute("SELECT * FROM user_settings WHERE user_id = ?", (g.user["id"],)).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
return {}
|
return settings
|
||||||
settings = dict(row)
|
settings.update(dict(row))
|
||||||
boolean_fields = {
|
boolean_fields = {
|
||||||
"reminders_enabled",
|
"reminders_enabled",
|
||||||
"push_enabled",
|
"push_enabled",
|
||||||
@@ -190,6 +232,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 +245,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 +267,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 +349,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 +568,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 +694,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 +704,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 +715,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 +726,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"],
|
||||||
@@ -1169,65 +1277,89 @@ def build_generated_meal_name(combo: list[dict], daypart_slug: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, limit: int) -> list[dict]:
|
def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, limit: int) -> list[dict]:
|
||||||
builder_groups: dict[str, list[dict]] = defaultdict(list)
|
|
||||||
for food in home_foods:
|
|
||||||
for builder_key in food.get("builder_keys", ["neutral"]):
|
|
||||||
builder_groups[builder_key].append(food)
|
|
||||||
|
|
||||||
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}:
|
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}:
|
||||||
target_patterns = [
|
target_patterns = [
|
||||||
("carb", "dairy", "fruit"),
|
{
|
||||||
("carb", "dairy", "nuts"),
|
"slots": ({"carb"}, {"dairy", "protein"}, {"fruit", "nuts", "seeds"}),
|
||||||
("carb", "fruit", "dairy"),
|
"reason": "Passt gut zu Frühstück oder Snack",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slots": ({"carb"}, {"dairy", "protein"}),
|
||||||
|
"reason": "Zuhause schnell kombinierbar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slots": ({"dairy", "protein"}, {"fruit", "nuts", "seeds"}),
|
||||||
|
"reason": "Lässt sich gut als kleiner Snack vormerken",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
reasons = {
|
|
||||||
("carb", "dairy", "fruit"): "Passt gut zu Frühstück oder Snack",
|
|
||||||
("carb", "dairy", "nuts"): "Lässt sich gut für einen Snack vormerken",
|
|
||||||
("carb", "fruit", "dairy"): "Zuhause gut kombinierbar",
|
|
||||||
}
|
|
||||||
else:
|
else:
|
||||||
target_patterns = [
|
target_patterns = [
|
||||||
("protein", "carb", "veg"),
|
{
|
||||||
("protein", "carb"),
|
"slots": ({"protein"}, {"carb"}, {"veg"}),
|
||||||
|
"reason": "Zuhause als vollständige Mahlzeit möglich",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slots": ({"protein"}, {"carb"}),
|
||||||
|
"reason": "Lässt sich leicht ergänzen",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slots": ({"protein"}, {"veg"}),
|
||||||
|
"reason": "Zuhause schon gut kombinierbar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slots": ({"carb"}, {"veg"}),
|
||||||
|
"reason": "Daraus kann schnell etwas Einfaches werden",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
reasons = {
|
|
||||||
("protein", "carb", "veg"): "Zuhause als vollständige Mahlzeit möglich",
|
|
||||||
("protein", "carb"): "Lässt sich leicht ergänzen",
|
|
||||||
}
|
|
||||||
|
|
||||||
suggestions: list[dict] = []
|
suggestions: list[dict] = []
|
||||||
seen_signatures: set[tuple[int, ...]] = set()
|
seen_signatures: set[tuple[int, ...]] = set()
|
||||||
|
|
||||||
|
def slot_matches(food: dict, slot_keys: set[str]) -> bool:
|
||||||
|
return bool(slot_keys & set(food.get("builder_keys", ["neutral"])))
|
||||||
|
|
||||||
for pattern in target_patterns:
|
for pattern in target_patterns:
|
||||||
groups = [builder_groups.get(builder_key, []) for builder_key in pattern]
|
slot_candidates = []
|
||||||
if any(not group for group in groups):
|
for slot_keys in pattern["slots"]:
|
||||||
|
matches = [food for food in home_foods if slot_matches(food, slot_keys)]
|
||||||
|
if not matches:
|
||||||
|
slot_candidates = []
|
||||||
|
break
|
||||||
|
slot_candidates.append(matches)
|
||||||
|
if not slot_candidates:
|
||||||
continue
|
continue
|
||||||
for combo in product(*groups):
|
|
||||||
|
for combo in product(*slot_candidates):
|
||||||
signature = normalized_component_signature([item["id"] for item in combo])
|
signature = normalized_component_signature([item["id"] for item in combo])
|
||||||
if len(signature) != len(pattern) or signature in seen_signatures:
|
if len(signature) != len(combo) or signature in seen_signatures:
|
||||||
continue
|
continue
|
||||||
seen_signatures.add(signature)
|
seen_signatures.add(signature)
|
||||||
combo_items = list(combo)
|
combo_items = list(combo)
|
||||||
suggestions.append(
|
suggestions.append(
|
||||||
{
|
{
|
||||||
"title": build_generated_meal_name(combo_items, daypart_slug),
|
"title": build_generated_meal_name(combo_items, daypart_slug),
|
||||||
"reason": reasons.get(pattern, "Zuhause gut kombinierbar"),
|
"reason": pattern["reason"],
|
||||||
"component_ids": [item["id"] for item in combo_items],
|
"component_ids": [item["id"] for item in combo_items],
|
||||||
"existing_item_id": None,
|
"existing_item_id": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if len(suggestions) >= limit:
|
if len(suggestions) >= limit * 3:
|
||||||
return suggestions
|
break
|
||||||
|
if len(suggestions) >= limit * 3:
|
||||||
|
break
|
||||||
return suggestions
|
return suggestions
|
||||||
|
|
||||||
|
|
||||||
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 +1370,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 +1430,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 +1509,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 +1714,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():
|
||||||
@@ -1584,6 +1776,7 @@ def build_day_planner_sections(selected_date: date, selected_item_id: int | None
|
|||||||
+ [item for item in candidates if item["kind"] == "food"],
|
+ [item for item in candidates if item["kind"] == "food"],
|
||||||
limit=20,
|
limit=20,
|
||||||
)
|
)
|
||||||
|
search_candidates = dedupe_items(meal_candidates + food_candidates, limit=24)
|
||||||
entry_item_ids = [int(entry["item_id"]) for entry in entries]
|
entry_item_ids = [int(entry["item_id"]) for entry in entries]
|
||||||
sections.append(
|
sections.append(
|
||||||
{
|
{
|
||||||
@@ -1592,10 +1785,18 @@ def build_day_planner_sections(selected_date: date, selected_item_id: int | None
|
|||||||
"candidates": candidates,
|
"candidates": candidates,
|
||||||
"meal_candidates": meal_candidates,
|
"meal_candidates": meal_candidates,
|
||||||
"food_candidates": food_candidates,
|
"food_candidates": food_candidates,
|
||||||
|
"search_candidates": search_candidates,
|
||||||
"recipe_suggestions": build_home_recipe_suggestions(int(daypart["id"]), limit=3),
|
"recipe_suggestions": build_home_recipe_suggestions(int(daypart["id"]), limit=3),
|
||||||
"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 +2283,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 +2790,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 +2821,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 +3044,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 +3054,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 +3093,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 +3105,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 +3137,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 +3147,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 +3188,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 +3200,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 +3523,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,
|
||||||
|
|||||||
+47
-2
@@ -78,8 +78,8 @@
|
|||||||
const applyFilter = () => {
|
const applyFilter = () => {
|
||||||
const term = input.value.trim().toLowerCase();
|
const term = input.value.trim().toLowerCase();
|
||||||
if (!term) {
|
if (!term) {
|
||||||
items.forEach((item) => {
|
items.forEach((item, index) => {
|
||||||
item.hidden = false;
|
item.hidden = hasLimit ? index >= resultLimit : false;
|
||||||
});
|
});
|
||||||
syncGroups();
|
syncGroups();
|
||||||
return;
|
return;
|
||||||
@@ -109,8 +109,53 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const initIosPullToRefresh = () => {
|
||||||
|
const isAppleTouchDevice = /iP(ad|hone|od)/.test(navigator.userAgent)
|
||||||
|
|| (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||||
|
if (!isAppleTouchDevice) return;
|
||||||
|
|
||||||
|
let startY = 0;
|
||||||
|
let maxPull = 0;
|
||||||
|
let tracking = false;
|
||||||
|
|
||||||
|
window.addEventListener("touchstart", (event) => {
|
||||||
|
if (window.scrollY > 0) {
|
||||||
|
tracking = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startY = event.touches[0].clientY;
|
||||||
|
maxPull = 0;
|
||||||
|
tracking = true;
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
window.addEventListener("touchmove", (event) => {
|
||||||
|
if (!tracking) return;
|
||||||
|
const currentY = event.touches[0].clientY;
|
||||||
|
maxPull = Math.max(maxPull, currentY - startY);
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
window.addEventListener("touchend", () => {
|
||||||
|
if (tracking && maxPull > 96 && window.scrollY <= 2) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
tracking = false;
|
||||||
|
maxPull = 0;
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
document.addEventListener("gesturestart", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("touchmove", (event) => {
|
||||||
|
if (event.touches.length > 1) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
initMobileSheet();
|
initMobileSheet();
|
||||||
initFilterInputs();
|
initFilterInputs();
|
||||||
|
initIosPullToRefresh();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -66,6 +66,12 @@
|
|||||||
{% if category.is_active %}Pausieren{% else %}Wieder aktivieren{% endif %}
|
{% if category.is_active %}Pausieren{% else %}Wieder aktivieren{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% if category.name not in default_categories %}
|
||||||
|
<form method="post" action="{{ url_for('admin.category_delete', category_id=category.id) }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button class="ghost-button" type="submit">Löschen</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="de" data-theme="auto">
|
<html lang="de" data-theme="auto">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||||
<title>{% block title %}Nouri{% endblock %}</title>
|
<title>{% block title %}Nouri{% endblock %}</title>
|
||||||
<meta name="theme-color" content="#de9862">
|
<meta name="theme-color" content="#de9862">
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<div class="footer-copy">
|
<div class="footer-copy">
|
||||||
<span>Version {{ app_version }}</span>
|
<a href="{{ app_release_url }}" target="_blank" rel="noreferrer">Version {{ app_version }}</a>
|
||||||
<span>Made with <span class="ui-icon icon-heart"></span> in Göttingen</span>
|
<span>Made with <span class="ui-icon icon-heart"></span> in Göttingen</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-copy">
|
<div class="footer-copy">
|
||||||
|
|||||||
@@ -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,10 +172,10 @@
|
|||||||
<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.search_candidates %}
|
||||||
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
|
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
||||||
@@ -155,7 +184,10 @@
|
|||||||
<input type="hidden" name="visibility" value="{{ item.visibility }}">
|
<input type="hidden" name="visibility" value="{{ item.visibility }}">
|
||||||
<button class="picker-row" type="submit">
|
<button class="picker-row" type="submit">
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
{% if item.availability_state == 'home' %}<small>zuhause</small>{% endif %}
|
<small>
|
||||||
|
{{ item_kind_labels[item.kind]|lower }}
|
||||||
|
{% if item.availability_state == 'home' %} · zuhause{% endif %}
|
||||||
|
</small>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -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 1 --threads 4 wsgi:app
|
||||||
|
|||||||
Reference in New Issue
Block a user