5 Commits

Author SHA1 Message Date
hnzio 1839328dfe Harden local secrets and backup restore 2026-06-05 16:23:38 +02:00
hnzio 01b8128835 Add Home Assistant shopping API 2026-05-03 14:43:00 +02:00
hnzio 4babf93179 Release Nouri 1.3.4 with quieter meal cards 2026-05-01 14:38:54 +02:00
hnzio aff40eff49 Release Nouri 1.3.3 with shopping articles 2026-05-01 14:32:30 +02:00
hnzio 6b2c495cf2 Add food card quick status actions 2026-04-29 12:43:23 +02:00
21 changed files with 1028 additions and 70 deletions
+4
View File
@@ -12,3 +12,7 @@ dist
build
data
instance
.cloudron-push.env
.env.local
.env.push.local
nouri.sqlite3
+1
View File
@@ -9,6 +9,7 @@ __pycache__/
data/
instance/
nouri.sqlite3
.cloudron-push.env
.env.local
.env.push.local
+2 -2
View File
@@ -4,8 +4,8 @@
"author": "Florian Heinz",
"description": "Private Flask app for meals, shopping and gentle food planning",
"tagline": "einfach essen planen",
"version": "1.3.2",
"upstreamVersion": "1.3.2",
"version": "1.3.4",
"upstreamVersion": "1.3.4",
"healthCheckPath": "/",
"httpPort": 8000,
"manifestVersion": 2,
+210
View File
@@ -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.
+4
View File
@@ -47,6 +47,8 @@ Wichtige Umgebungsvariablen:
- `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_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_PRIVATE_KEY`: privater VAPID-Schlüssel für Web Push
- `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_TIMEZONE=Europe/Berlin`
- `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_PRIVATE_KEY=<private-key>`
- `NOURI_VAPID_SUBJECT=mailto:mail@hnz.io`
+38
View File
@@ -0,0 +1,38 @@
# Nouri 1.3.3
Nouri 1.3.3 erweitert die Einkaufsliste um freie Einkaufsartikel, ohne die bestehende Lebensmittel- und Rezeptlogik umzubauen. Der Fokus liegt darauf, Alltagsdinge wie Drogerie, Haushalt oder Garten genauso schnell auf die Liste setzen zu können wie Lebensmittel.
## Neu in 1.3.3
- Die Einkaufssuche kann jetzt Lebensmittel und allgemeine Artikel finden.
- Neue Artikel können direkt aus dem Suchbegriff angelegt werden:
- `Als Lebensmittel anlegen`
- `Als Einkaufsartikel anlegen`
- Einkaufsartikel wie `Blumenerde`, `Deo`, `Insektenschutz` oder `Sonnencreme` werden intern gespeichert.
- Reine Einkaufsartikel bleiben aus Mahlzeiten, Rezeptvorschlägen und Lebensmittel-Details heraus.
- Nicht vorhandene Lebensmittel können aus der Einkaufsliste heraus schnell als unsortiertes Lebensmittel angelegt werden.
## Einkaufsliste
- Bereits angelegte Einkaufsartikel erscheinen bei späteren Suchen wieder als Treffer.
- Einkaufshinweise wie `TK`, `Dose`, `frisch` oder andere kurze Notizen funktionieren weiterhin.
- Derselbe Artikel kann mit unterschiedlichen Einkaufshinweisen mehrfach auf der Liste stehen.
- Einkaufsartikel werden auf der Liste als `Einkaufsartikel` markiert und nutzen ein Einkaufswagen-Symbol.
- Beim Abhaken eines Einkaufsartikels wird er als eingekauft markiert, ohne ihn als zuhause vorhandenes Lebensmittel zu behandeln.
## Daten und Migration
- Das Items-Schema unterstützt jetzt zusätzlich den internen Typ `shopping`.
- Bestehende Datenbanken werden beim Start migriert, damit der neue Typ auch bei Updates funktioniert.
- Der Index für Items nach Typ und Name wird bei Schema-Upgrades sauber wieder angelegt.
## Betrieb
- Cloudron-Version und Upstream-Version stehen jetzt auf `1.3.3`.
- Die interne Schema-Version und der App-Version-Fallback wurden auf `1.3.3` angehoben.
- Der Service Worker nutzt einen neuen Cache-Namen für `1.3.3`.
## Upgrade-Hinweis
- Bestehende Lebensmittel, Mahlzeitenideen und Einkaufseinträge bleiben erhalten.
- Nach dem Update können freie Einkaufsartikel direkt unter `Einkauf` über das Suchfeld angelegt werden.
+24
View File
@@ -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`.
View File
+3 -1
View File
@@ -93,7 +93,7 @@ def load_app_version(root_dir: Path) -> str:
).strip()
if manifest_version:
return manifest_version
return "1.3.2"
return "1.3.4"
def load_release_url() -> str:
@@ -125,6 +125,8 @@ def create_app() -> Flask:
APP_VERSION=app_version,
RELEASE_URL=release_url,
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_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""),
VAPID_SUBJECT=os.environ.get("NOURI_VAPID_SUBJECT", "mailto:mail@hnz.io"),
+6 -2
View File
@@ -73,6 +73,10 @@ def url_with_scroll_position(url: str) -> str:
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:
return raw.strip().lower()
@@ -136,10 +140,10 @@ def load_logged_in_user():
g.user = None
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"))
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")
form_token = request.form.get("csrf_token")
if not token or token != form_token:
+5 -1
View File
@@ -77,6 +77,7 @@ def export_backup_archive(
def _extract_uploads_to_temp(archive: zipfile.ZipFile) -> Path:
temp_dir = Path(tempfile.mkdtemp(prefix="nouri-restore-uploads-"))
temp_root = temp_dir.resolve()
for member in archive.infolist():
if not member.filename.startswith("uploads/") or member.is_dir():
continue
@@ -84,8 +85,11 @@ def _extract_uploads_to_temp(archive: zipfile.ZipFile) -> Path:
if not relative_target:
continue
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)
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)
return temp_dir
+111 -1
View File
@@ -15,7 +15,7 @@ from .constants import (
DEFAULT_CATEGORY_BUILDERS,
)
CURRENT_SCHEMA_VERSION = "1.3.2"
CURRENT_SCHEMA_VERSION = "1.3.4"
ANIMAL_HINTS = (
"huhn",
@@ -423,6 +423,109 @@ def set_meta(database: sqlite3.Connection, key: str, value: str) -> None:
)
def item_kind_constraint_supports_shopping(database: sqlite3.Connection) -> bool:
row = database.execute(
"""
SELECT sql
FROM sqlite_master
WHERE type = 'table' AND name = 'items'
"""
).fetchone()
return bool(row and row["sql"] and "'shopping'" in row["sql"])
def migrate_items_kind_constraint(database: sqlite3.Connection) -> None:
if not table_exists(database, "items") or item_kind_constraint_supports_shopping(database):
return
columns = table_columns(database, "items")
if "kind" not in columns:
return
foreign_keys_enabled = bool(database.execute("PRAGMA foreign_keys").fetchone()[0])
if foreign_keys_enabled:
database.execute("PRAGMA foreign_keys = OFF")
try:
database.execute("DROP TABLE IF EXISTS items_new")
database.execute(
"""
CREATE TABLE items_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER,
owner_user_id INTEGER,
target_user_id INTEGER,
visibility TEXT NOT NULL DEFAULT 'shared',
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal', 'shopping')),
name TEXT NOT NULL,
category TEXT,
base_type TEXT NOT NULL DEFAULT 'neutral',
flavor_profile TEXT NOT NULL DEFAULT 'neutral',
suggestion_role TEXT NOT NULL DEFAULT 'base',
suggestion_priority TEXT NOT NULL DEFAULT 'normal',
can_be_meal_core INTEGER NOT NULL DEFAULT 0,
meal_type TEXT,
meal_tags TEXT NOT NULL DEFAULT '',
energy_density TEXT NOT NULL DEFAULT 'neutral',
note TEXT,
photo_filename TEXT,
availability_state TEXT NOT NULL DEFAULT 'idea' CHECK (availability_state IN ('idea', 'home', 'archived')),
is_archived INTEGER NOT NULL DEFAULT 0,
is_quick_added INTEGER NOT NULL DEFAULT 0,
created_by INTEGER,
updated_by INTEGER,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
)
"""
)
target_columns = [
"id",
"household_id",
"owner_user_id",
"target_user_id",
"visibility",
"kind",
"name",
"category",
"base_type",
"flavor_profile",
"suggestion_role",
"suggestion_priority",
"can_be_meal_core",
"meal_type",
"meal_tags",
"energy_density",
"note",
"photo_filename",
"availability_state",
"is_archived",
"is_quick_added",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
copy_columns = [column for column in target_columns if column in columns]
quoted_columns = ", ".join(copy_columns)
database.execute(
f"""
INSERT INTO items_new ({quoted_columns})
SELECT {quoted_columns}
FROM items
"""
)
database.execute("DROP TABLE items")
database.execute("ALTER TABLE items_new RENAME TO items")
finally:
if foreign_keys_enabled:
database.execute("PRAGMA foreign_keys = ON")
def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
ensure_meta_table(database)
database.execute(
@@ -739,6 +842,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
add_column_if_missing(database, "items", "is_archived INTEGER NOT NULL DEFAULT 0")
add_column_if_missing(database, "items", "is_quick_added INTEGER NOT NULL DEFAULT 0")
migrate_items_kind_constraint(database)
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", "shopping_note TEXT NOT NULL DEFAULT ''")
@@ -821,6 +925,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
WHERE email IS NOT NULL AND email != ''
"""
)
database.execute(
"""
CREATE INDEX IF NOT EXISTS idx_items_kind_name
ON items (kind, name)
"""
)
database.execute(
"""
CREATE INDEX IF NOT EXISTS idx_items_household_visibility
+306 -9
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
from collections import defaultdict
from datetime import date, datetime, timedelta
import functools
import secrets
from io import BytesIO
from itertools import product
from pathlib import Path
@@ -57,7 +59,7 @@ from .constants import (
WEEKDAY_OPTIONS,
WEEK_TEMPLATE_NAME_SUGGESTIONS,
)
from .db import get_db
from .db import get_db, infer_food_flavor_profile, infer_food_profile
from .images import (
allowed_image_file,
save_photo_with_variants,
@@ -1005,6 +1007,147 @@ def normalize_shopping_note(value: str | None) -> str:
return " ".join((value or "").strip().split())[:80]
def normalize_new_item_name(value: str | None) -> str:
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(
*,
item_id: int,
@@ -1298,8 +1441,8 @@ def fetch_items_by_ids(item_ids: list[int]) -> list[dict]:
return [items_by_id[item_id] for item_id in normalized_ids if item_id in items_by_id]
def find_shopping_food_by_name(name: str) -> dict | None:
normalized_name = name.strip().lower()
def find_shopping_item_by_name(name: str) -> dict | None:
normalized_name = normalize_new_item_name(name).lower()
if not normalized_name:
return None
row = get_db().execute(
@@ -1317,11 +1460,11 @@ def find_shopping_food_by_name(name: str) -> dict | None:
FROM items
LEFT JOIN users AS owner ON owner.id = items.owner_user_id
LEFT JOIN users AS target ON target.id = items.target_user_id
WHERE items.kind = 'food'
WHERE items.kind IN ('food', 'shopping')
AND items.is_archived = 0
AND LOWER(items.name) = ?
AND {visible_clause('items')}
ORDER BY LOWER(items.name), items.id
ORDER BY CASE items.kind WHEN 'food' THEN 0 ELSE 1 END, LOWER(items.name), items.id
LIMIT 1
""",
[normalized_name, *visible_params()],
@@ -1331,6 +1474,64 @@ def find_shopping_food_by_name(name: str) -> dict | None:
return attach_builder_keys(attach_dayparts(describe_records([row])))[0]
def create_shopping_search_item(name: str, kind: str) -> dict:
normalized_name = normalize_new_item_name(name)
if not normalized_name:
raise ValueError("Bitte gib zuerst einen Namen ein.")
if kind not in {"food", "shopping"}:
raise ValueError("Bitte wähle aus, ob es ein Lebensmittel oder ein Einkaufsartikel ist.")
existing = find_shopping_item_by_name(normalized_name)
if existing is not None:
return existing
if kind == "food":
profile = infer_food_profile(normalized_name, "Unsortiert", "neutral")
category = "Unsortiert"
note = "Aus der Einkaufssuche angelegt. Details später ergänzen."
is_quick_added = 1
else:
profile = {
"base_type": "neutral",
"suggestion_role": "cooking",
"suggestion_priority": "never",
"can_be_meal_core": 0,
}
category = "Einkaufsartikel"
note = "Einkaufsartikel ohne Rezeptlogik."
is_quick_added = 0
cursor = get_db().execute(
"""
INSERT INTO items (
household_id, owner_user_id, visibility, kind, name, category,
base_type, flavor_profile, suggestion_role, suggestion_priority,
can_be_meal_core, energy_density, availability_state, note,
is_quick_added, created_by, updated_by
)
VALUES (?, ?, 'shared', ?, ?, ?, ?, ?, ?, ?, ?, 'neutral', 'idea', ?, ?, ?, ?)
""",
(
current_household_id(),
g.user["id"],
kind,
normalized_name,
category,
profile["base_type"],
infer_food_flavor_profile(normalized_name, category, profile["base_type"], profile["suggestion_role"]),
profile["suggestion_role"],
profile["suggestion_priority"],
profile["can_be_meal_core"],
note,
is_quick_added,
g.user["id"],
g.user["id"],
),
)
get_db().commit()
return get_item(int(cursor.lastrowid))
def fetch_shopping_entries():
rows = get_db().execute(
f"""
@@ -4390,6 +4591,7 @@ def mark_shopping_entry_checked(entry_id: int) -> dict:
"UPDATE shopping_entries SET is_checked = 1, checked_at = CURRENT_TIMESTAMP, checked_by = ? WHERE id = ?",
(g.user["id"], entry_id),
)
if item["kind"] != "shopping":
get_db().execute(
"""
UPDATE items
@@ -4495,6 +4697,9 @@ def item_mark_bought(item_id: int):
except (ValueError, PermissionError) as exc:
flash(str(exc), "error")
return redirect(request.referrer or url_for("main.shopping_list"))
if item["kind"] == "shopping":
flash(f"{item['name']} wurde als eingekauft markiert.", "success")
else:
flash(f"{item['name']} ist jetzt als Zuhause vorhanden markiert.", "success")
return redirect(request.referrer or url_for("main.shopping_list"))
@@ -4522,23 +4727,109 @@ def item_remove_from_shopping(item_id: int):
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"))
@login_required
def shopping_list():
if request.method == "POST":
selected_item_id = request.form.get("item_id", "").strip()
item_search = request.form.get("item_search", "").strip()
create_as = request.form.get("create_as", "").strip()
create_item_name = normalize_new_item_name(request.form.get("create_item_name") or item_search)
shopping_note = normalize_shopping_note(request.form.get("shopping_note"))
item = None
if selected_item_id.isdigit():
if create_as in {"food", "shopping"}:
try:
item = create_shopping_search_item(create_item_name, create_as)
except ValueError as exc:
flash(str(exc), "error")
elif selected_item_id.isdigit():
try:
item = get_item(int(selected_item_id))
except ValueError as exc:
flash(str(exc), "error")
elif item_search:
item = find_shopping_food_by_name(item_search)
item = find_shopping_item_by_name(item_search)
if item is None:
flash("Bitte ein Lebensmittel aus der Suche auswählen.", "error")
flash("Bitte einen Treffer auswählen oder den Begriff als Lebensmittel bzw. Einkaufsartikel anlegen.", "error")
else:
flash("Bitte zuerst etwas auswählen.", "error")
@@ -4560,7 +4851,10 @@ def shopping_list():
entries = fetch_shopping_entries()
upcoming_entries = fetch_upcoming_shopping_needs()
addable_items = fetch_items(kind="food", include_archived=False, include_quick_added=True)
addable_items = [
item for item in fetch_items(include_archived=False, include_quick_added=True)
if item["kind"] in {"food", "shopping"}
]
household_settings = get_household_settings()
shopping_weekday_label = dict(WEEKDAY_OPTIONS).get(household_settings["shopping_weekday"], "gesetzt")
return render_template(
@@ -4581,6 +4875,9 @@ def shopping_check(entry_id: int):
except (ValueError, PermissionError) as exc:
flash(str(exc), "error")
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
if item["kind"] == "shopping":
flash(f"{item['name']} wurde als eingekauft markiert.", "success")
else:
flash(f"{item['name']} ist jetzt als Zuhause vorhanden markiert.", "success")
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
+1 -1
View File
@@ -118,7 +118,7 @@ CREATE TABLE IF NOT EXISTS items (
owner_user_id INTEGER,
target_user_id INTEGER,
visibility TEXT NOT NULL DEFAULT 'shared',
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')),
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal', 'shopping')),
name TEXT NOT NULL,
category TEXT,
base_type TEXT NOT NULL DEFAULT 'neutral',
+174 -7
View File
@@ -1082,6 +1082,16 @@ h3 {
color: #ece8e4;
}
.status-missing {
background: rgba(210, 125, 115, 0.18);
color: color-mix(in srgb, #9f4339 72%, var(--text) 28%);
}
[data-theme="dark"] .status-missing {
background: rgba(210, 125, 115, 0.22);
color: #f2d0ca;
}
.status-unsorted {
background: rgba(184, 161, 108, 0.18);
}
@@ -1095,6 +1105,10 @@ h3 {
background: var(--lilac-soft);
}
.status-shopping {
background: color-mix(in srgb, var(--mint-soft) 70%, var(--sky-soft) 30%);
}
.item-card {
position: relative;
display: grid;
@@ -1222,7 +1236,8 @@ h3 {
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;
gap: 0.9rem;
align-content: start;
@@ -1234,20 +1249,23 @@ h3 {
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);
justify-self: center;
aspect-ratio: 1;
border-radius: 24px;
}
.item-card-food .item-body-food {
.item-card-food .item-body-food,
.item-card-meal .item-body-meal {
justify-items: center;
text-align: center;
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;
font-size: 1.9rem;
line-height: 1.08;
@@ -1289,6 +1307,79 @@ h3 {
transform: translateY(-1px);
}
.item-card-quick-actions {
position: absolute;
top: 0.9rem;
left: 0.9rem;
z-index: 3;
display: flex;
gap: 0.45rem;
}
.item-card-quick-actions form {
margin: 0;
}
.item-card-icon-button {
width: 2.45rem;
height: 2.45rem;
min-width: 2.45rem;
padding: 0;
border-radius: 999px;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--surface-strong) 84%, var(--accent-soft) 16%);
color: var(--text);
border: 1px solid color-mix(in srgb, var(--line) 62%, var(--accent) 38%);
box-shadow: 0 10px 22px rgba(70, 48, 34, 0.12);
}
.item-card-icon-link {
text-decoration: none;
}
.item-card-icon-button:hover {
background: color-mix(in srgb, var(--accent) 78%, #fff 22%);
color: #201a17;
transform: translateY(-1px) scale(1.02);
}
.item-card-icon-button.is-active,
.item-card-icon-button:disabled {
opacity: 1;
background: color-mix(in srgb, var(--accent) 82%, var(--surface-strong) 18%);
color: #201a17;
border-color: color-mix(in srgb, var(--accent-strong) 68%, var(--line) 32%);
transform: none;
}
.item-card-icon-button:disabled {
cursor: default;
}
.item-card-icon-button.is-inactive {
background: color-mix(in srgb, var(--surface-soft) 82%, #8a674f 18%);
color: color-mix(in srgb, var(--muted) 72%, var(--text) 28%);
border-color: color-mix(in srgb, var(--line) 72%, #8a674f 28%);
}
.item-card-icon-button.is-home {
background: color-mix(in srgb, var(--accent) 82%, var(--surface-strong) 18%);
color: #201a17;
border-color: color-mix(in srgb, var(--accent-strong) 68%, var(--line) 32%);
}
.item-card-icon-button.is-missing {
background: rgba(210, 125, 115, 0.18);
color: color-mix(in srgb, #9f4339 74%, var(--text) 26%);
border-color: color-mix(in srgb, rgba(210, 125, 115, 0.42) 62%, var(--line) 38%);
}
.item-card-icon-button .ui-icon {
width: 1.05rem;
height: 1.05rem;
}
.item-card-hover-meta {
position: absolute;
inset: 0;
@@ -1314,7 +1405,9 @@ h3 {
}
.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;
visibility: visible;
transform: translateY(0);
@@ -1325,6 +1418,43 @@ h3 {
border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.16) 42%);
}
[data-theme="dark"] button.item-card-icon-button,
[data-theme="dark"] .item-card-icon-button {
background: color-mix(in srgb, var(--surface-soft) 70%, rgba(33, 28, 27, 0.5) 30%);
color: var(--text);
border-color: color-mix(in srgb, var(--line) 50%, rgba(243, 177, 125, 0.24) 50%);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.24);
}
[data-theme="dark"] button.item-card-icon-button:hover,
[data-theme="dark"] .item-card-icon-button:hover {
background: #d7935f;
color: #201a17;
}
[data-theme="dark"] .item-card-icon-button.is-active,
[data-theme="dark"] .item-card-icon-button:disabled,
[data-theme="dark"] button.item-card-icon-button.is-active,
[data-theme="dark"] button.item-card-icon-button:disabled {
background: #d7935f;
color: #201a17;
border-color: color-mix(in srgb, var(--accent-strong) 66%, rgba(243, 177, 125, 0.24) 34%);
}
[data-theme="dark"] .item-card-icon-button.is-inactive,
[data-theme="dark"] button.item-card-icon-button.is-inactive {
background: color-mix(in srgb, var(--surface-soft) 76%, #6c5141 24%);
color: color-mix(in srgb, var(--muted) 86%, white 14%);
border-color: color-mix(in srgb, var(--line) 62%, #8a674f 38%);
}
[data-theme="dark"] .item-card-icon-button.is-missing,
[data-theme="dark"] button.item-card-icon-button.is-missing {
background: rgba(210, 125, 115, 0.22);
color: #f2d0ca;
border-color: color-mix(in srgb, rgba(210, 125, 115, 0.44) 62%, var(--line) 38%);
}
[data-theme="dark"] .item-card-hover-meta {
background: linear-gradient(
180deg,
@@ -1371,6 +1501,31 @@ h3 {
grid-column: 1 / -1;
}
.shopping-create-actions {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
padding: 0.65rem;
border-radius: 18px;
background: color-mix(in srgb, var(--surface-soft) 76%, transparent 24%);
border: 1px solid color-mix(in srgb, var(--line) 78%, transparent 22%);
}
.shopping-create-actions[hidden] {
display: none;
}
.shopping-create-actions p {
flex: 1 1 16rem;
margin: 0;
}
.shopping-create-actions button {
flex: 0 0 auto;
}
.shopping-add-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
@@ -1626,6 +1781,11 @@ h3 {
width: 100%;
}
.shopping-create-actions {
display: grid;
grid-template-columns: 1fr;
}
.shopping-entry-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
@@ -3057,6 +3217,11 @@ legend {
mask-image: url("../icons/fa/house.svg");
}
.icon-house-xmark {
-webkit-mask-image: url("../icons/fa/house-circle-xmark.svg");
mask-image: url("../icons/fa/house-circle-xmark.svg");
}
.icon-utensils {
-webkit-mask-image: url("../icons/fa/utensils.svg");
mask-image: url("../icons/fa/utensils.svg");
@@ -3458,11 +3623,13 @@ legend {
grid-template-columns: 1fr;
}
.item-card-food {
.item-card-food,
.item-card-meal {
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);
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M272 70.1C281.1 61.9 294.9 61.9 304 70.1L533.2 275.6C521.2 273.2 508.7 272 496 272C484 272 472.2 273.1 460.9 275.2L288 120.2L128 263.7L128 512C128 520.8 135.2 528 144 528L192 528L192 424C192 384.2 224.2 352 264 352L312 352C320.8 352 329.2 353.6 336.9 356.4C327.8 369.9 320.3 384.5 314.8 400.1C313.9 400 312.9 399.9 311.9 399.9L263.9 399.9C250.6 399.9 239.9 410.6 239.9 423.9L239.9 527.9L314.8 527.9C320.9 545.2 329.4 561.3 339.9 575.9L143.9 575.9C108.6 575.9 79.9 547.2 79.9 511.9L79.9 306.6L71.9 313.8C62 322.6 46.9 321.8 38 312C29.1 302.2 30 287 39.8 278.1L272 70.1zM496 320C575.5 320 640 384.5 640 464C640 543.5 575.5 608 496 608C416.5 608 352 543.5 352 464C352 384.5 416.5 320 496 320zM555.3 427.3C561.5 421.1 561.5 410.9 555.3 404.7C549.1 398.5 538.9 398.5 532.7 404.7L496 441.4L459.3 404.7C453.1 398.5 442.9 398.5 436.7 404.7C430.5 410.9 430.5 421.1 436.7 427.3L473.4 464L436.7 500.7C430.5 506.9 430.5 517.1 436.7 523.3C442.9 529.5 453.1 529.5 459.3 523.3L496 486.6L532.7 523.3C538.9 529.5 549.1 529.5 555.3 523.3C561.5 517.1 561.5 506.9 555.3 500.7L518.6 464L555.3 427.3z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+20
View File
@@ -219,6 +219,25 @@
});
};
const initCreateFromSearch = () => {
document.querySelectorAll("[data-create-from]").forEach((container) => {
const inputSelector = container.getAttribute("data-create-from");
if (!inputSelector) return;
const input = document.querySelector(inputSelector);
const hiddenName = container.querySelector("[data-create-name]");
if (!(input instanceof HTMLInputElement) || !(hiddenName instanceof HTMLInputElement)) return;
const sync = () => {
const value = input.value.trim().replace(/\s+/g, " ");
hiddenName.value = value;
container.hidden = value.length === 0;
};
input.addEventListener("input", sync);
sync();
});
};
const initIosPullToRefresh = () => {
const isAppleTouchDevice = /iP(ad|hone|od)/.test(navigator.userAgent)
|| (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
@@ -323,6 +342,7 @@
initMobileSheet();
initFilterInputs();
initSelectedPreviews();
initCreateFromSearch();
initIosPullToRefresh();
initDialogs();
});
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = "nouri-v1-3-2";
const CACHE_NAME = "nouri-v1-3-4";
const OFFLINE_URL = "/static/pwa/offline.html";
const STATIC_ASSETS = [
"/static/css/styles.css",
+74 -19
View File
@@ -86,6 +86,36 @@
<button class="item-card-archive-button" type="submit" aria-label="{{ item.name }} archivieren">×</button>
</form>
{% endif %}
<div class="item-card-quick-actions">
{% if item.can_edit %}
{% if item.is_home %}
<form method="post" action="{{ url_for('main.item_set_not_home', item_id=item.id) }}">
{{ csrf_input() }}
<button class="item-card-icon-button is-home" type="submit" aria-label="{{ item.name }} als nicht mehr da markieren" title="Lebensmittel ist zuhause">
<span class="ui-icon icon-house"></span>
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('main.item_set_home', item_id=item.id) }}">
{{ csrf_input() }}
<button class="item-card-icon-button is-missing" type="submit" aria-label="{{ item.name }} als zuhause markieren" title="Lebensmittel ist nicht zuhause">
<span class="ui-icon icon-house-xmark"></span>
</button>
</form>
{% endif %}
{% endif %}
<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-food">
{% if item.photo_filename %}
@@ -132,8 +162,44 @@
</div>
</article>
{% else %}
<article class="item-card">
<div class="item-media">
<article class="item-card item-card-meal">
{% 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 %}
<img
src="{{ image_url(item.photo_filename, 'md') }}"
@@ -145,7 +211,12 @@
<div class="placeholder-tile">{{ item.name[:1] }}</div>
{% endif %}
</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">
<span class="chip">{{ item.visibility_label }}</span>
<span class="chip status-soft">{{ item.owner_label }}</span>
@@ -172,22 +243,6 @@
<p>{{ item.note }}</p>
{% endif %}
</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>
{% endif %}
{% endfor %}
+24 -7
View File
@@ -13,11 +13,12 @@
<form method="post" class="shopping-add-form">
{{ csrf_input() }}
<label>
Lebensmittel suchen
Lebensmittel oder Artikel suchen
<input
type="text"
name="item_search"
placeholder="Nach Lebensmitteln suchen"
id="shopping-item-search"
placeholder="Nach Lebensmitteln oder Artikeln suchen"
autocomplete="off"
data-filter-input
data-filter-target="#shopping-food-options"
@@ -46,12 +47,15 @@
'seeds': 'icon-component-seeds',
'neutral': 'icon-component-neutral',
}.get(item.primary_builder_key or item.base_type, 'icon-component-neutral') %}
{% if item.kind == 'shopping' %}
{% set item_icon_class = 'icon-cart-shopping' %}
{% endif %}
<button
class="shopping-add-card"
type="submit"
name="item_id"
value="{{ item.id }}"
data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }} {{ item.base_type_label|lower }} {{ item.availability_label|lower }}"
data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }} {{ item.base_type_label|lower }} {{ item.availability_label|lower }} {% if item.kind == 'shopping' %}einkaufsartikel artikel drogerie haushalt{% endif %}"
>
<span class="shopping-add-card-visual">
{% if item.photo_filename %}
@@ -69,11 +73,17 @@
</span>
<span class="shopping-add-card-copy">
<strong>{{ item.name }}</strong>
<small>{{ item.availability_label }}</small>
<small>{% if item.kind == 'shopping' %}Einkaufsartikel{% else %}{{ item.availability_label }}{% endif %}</small>
</span>
</button>
{% endfor %}
</div>
<div class="shopping-create-actions" data-create-from="#shopping-item-search" hidden>
<input type="hidden" name="create_item_name" data-create-name>
<p class="muted">Kein passender Treffer? Direkt aus dem Suchbegriff anlegen:</p>
<button class="ghost-button" type="submit" name="create_as" value="food">Als Lebensmittel anlegen</button>
<button class="ghost-button" type="submit" name="create_as" value="shopping">Als Einkaufsartikel anlegen</button>
</div>
<button type="submit">Auf die Liste</button>
</form>
</section>
@@ -97,6 +107,9 @@
'seeds': 'icon-component-seeds',
'neutral': 'icon-component-neutral',
}.get(entry.primary_builder_key or entry.base_type, 'icon-component-neutral') %}
{% if entry.item_kind == 'shopping' %}
{% set entry_icon_class = 'icon-cart-shopping' %}
{% endif %}
<article class="shopping-entry-card">
<div class="shopping-entry-row">
<div
@@ -165,6 +178,8 @@
{% elif entry.needed_for_label %}
Für {{ entry.needed_for_label }}
{% if entry.needed_daypart_name %} · {{ entry.needed_daypart_name }}{% endif %}
{% elif entry.item_kind == 'shopping' %}
Einkaufsartikel
{% elif entry.is_home %}
Zuhause vorhanden
{% else %}
@@ -175,7 +190,7 @@
<button class="ghost-button" type="button" data-dialog-close>Schließen</button>
</div>
<div class="shopping-entry-dialog-actions">
{% if entry.can_edit %}
{% if entry.can_edit and entry.item_kind != 'shopping' %}
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=entry.item_id) }}">Bearbeiten</a>
{% endif %}
<form method="post" action="{{ url_for('main.shopping_check', entry_id=entry.id) }}">
@@ -201,22 +216,24 @@
{{ csrf_input() }}
<button class="ghost-button" type="submit">Von Einkaufsliste nehmen</button>
</form>
{% if entry.is_home %}
{% if entry.item_kind != 'shopping' and entry.is_home %}
<form method="post" action="{{ url_for('main.item_set_not_home', item_id=entry.item_id) }}">
{{ csrf_input() }}
<button class="ghost-button" type="submit">Nicht mehr da</button>
</form>
{% else %}
{% elif entry.item_kind != 'shopping' %}
<form method="post" action="{{ url_for('main.item_set_home', item_id=entry.item_id) }}">
{{ csrf_input() }}
<button class="ghost-button" type="submit">Als zuhause markieren</button>
</form>
{% endif %}
{% if entry.item_kind != 'shopping' %}
<form method="post" action="{{ url_for('main.item_archive', item_id=entry.item_id) }}">
{{ csrf_input() }}
<button class="ghost-button" type="submit">Archivieren</button>
</form>
{% endif %}
{% endif %}
</div>
</div>
</dialog>
+1 -1
View File
@@ -1,5 +1,5 @@
Flask==3.1.1
gunicorn==23.0.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