Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1839328dfe | |||
| 01b8128835 | |||
| 4babf93179 |
@@ -12,3 +12,7 @@ dist
|
|||||||
build
|
build
|
||||||
data
|
data
|
||||||
instance
|
instance
|
||||||
|
.cloudron-push.env
|
||||||
|
.env.local
|
||||||
|
.env.push.local
|
||||||
|
nouri.sqlite3
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ __pycache__/
|
|||||||
|
|
||||||
data/
|
data/
|
||||||
instance/
|
instance/
|
||||||
|
nouri.sqlite3
|
||||||
.cloudron-push.env
|
.cloudron-push.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.push.local
|
.env.push.local
|
||||||
|
|||||||
@@ -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": "1.3.3",
|
"version": "1.3.4",
|
||||||
"upstreamVersion": "1.3.3",
|
"upstreamVersion": "1.3.4",
|
||||||
"healthCheckPath": "/",
|
"healthCheckPath": "/",
|
||||||
"httpPort": 8000,
|
"httpPort": 8000,
|
||||||
"manifestVersion": 2,
|
"manifestVersion": 2,
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# Home Assistant
|
||||||
|
|
||||||
|
Nouri stellt für Home Assistant eine kleine token-geschützte JSON-API bereit. Sie ist für Sprachbefehle wie diese gedacht:
|
||||||
|
|
||||||
|
- `Füge Erbsen auf die Einkaufsliste hinzu`
|
||||||
|
- `Füge Erbsen mit Hinweis TK auf die Einkaufsliste hinzu`
|
||||||
|
- `Füge Blumenerde als Einkaufsartikel hinzu`
|
||||||
|
- `Füge Skyr als Lebensmittel hinzu`
|
||||||
|
|
||||||
|
## Nouri konfigurieren
|
||||||
|
|
||||||
|
Setze in der Nouri-Umgebung:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NOURI_HA_TOKEN=<langes-zufälliges-token>
|
||||||
|
NOURI_HA_USER_ID=<optionale-nouri-user-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
`NOURI_HA_USER_ID` ist optional. Ohne diesen Wert nutzt Nouri zuerst den ersten aktiven Admin und danach den ersten aktiven Nutzer.
|
||||||
|
|
||||||
|
Lokal zum Testen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NOURI_HA_TOKEN='ein-langes-zufälliges-token'
|
||||||
|
flask --app wsgi run --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Auf Cloudron:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cloudron env set --app <nouri-app> NOURI_HA_TOKEN='ein-langes-zufälliges-token'
|
||||||
|
cloudron restart --app <nouri-app>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Home Assistant vorbereiten
|
||||||
|
|
||||||
|
Lege in Home Assistant in `secrets.yaml` diese Werte an:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nouri_ha_token: ein-langes-zufälliges-token
|
||||||
|
nouri_url: https://deine-nouri-domain.example.org
|
||||||
|
```
|
||||||
|
|
||||||
|
Wenn Home Assistant lokal auf Nouri zugreift, nimm die aus Home Assistant erreichbare Adresse, zum Beispiel:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nouri_url: http://192.168.178.50:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
`127.0.0.1` funktioniert nur, wenn Home Assistant und Nouri im selben Prozess- oder Container-Kontext laufen.
|
||||||
|
|
||||||
|
## Eintrag hinzufügen
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/home-assistant/shopping
|
||||||
|
Authorization: Bearer <NOURI_HA_TOKEN>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Erbsen",
|
||||||
|
"note": "TK"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wenn Nouri den Eintrag kennt, wird er direkt auf die Einkaufsliste gesetzt. `note` landet im bestehenden Feld `Einkaufshinweis`.
|
||||||
|
|
||||||
|
Wenn Nouri den Eintrag nicht kennt, kommt eine Rückfrage-Antwort:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"status": "needs_confirmation",
|
||||||
|
"name": "Erbsen",
|
||||||
|
"note": "TK",
|
||||||
|
"options": ["food", "shopping"],
|
||||||
|
"message": "Erbsen kenne ich noch nicht. Soll ich es als Lebensmittel oder Einkaufsartikel anlegen?"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rückfrage bestätigen
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/home-assistant/shopping/confirm
|
||||||
|
Authorization: Bearer <NOURI_HA_TOKEN>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Erbsen",
|
||||||
|
"note": "TK",
|
||||||
|
"create_as": "food"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Mögliche Werte für `create_as`:
|
||||||
|
|
||||||
|
- `food` für Lebensmittel
|
||||||
|
- `shopping` für reine Einkaufsartikel
|
||||||
|
|
||||||
|
Alternativ kann der erste Endpoint mit `confirm_create: true` verwendet werden:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Blumenerde",
|
||||||
|
"create_as": "shopping",
|
||||||
|
"confirm_create": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## REST Commands
|
||||||
|
|
||||||
|
Trage in `configuration.yaml` ein:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
rest_command:
|
||||||
|
nouri_shopping:
|
||||||
|
url: "{{ url }}/api/home-assistant/shopping"
|
||||||
|
method: post
|
||||||
|
headers:
|
||||||
|
X-Nouri-Token: !secret nouri_ha_token
|
||||||
|
Content-Type: "application/json"
|
||||||
|
payload: >
|
||||||
|
{
|
||||||
|
"name": "{{ name }}",
|
||||||
|
"note": "{{ note | default('') }}",
|
||||||
|
"create_as": "{{ create_as | default('') }}",
|
||||||
|
"confirm_create": {{ confirm_create | default(false) | tojson }}
|
||||||
|
}
|
||||||
|
|
||||||
|
nouri_shopping_confirm:
|
||||||
|
url: "{{ url }}/api/home-assistant/shopping/confirm"
|
||||||
|
method: post
|
||||||
|
headers:
|
||||||
|
X-Nouri-Token: !secret nouri_ha_token
|
||||||
|
Content-Type: "application/json"
|
||||||
|
payload: >
|
||||||
|
{
|
||||||
|
"name": "{{ name }}",
|
||||||
|
"note": "{{ note | default('') }}",
|
||||||
|
"create_as": "{{ create_as }}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach Home Assistant neu starten oder die YAML-Konfiguration neu laden.
|
||||||
|
|
||||||
|
## Manuell testen
|
||||||
|
|
||||||
|
In Home Assistant:
|
||||||
|
|
||||||
|
1. Öffne `Entwicklerwerkzeuge`.
|
||||||
|
2. Öffne `Aktionen`.
|
||||||
|
3. Wähle `rest_command.nouri_shopping`.
|
||||||
|
4. Nutze zum Testen:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: !secret nouri_url
|
||||||
|
name: Erbsen
|
||||||
|
note: TK
|
||||||
|
```
|
||||||
|
|
||||||
|
Wenn `Erbsen` in Nouri existiert, wird es auf die Einkaufsliste gesetzt. Wenn nicht, antwortet Nouri mit `needs_confirmation`.
|
||||||
|
|
||||||
|
## Assist-Sprachsätze
|
||||||
|
|
||||||
|
Minimal in `configuration.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
conversation:
|
||||||
|
intents:
|
||||||
|
NouriShopping:
|
||||||
|
- "füge {name} auf die einkaufsliste hinzu"
|
||||||
|
- "füge {name} mit hinweis {note} auf die einkaufsliste hinzu"
|
||||||
|
- "füge {name} als lebensmittel hinzu"
|
||||||
|
- "füge {name} als einkaufsartikel hinzu"
|
||||||
|
```
|
||||||
|
|
||||||
|
Dazu ein Intent Script:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
intent_script:
|
||||||
|
NouriShopping:
|
||||||
|
action:
|
||||||
|
- action: rest_command.nouri_shopping
|
||||||
|
data:
|
||||||
|
url: !secret nouri_url
|
||||||
|
name: "{{ name }}"
|
||||||
|
note: "{{ note | default('') }}"
|
||||||
|
create_as: >
|
||||||
|
{% if 'lebensmittel' in text | default('') | lower %}food{% elif 'einkaufsartikel' in text | default('') | lower %}shopping{% else %}{% endif %}
|
||||||
|
confirm_create: false
|
||||||
|
response_variable: nouri_response
|
||||||
|
- stop: "Nouri Antwort"
|
||||||
|
response_variable: nouri_response
|
||||||
|
speech:
|
||||||
|
text: >
|
||||||
|
{{ action_response.content.message }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rückfrage-Logik
|
||||||
|
|
||||||
|
Der erste Schritt ist damit fertig: bekannte Lebensmittel oder Einkaufsartikel landen direkt auf der Nouri-Einkaufsliste.
|
||||||
|
|
||||||
|
Wenn Nouri mit `needs_confirmation` antwortet, muss Home Assistant im nächsten Schritt nachfragen:
|
||||||
|
|
||||||
|
> Soll ich das als Lebensmittel oder als Einkaufsartikel anlegen?
|
||||||
|
|
||||||
|
Danach ruft Home Assistant `/api/home-assistant/shopping/confirm` mit `create_as: food` oder `create_as: shopping` auf. Diese zweite Stufe kann je nach Setup über Assist, eine Automation, eine mobile Benachrichtigung oder später über Alexa umgesetzt werden.
|
||||||
@@ -47,6 +47,8 @@ Wichtige Umgebungsvariablen:
|
|||||||
- `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_SECURE_COOKIES`: bei HTTPS in Produktion auf `1` setzen
|
||||||
- `NOURI_TIMEZONE`: lokale Zeitzone, z. B. `Europe/Berlin`
|
- `NOURI_TIMEZONE`: lokale Zeitzone, z. B. `Europe/Berlin`
|
||||||
|
- `NOURI_HA_TOKEN`: optionaler Bearer Token fuer die Home-Assistant-API
|
||||||
|
- `NOURI_HA_USER_ID`: optionaler Nutzer, unter dem Home Assistant Eintraege anlegt
|
||||||
- `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`
|
||||||
@@ -102,6 +104,8 @@ Für eine saubere produktive Installation sind diese Werte sinnvoll:
|
|||||||
- `NOURI_SECURE_COOKIES=1`
|
- `NOURI_SECURE_COOKIES=1`
|
||||||
- `NOURI_TIMEZONE=Europe/Berlin`
|
- `NOURI_TIMEZONE=Europe/Berlin`
|
||||||
- `NOURI_SECRET_KEY=<eigenes-secret>`
|
- `NOURI_SECRET_KEY=<eigenes-secret>`
|
||||||
|
- `NOURI_HA_TOKEN=<eigenes-home-assistant-token>`
|
||||||
|
- `NOURI_HA_USER_ID=<optionale-nouri-user-id>`
|
||||||
- `NOURI_VAPID_PUBLIC_KEY=<public-key>`
|
- `NOURI_VAPID_PUBLIC_KEY=<public-key>`
|
||||||
- `NOURI_VAPID_PRIVATE_KEY=<private-key>`
|
- `NOURI_VAPID_PRIVATE_KEY=<private-key>`
|
||||||
- `NOURI_VAPID_SUBJECT=mailto:mail@hnz.io`
|
- `NOURI_VAPID_SUBJECT=mailto:mail@hnz.io`
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Nouri 1.3.4
|
||||||
|
|
||||||
|
Nouri 1.3.4 räumt die Mahlzeiten-Übersicht auf und bringt sie optisch näher an die ruhigeren Lebensmittel-Karten. Der Fokus liegt auf weniger sichtbaren Details, klarerem Scannen und dem Namen direkt unter dem Bild.
|
||||||
|
|
||||||
|
## Neu in 1.3.4
|
||||||
|
|
||||||
|
- Mahlzeitenkarten zeigen auf der Übersichtsseite jetzt zuerst Bild oder Platzhalter und darunter den Namen.
|
||||||
|
- Details wie Sichtbarkeit, Zielperson, Mahlzeitentyp, Energie und Tags liegen in einer Hover-Ansicht.
|
||||||
|
- Komponenten und Notizen werden ebenfalls erst beim Hover bzw. Fokus eingeblendet.
|
||||||
|
- Die sichtbaren Text-Buttons auf Mahlzeitenkarten wurden durch kompakte Icon-Aktionen ersetzt.
|
||||||
|
- Archivieren bleibt als kleiner Kreis oben rechts erreichbar.
|
||||||
|
- Tagesplan und Einkaufsliste sind als schnelle Icon-Aktionen oben links erreichbar.
|
||||||
|
|
||||||
|
## Bedienung
|
||||||
|
|
||||||
|
- Auf Desktop erscheinen Zusatzinfos beim Mouseover.
|
||||||
|
- Per Tastatur erscheinen Zusatzinfos, sobald eine Aktion auf der Karte fokussiert ist.
|
||||||
|
- Auf kleinen Bildschirmen bleibt die Übersicht bewusst reduziert, wie bei den Lebensmittelkarten.
|
||||||
|
|
||||||
|
## Betrieb
|
||||||
|
|
||||||
|
- Cloudron-Version und Upstream-Version stehen jetzt auf `1.3.4`.
|
||||||
|
- Die interne Schema-Version und der App-Version-Fallback wurden auf `1.3.4` angehoben.
|
||||||
|
- Der Service Worker nutzt einen neuen Cache-Namen für `1.3.4`.
|
||||||
+3
-1
@@ -93,7 +93,7 @@ def load_app_version(root_dir: Path) -> str:
|
|||||||
).strip()
|
).strip()
|
||||||
if manifest_version:
|
if manifest_version:
|
||||||
return manifest_version
|
return manifest_version
|
||||||
return "1.3.3"
|
return "1.3.4"
|
||||||
|
|
||||||
|
|
||||||
def load_release_url() -> str:
|
def load_release_url() -> str:
|
||||||
@@ -125,6 +125,8 @@ def create_app() -> Flask:
|
|||||||
APP_VERSION=app_version,
|
APP_VERSION=app_version,
|
||||||
RELEASE_URL=release_url,
|
RELEASE_URL=release_url,
|
||||||
TIMEZONE=os.environ.get("NOURI_TIMEZONE", "Europe/Berlin"),
|
TIMEZONE=os.environ.get("NOURI_TIMEZONE", "Europe/Berlin"),
|
||||||
|
HOME_ASSISTANT_TOKEN=os.environ.get("NOURI_HA_TOKEN", "").strip(),
|
||||||
|
HOME_ASSISTANT_USER_ID=os.environ.get("NOURI_HA_USER_ID", "").strip(),
|
||||||
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"),
|
||||||
|
|||||||
+6
-2
@@ -73,6 +73,10 @@ def url_with_scroll_position(url: str) -> str:
|
|||||||
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
||||||
|
|
||||||
|
|
||||||
|
def is_home_assistant_api_endpoint(endpoint: str) -> bool:
|
||||||
|
return endpoint.startswith("main.home_assistant_")
|
||||||
|
|
||||||
|
|
||||||
def normalize_login_value(raw: str) -> str:
|
def normalize_login_value(raw: str) -> str:
|
||||||
return raw.strip().lower()
|
return raw.strip().lower()
|
||||||
|
|
||||||
@@ -136,10 +140,10 @@ def load_logged_in_user():
|
|||||||
g.user = None
|
g.user = None
|
||||||
|
|
||||||
endpoint = request.endpoint or ""
|
endpoint = request.endpoint or ""
|
||||||
if user_count() == 0 and endpoint not in {"auth.setup", "static", "uploaded_file"}:
|
if user_count() == 0 and endpoint not in {"auth.setup", "static", "uploaded_file"} and not is_home_assistant_api_endpoint(endpoint):
|
||||||
return redirect(url_for("auth.setup"))
|
return redirect(url_for("auth.setup"))
|
||||||
|
|
||||||
if request.method == "POST" and endpoint != "static":
|
if request.method == "POST" and endpoint != "static" and not is_home_assistant_api_endpoint(endpoint):
|
||||||
token = session.get("_csrf_token")
|
token = session.get("_csrf_token")
|
||||||
form_token = request.form.get("csrf_token")
|
form_token = request.form.get("csrf_token")
|
||||||
if not token or token != form_token:
|
if not token or token != form_token:
|
||||||
|
|||||||
+5
-1
@@ -77,6 +77,7 @@ def export_backup_archive(
|
|||||||
|
|
||||||
def _extract_uploads_to_temp(archive: zipfile.ZipFile) -> Path:
|
def _extract_uploads_to_temp(archive: zipfile.ZipFile) -> Path:
|
||||||
temp_dir = Path(tempfile.mkdtemp(prefix="nouri-restore-uploads-"))
|
temp_dir = Path(tempfile.mkdtemp(prefix="nouri-restore-uploads-"))
|
||||||
|
temp_root = temp_dir.resolve()
|
||||||
for member in archive.infolist():
|
for member in archive.infolist():
|
||||||
if not member.filename.startswith("uploads/") or member.is_dir():
|
if not member.filename.startswith("uploads/") or member.is_dir():
|
||||||
continue
|
continue
|
||||||
@@ -84,8 +85,11 @@ def _extract_uploads_to_temp(archive: zipfile.ZipFile) -> Path:
|
|||||||
if not relative_target:
|
if not relative_target:
|
||||||
continue
|
continue
|
||||||
target_path = temp_dir / relative_target
|
target_path = temp_dir / relative_target
|
||||||
|
resolved_target = target_path.resolve()
|
||||||
|
if not resolved_target.is_relative_to(temp_root):
|
||||||
|
raise ValueError("Das Backup enthält einen ungültigen Upload-Pfad.")
|
||||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with archive.open(member, "r") as source, target_path.open("wb") as destination:
|
with archive.open(member, "r") as source, resolved_target.open("wb") as destination:
|
||||||
shutil.copyfileobj(source, destination)
|
shutil.copyfileobj(source, destination)
|
||||||
return temp_dir
|
return temp_dir
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ from .constants import (
|
|||||||
DEFAULT_CATEGORY_BUILDERS,
|
DEFAULT_CATEGORY_BUILDERS,
|
||||||
)
|
)
|
||||||
|
|
||||||
CURRENT_SCHEMA_VERSION = "1.3.3"
|
CURRENT_SCHEMA_VERSION = "1.3.4"
|
||||||
|
|
||||||
ANIMAL_HINTS = (
|
ANIMAL_HINTS = (
|
||||||
"huhn",
|
"huhn",
|
||||||
|
|||||||
+218
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
|
import functools
|
||||||
|
import secrets
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from itertools import product
|
from itertools import product
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -1009,6 +1011,143 @@ def normalize_new_item_name(value: str | None) -> str:
|
|||||||
return " ".join((value or "").strip().split())[:120]
|
return " ".join((value or "").strip().split())[:120]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_home_assistant_create_kind(value: str | None) -> str | None:
|
||||||
|
normalized = (value or "").strip().lower()
|
||||||
|
if normalized in {"food", "lebensmittel"}:
|
||||||
|
return "food"
|
||||||
|
if normalized in {"shopping", "article", "artikel", "einkaufsartikel"}:
|
||||||
|
return "shopping"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def home_assistant_api_user():
|
||||||
|
configured_user_id = current_app.config.get("HOME_ASSISTANT_USER_ID", "")
|
||||||
|
if str(configured_user_id).isdigit():
|
||||||
|
return get_db().execute(
|
||||||
|
"""
|
||||||
|
SELECT users.*,
|
||||||
|
households.name AS household_name
|
||||||
|
FROM users
|
||||||
|
LEFT JOIN households ON households.id = users.household_id
|
||||||
|
WHERE users.is_active = 1
|
||||||
|
AND users.id = ?
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(int(configured_user_id),),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
row = get_db().execute(
|
||||||
|
"""
|
||||||
|
SELECT users.*,
|
||||||
|
households.name AS household_name
|
||||||
|
FROM users
|
||||||
|
LEFT JOIN households ON households.id = users.household_id
|
||||||
|
WHERE users.is_active = 1
|
||||||
|
AND users.role = 'admin'
|
||||||
|
ORDER BY users.id
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
).fetchone()
|
||||||
|
if row is not None:
|
||||||
|
return row
|
||||||
|
|
||||||
|
return get_db().execute(
|
||||||
|
"""
|
||||||
|
SELECT users.*,
|
||||||
|
households.name AS household_name
|
||||||
|
FROM users
|
||||||
|
LEFT JOIN households ON households.id = users.household_id
|
||||||
|
WHERE users.is_active = 1
|
||||||
|
ORDER BY users.id
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def home_assistant_api_required(view):
|
||||||
|
@functools.wraps(view)
|
||||||
|
def wrapped_view(*args, **kwargs):
|
||||||
|
configured_token = current_app.config.get("HOME_ASSISTANT_TOKEN", "")
|
||||||
|
if not configured_token:
|
||||||
|
return jsonify({
|
||||||
|
"ok": False,
|
||||||
|
"status": "not_configured",
|
||||||
|
"message": "Home Assistant ist in Nouri noch nicht konfiguriert.",
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
auth_header = request.headers.get("Authorization", "")
|
||||||
|
bearer_token = auth_header.removeprefix("Bearer ").strip() if auth_header.startswith("Bearer ") else ""
|
||||||
|
request_token = bearer_token or request.headers.get("X-Nouri-Token", "").strip()
|
||||||
|
if not request_token or not secrets.compare_digest(request_token, configured_token):
|
||||||
|
return jsonify({
|
||||||
|
"ok": False,
|
||||||
|
"status": "unauthorized",
|
||||||
|
"message": "Der Home-Assistant-Token passt nicht.",
|
||||||
|
}), 401
|
||||||
|
|
||||||
|
api_user = home_assistant_api_user()
|
||||||
|
if api_user is None:
|
||||||
|
return jsonify({
|
||||||
|
"ok": False,
|
||||||
|
"status": "no_user",
|
||||||
|
"message": "In Nouri gibt es noch keinen aktiven Nutzer fuer Home Assistant.",
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
g.user = api_user
|
||||||
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapped_view
|
||||||
|
|
||||||
|
|
||||||
|
def home_assistant_payload() -> dict:
|
||||||
|
payload = request.get_json(silent=True)
|
||||||
|
return payload if isinstance(payload, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def home_assistant_truthy(value) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
return str(value).strip().lower() in {"1", "true", "yes", "ja", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def item_api_payload(item: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"id": item["id"],
|
||||||
|
"name": item["name"],
|
||||||
|
"kind": item["kind"],
|
||||||
|
"visibility": item["visibility"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def shopping_api_message(status: str, item_name: str, note: str = "") -> str:
|
||||||
|
note_suffix = f" mit Hinweis {note}" if note else ""
|
||||||
|
if status == "added":
|
||||||
|
return f"{item_name} wurde{note_suffix} auf die Einkaufsliste gesetzt."
|
||||||
|
if status == "duplicate":
|
||||||
|
return f"{item_name} steht{note_suffix} schon auf der Einkaufsliste."
|
||||||
|
return f"{item_name} wurde verarbeitet."
|
||||||
|
|
||||||
|
|
||||||
|
def add_item_to_shopping_api(item: dict, note: str) -> dict:
|
||||||
|
result = ensure_item_or_missing_components_are_shopped(
|
||||||
|
item["id"],
|
||||||
|
g.user["id"],
|
||||||
|
item["visibility"],
|
||||||
|
shopping_note=note,
|
||||||
|
)
|
||||||
|
status = "added" if result["count"] else "duplicate"
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"status": status,
|
||||||
|
"item": item_api_payload(item),
|
||||||
|
"note": note,
|
||||||
|
"result": result,
|
||||||
|
"message": shopping_api_message(status, item["name"], note),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def schedule_shopping_need(
|
def schedule_shopping_need(
|
||||||
*,
|
*,
|
||||||
item_id: int,
|
item_id: int,
|
||||||
@@ -4588,6 +4727,85 @@ def item_remove_from_shopping(item_id: int):
|
|||||||
return redirect(request.referrer or url_for("main.shopping_list"))
|
return redirect(request.referrer or url_for("main.shopping_list"))
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.post("/api/home-assistant/shopping")
|
||||||
|
@home_assistant_api_required
|
||||||
|
def home_assistant_shopping():
|
||||||
|
payload = home_assistant_payload()
|
||||||
|
item_name = normalize_new_item_name(payload.get("name") or payload.get("item") or payload.get("query"))
|
||||||
|
note = normalize_shopping_note(payload.get("note") or payload.get("shopping_note"))
|
||||||
|
create_as = normalize_home_assistant_create_kind(payload.get("create_as") or payload.get("kind"))
|
||||||
|
confirm_create = home_assistant_truthy(payload.get("confirm_create"))
|
||||||
|
|
||||||
|
if not item_name:
|
||||||
|
return jsonify({
|
||||||
|
"ok": False,
|
||||||
|
"status": "missing_name",
|
||||||
|
"message": "Bitte einen Namen fuer den Einkaufswunsch uebergeben.",
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
item = find_shopping_item_by_name(item_name)
|
||||||
|
if item is not None:
|
||||||
|
return jsonify(add_item_to_shopping_api(item, note))
|
||||||
|
|
||||||
|
if confirm_create:
|
||||||
|
if create_as is None:
|
||||||
|
return jsonify({
|
||||||
|
"ok": False,
|
||||||
|
"status": "missing_create_kind",
|
||||||
|
"message": "Bitte bestaetige, ob der Eintrag als Lebensmittel oder Einkaufsartikel angelegt werden soll.",
|
||||||
|
"name": item_name,
|
||||||
|
"note": note,
|
||||||
|
"options": ["food", "shopping"],
|
||||||
|
}), 400
|
||||||
|
existing_before_create = find_shopping_item_by_name(item_name)
|
||||||
|
item = create_shopping_search_item(item_name, create_as)
|
||||||
|
response = add_item_to_shopping_api(item, note)
|
||||||
|
response["created"] = existing_before_create is None
|
||||||
|
return jsonify(response)
|
||||||
|
|
||||||
|
suggested_kind = create_as or "shopping"
|
||||||
|
return jsonify({
|
||||||
|
"ok": True,
|
||||||
|
"status": "needs_confirmation",
|
||||||
|
"name": item_name,
|
||||||
|
"note": note,
|
||||||
|
"suggested_create_as": suggested_kind,
|
||||||
|
"options": ["food", "shopping"],
|
||||||
|
"message": f"{item_name} kenne ich noch nicht. Soll ich es als Lebensmittel oder Einkaufsartikel anlegen?",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.post("/api/home-assistant/shopping/confirm")
|
||||||
|
@home_assistant_api_required
|
||||||
|
def home_assistant_shopping_confirm():
|
||||||
|
payload = home_assistant_payload()
|
||||||
|
item_name = normalize_new_item_name(payload.get("name") or payload.get("item") or payload.get("query"))
|
||||||
|
note = normalize_shopping_note(payload.get("note") or payload.get("shopping_note"))
|
||||||
|
create_as = normalize_home_assistant_create_kind(payload.get("create_as") or payload.get("kind"))
|
||||||
|
|
||||||
|
if not item_name:
|
||||||
|
return jsonify({
|
||||||
|
"ok": False,
|
||||||
|
"status": "missing_name",
|
||||||
|
"message": "Bitte einen Namen fuer den Einkaufswunsch uebergeben.",
|
||||||
|
}), 400
|
||||||
|
if create_as is None:
|
||||||
|
return jsonify({
|
||||||
|
"ok": False,
|
||||||
|
"status": "missing_create_kind",
|
||||||
|
"message": "Bitte bestaetige, ob der Eintrag als Lebensmittel oder Einkaufsartikel angelegt werden soll.",
|
||||||
|
"name": item_name,
|
||||||
|
"note": note,
|
||||||
|
"options": ["food", "shopping"],
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
existing_before_create = find_shopping_item_by_name(item_name)
|
||||||
|
item = create_shopping_search_item(item_name, create_as)
|
||||||
|
response = add_item_to_shopping_api(item, note)
|
||||||
|
response["created"] = existing_before_create is None
|
||||||
|
return jsonify(response)
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route("/shopping", methods=("GET", "POST"))
|
@main_bp.route("/shopping", methods=("GET", "POST"))
|
||||||
@login_required
|
@login_required
|
||||||
def shopping_list():
|
def shopping_list():
|
||||||
|
|||||||
@@ -1236,7 +1236,8 @@ h3 {
|
|||||||
border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.16) 42%);
|
border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.16) 42%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card-food {
|
.item-card-food,
|
||||||
|
.item-card-meal {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
@@ -1248,20 +1249,23 @@ h3 {
|
|||||||
opacity: 0.72;
|
opacity: 0.72;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card-food .item-media-food {
|
.item-card-food .item-media-food,
|
||||||
|
.item-card-meal .item-media-meal {
|
||||||
width: min(100%, 170px);
|
width: min(100%, 170px);
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card-food .item-body-food {
|
.item-card-food .item-body-food,
|
||||||
|
.item-card-meal .item-body-meal {
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card-food .item-body-food h2 {
|
.item-card-food .item-body-food h2,
|
||||||
|
.item-card-meal .item-body-meal h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.9rem;
|
font-size: 1.9rem;
|
||||||
line-height: 1.08;
|
line-height: 1.08;
|
||||||
@@ -1330,6 +1334,10 @@ h3 {
|
|||||||
box-shadow: 0 10px 22px rgba(70, 48, 34, 0.12);
|
box-shadow: 0 10px 22px rgba(70, 48, 34, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item-card-icon-link {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.item-card-icon-button:hover {
|
.item-card-icon-button:hover {
|
||||||
background: color-mix(in srgb, var(--accent) 78%, #fff 22%);
|
background: color-mix(in srgb, var(--accent) 78%, #fff 22%);
|
||||||
color: #201a17;
|
color: #201a17;
|
||||||
@@ -1397,7 +1405,9 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.item-card-food:hover .item-card-hover-meta,
|
.item-card-food:hover .item-card-hover-meta,
|
||||||
.item-card-food:focus-within .item-card-hover-meta {
|
.item-card-food:focus-within .item-card-hover-meta,
|
||||||
|
.item-card-meal:hover .item-card-hover-meta,
|
||||||
|
.item-card-meal:focus-within .item-card-hover-meta {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
@@ -3613,11 +3623,13 @@ legend {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card-food {
|
.item-card-food,
|
||||||
|
.item-card-meal {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card-food .item-media-food {
|
.item-card-food .item-media-food,
|
||||||
|
.item-card-meal .item-media-meal {
|
||||||
width: min(100%, 156px);
|
width: min(100%, 156px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = "nouri-v1-3-3";
|
const CACHE_NAME = "nouri-v1-3-4";
|
||||||
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",
|
||||||
|
|||||||
@@ -162,8 +162,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% else %}
|
{% else %}
|
||||||
<article class="item-card">
|
<article class="item-card item-card-meal">
|
||||||
<div class="item-media">
|
{% if item.can_edit %}
|
||||||
|
<a class="item-card-cover-link" href="{{ url_for('main.item_edit', item_id=item.id) }}">
|
||||||
|
<span class="sr-only">{{ item.name }} bearbeiten</span>
|
||||||
|
</a>
|
||||||
|
<form
|
||||||
|
class="item-card-archive-form"
|
||||||
|
method="post"
|
||||||
|
action="{{ url_for('main.item_archive', item_id=item.id) }}"
|
||||||
|
onsubmit="return confirm('Willst du diese Mahlzeitenidee wirklich archivieren?');"
|
||||||
|
>
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button class="item-card-archive-button" type="submit" aria-label="{{ item.name }} archivieren">×</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<div class="item-card-quick-actions">
|
||||||
|
<a
|
||||||
|
class="item-card-icon-button item-card-icon-link"
|
||||||
|
href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}"
|
||||||
|
aria-label="{{ item.name }} im Tagesplan öffnen"
|
||||||
|
title="Im Tagesplan öffnen"
|
||||||
|
>
|
||||||
|
<span class="ui-icon icon-calendar"></span>
|
||||||
|
</a>
|
||||||
|
<form method="post" action="{{ url_for('main.item_remove_from_shopping' if item.is_on_shopping_list else 'main.item_add_to_shopping', item_id=item.id) }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button
|
||||||
|
class="item-card-icon-button {% if item.is_on_shopping_list %}is-active{% else %}is-inactive{% endif %}"
|
||||||
|
type="submit"
|
||||||
|
aria-label="{% if item.is_on_shopping_list %}{{ item.name }} von der Einkaufsliste entfernen{% else %}{{ item.name }} auf die Einkaufsliste setzen{% endif %}"
|
||||||
|
title="{% if item.is_on_shopping_list %}Auf Einkaufsliste{% else %}Nicht auf Einkaufsliste{% endif %}"
|
||||||
|
>
|
||||||
|
<span class="ui-icon icon-cart-shopping"></span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-media item-media-meal">
|
||||||
{% if item.photo_filename %}
|
{% if item.photo_filename %}
|
||||||
<img
|
<img
|
||||||
src="{{ image_url(item.photo_filename, 'md') }}"
|
src="{{ image_url(item.photo_filename, 'md') }}"
|
||||||
@@ -175,7 +211,12 @@
|
|||||||
<div class="placeholder-tile">{{ item.name[:1] }}</div>
|
<div class="placeholder-tile">{{ item.name[:1] }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="item-body">
|
|
||||||
|
<div class="item-body item-body-meal">
|
||||||
|
<h2>{{ item.name }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-card-hover-meta" aria-hidden="true">
|
||||||
<div class="chip-row">
|
<div class="chip-row">
|
||||||
<span class="chip">{{ item.visibility_label }}</span>
|
<span class="chip">{{ item.visibility_label }}</span>
|
||||||
<span class="chip status-soft">{{ item.owner_label }}</span>
|
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||||
@@ -202,22 +243,6 @@
|
|||||||
<p>{{ item.note }}</p>
|
<p>{{ item.note }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="item-actions">
|
|
||||||
{% if item.can_edit %}
|
|
||||||
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=item.id) }}">Bearbeiten</a>
|
|
||||||
{% endif %}
|
|
||||||
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
|
|
||||||
<form class="primary-action" method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
|
||||||
{{ csrf_input() }}
|
|
||||||
<button type="submit">Auf Einkaufsliste</button>
|
|
||||||
</form>
|
|
||||||
{% if item.can_edit %}
|
|
||||||
<form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}">
|
|
||||||
{{ csrf_input() }}
|
|
||||||
<button class="ghost-button" type="submit">Archivieren</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
Flask==3.1.1
|
Flask==3.1.1
|
||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
pywebpush==2.3.0
|
pywebpush==2.3.0
|
||||||
Pillow==11.2.1; python_version < "3.14"
|
Pillow==12.2.0; python_version < "3.14"
|
||||||
fpdf2==2.8.3
|
fpdf2==2.8.3
|
||||||
|
|||||||
Reference in New Issue
Block a user