Release Nouri 1.3.3 with shopping articles
This commit is contained in:
@@ -4,8 +4,8 @@
|
|||||||
"author": "Florian Heinz",
|
"author": "Florian Heinz",
|
||||||
"description": "Private Flask app for meals, shopping and gentle food planning",
|
"description": "Private Flask app for meals, shopping and gentle food planning",
|
||||||
"tagline": "einfach essen planen",
|
"tagline": "einfach essen planen",
|
||||||
"version": "1.3.2",
|
"version": "1.3.3",
|
||||||
"upstreamVersion": "1.3.2",
|
"upstreamVersion": "1.3.3",
|
||||||
"healthCheckPath": "/",
|
"healthCheckPath": "/",
|
||||||
"httpPort": 8000,
|
"httpPort": 8000,
|
||||||
"manifestVersion": 2,
|
"manifestVersion": 2,
|
||||||
|
|||||||
@@ -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.
|
||||||
+1
-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.2"
|
return "1.3.3"
|
||||||
|
|
||||||
|
|
||||||
def load_release_url() -> str:
|
def load_release_url() -> str:
|
||||||
|
|||||||
+111
-1
@@ -15,7 +15,7 @@ from .constants import (
|
|||||||
DEFAULT_CATEGORY_BUILDERS,
|
DEFAULT_CATEGORY_BUILDERS,
|
||||||
)
|
)
|
||||||
|
|
||||||
CURRENT_SCHEMA_VERSION = "1.3.2"
|
CURRENT_SCHEMA_VERSION = "1.3.3"
|
||||||
|
|
||||||
ANIMAL_HINTS = (
|
ANIMAL_HINTS = (
|
||||||
"huhn",
|
"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:
|
def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
||||||
ensure_meta_table(database)
|
ensure_meta_table(database)
|
||||||
database.execute(
|
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", "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_archived INTEGER NOT NULL DEFAULT 0")
|
||||||
add_column_if_missing(database, "items", "is_quick_added 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_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, "shopping_entries", "shopping_note TEXT NOT NULL DEFAULT ''")
|
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 != ''
|
WHERE email IS NOT NULL AND email != ''
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_kind_name
|
||||||
|
ON items (kind, name)
|
||||||
|
"""
|
||||||
|
)
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_items_household_visibility
|
CREATE INDEX IF NOT EXISTS idx_items_household_visibility
|
||||||
|
|||||||
+88
-9
@@ -57,7 +57,7 @@ from .constants import (
|
|||||||
WEEKDAY_OPTIONS,
|
WEEKDAY_OPTIONS,
|
||||||
WEEK_TEMPLATE_NAME_SUGGESTIONS,
|
WEEK_TEMPLATE_NAME_SUGGESTIONS,
|
||||||
)
|
)
|
||||||
from .db import get_db
|
from .db import get_db, infer_food_flavor_profile, infer_food_profile
|
||||||
from .images import (
|
from .images import (
|
||||||
allowed_image_file,
|
allowed_image_file,
|
||||||
save_photo_with_variants,
|
save_photo_with_variants,
|
||||||
@@ -1005,6 +1005,10 @@ def normalize_shopping_note(value: str | None) -> str:
|
|||||||
return " ".join((value or "").strip().split())[:80]
|
return " ".join((value or "").strip().split())[:80]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_new_item_name(value: str | None) -> str:
|
||||||
|
return " ".join((value or "").strip().split())[:120]
|
||||||
|
|
||||||
|
|
||||||
def schedule_shopping_need(
|
def schedule_shopping_need(
|
||||||
*,
|
*,
|
||||||
item_id: int,
|
item_id: int,
|
||||||
@@ -1298,8 +1302,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]
|
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:
|
def find_shopping_item_by_name(name: str) -> dict | None:
|
||||||
normalized_name = name.strip().lower()
|
normalized_name = normalize_new_item_name(name).lower()
|
||||||
if not normalized_name:
|
if not normalized_name:
|
||||||
return None
|
return None
|
||||||
row = get_db().execute(
|
row = get_db().execute(
|
||||||
@@ -1317,11 +1321,11 @@ def find_shopping_food_by_name(name: str) -> dict | None:
|
|||||||
FROM items
|
FROM items
|
||||||
LEFT JOIN users AS owner ON owner.id = items.owner_user_id
|
LEFT JOIN users AS owner ON owner.id = items.owner_user_id
|
||||||
LEFT JOIN users AS target ON target.id = items.target_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 items.is_archived = 0
|
||||||
AND LOWER(items.name) = ?
|
AND LOWER(items.name) = ?
|
||||||
AND {visible_clause('items')}
|
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
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
[normalized_name, *visible_params()],
|
[normalized_name, *visible_params()],
|
||||||
@@ -1331,6 +1335,64 @@ def find_shopping_food_by_name(name: str) -> dict | None:
|
|||||||
return attach_builder_keys(attach_dayparts(describe_records([row])))[0]
|
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():
|
def fetch_shopping_entries():
|
||||||
rows = get_db().execute(
|
rows = get_db().execute(
|
||||||
f"""
|
f"""
|
||||||
@@ -4390,6 +4452,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 = ?",
|
"UPDATE shopping_entries SET is_checked = 1, checked_at = CURRENT_TIMESTAMP, checked_by = ? WHERE id = ?",
|
||||||
(g.user["id"], entry_id),
|
(g.user["id"], entry_id),
|
||||||
)
|
)
|
||||||
|
if item["kind"] != "shopping":
|
||||||
get_db().execute(
|
get_db().execute(
|
||||||
"""
|
"""
|
||||||
UPDATE items
|
UPDATE items
|
||||||
@@ -4495,6 +4558,9 @@ def item_mark_bought(item_id: int):
|
|||||||
except (ValueError, PermissionError) as exc:
|
except (ValueError, PermissionError) as exc:
|
||||||
flash(str(exc), "error")
|
flash(str(exc), "error")
|
||||||
return redirect(request.referrer or url_for("main.shopping_list"))
|
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")
|
flash(f"{item['name']} ist jetzt als Zuhause vorhanden markiert.", "success")
|
||||||
return redirect(request.referrer or url_for("main.shopping_list"))
|
return redirect(request.referrer or url_for("main.shopping_list"))
|
||||||
|
|
||||||
@@ -4528,17 +4594,24 @@ def shopping_list():
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
selected_item_id = request.form.get("item_id", "").strip()
|
selected_item_id = request.form.get("item_id", "").strip()
|
||||||
item_search = request.form.get("item_search", "").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"))
|
shopping_note = normalize_shopping_note(request.form.get("shopping_note"))
|
||||||
item = None
|
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:
|
try:
|
||||||
item = get_item(int(selected_item_id))
|
item = get_item(int(selected_item_id))
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
flash(str(exc), "error")
|
flash(str(exc), "error")
|
||||||
elif item_search:
|
elif item_search:
|
||||||
item = find_shopping_food_by_name(item_search)
|
item = find_shopping_item_by_name(item_search)
|
||||||
if item is None:
|
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:
|
else:
|
||||||
flash("Bitte zuerst etwas auswählen.", "error")
|
flash("Bitte zuerst etwas auswählen.", "error")
|
||||||
|
|
||||||
@@ -4560,7 +4633,10 @@ def shopping_list():
|
|||||||
|
|
||||||
entries = fetch_shopping_entries()
|
entries = fetch_shopping_entries()
|
||||||
upcoming_entries = fetch_upcoming_shopping_needs()
|
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()
|
household_settings = get_household_settings()
|
||||||
shopping_weekday_label = dict(WEEKDAY_OPTIONS).get(household_settings["shopping_weekday"], "gesetzt")
|
shopping_weekday_label = dict(WEEKDAY_OPTIONS).get(household_settings["shopping_weekday"], "gesetzt")
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -4581,6 +4657,9 @@ def shopping_check(entry_id: int):
|
|||||||
except (ValueError, PermissionError) as exc:
|
except (ValueError, PermissionError) as exc:
|
||||||
flash(str(exc), "error")
|
flash(str(exc), "error")
|
||||||
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
|
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")
|
flash(f"{item['name']} ist jetzt als Zuhause vorhanden markiert.", "success")
|
||||||
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
|
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -118,7 +118,7 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
owner_user_id INTEGER,
|
owner_user_id INTEGER,
|
||||||
target_user_id INTEGER,
|
target_user_id INTEGER,
|
||||||
visibility TEXT NOT NULL DEFAULT 'shared',
|
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,
|
name TEXT NOT NULL,
|
||||||
category TEXT,
|
category TEXT,
|
||||||
base_type TEXT NOT NULL DEFAULT 'neutral',
|
base_type TEXT NOT NULL DEFAULT 'neutral',
|
||||||
|
|||||||
@@ -1491,6 +1491,31 @@ h3 {
|
|||||||
grid-column: 1 / -1;
|
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 {
|
.shopping-add-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||||
@@ -1746,6 +1771,11 @@ h3 {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shopping-create-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.shopping-entry-row {
|
.shopping-entry-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
|||||||
@@ -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 initIosPullToRefresh = () => {
|
||||||
const isAppleTouchDevice = /iP(ad|hone|od)/.test(navigator.userAgent)
|
const isAppleTouchDevice = /iP(ad|hone|od)/.test(navigator.userAgent)
|
||||||
|| (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
|| (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||||
@@ -323,6 +342,7 @@
|
|||||||
initMobileSheet();
|
initMobileSheet();
|
||||||
initFilterInputs();
|
initFilterInputs();
|
||||||
initSelectedPreviews();
|
initSelectedPreviews();
|
||||||
|
initCreateFromSearch();
|
||||||
initIosPullToRefresh();
|
initIosPullToRefresh();
|
||||||
initDialogs();
|
initDialogs();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = "nouri-v1-3-2";
|
const CACHE_NAME = "nouri-v1-3-3";
|
||||||
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",
|
||||||
|
|||||||
@@ -13,11 +13,12 @@
|
|||||||
<form method="post" class="shopping-add-form">
|
<form method="post" class="shopping-add-form">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<label>
|
<label>
|
||||||
Lebensmittel suchen
|
Lebensmittel oder Artikel suchen
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="item_search"
|
name="item_search"
|
||||||
placeholder="Nach Lebensmitteln suchen"
|
id="shopping-item-search"
|
||||||
|
placeholder="Nach Lebensmitteln oder Artikeln suchen"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
data-filter-input
|
data-filter-input
|
||||||
data-filter-target="#shopping-food-options"
|
data-filter-target="#shopping-food-options"
|
||||||
@@ -46,12 +47,15 @@
|
|||||||
'seeds': 'icon-component-seeds',
|
'seeds': 'icon-component-seeds',
|
||||||
'neutral': 'icon-component-neutral',
|
'neutral': 'icon-component-neutral',
|
||||||
}.get(item.primary_builder_key or item.base_type, '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
|
<button
|
||||||
class="shopping-add-card"
|
class="shopping-add-card"
|
||||||
type="submit"
|
type="submit"
|
||||||
name="item_id"
|
name="item_id"
|
||||||
value="{{ 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">
|
<span class="shopping-add-card-visual">
|
||||||
{% if item.photo_filename %}
|
{% if item.photo_filename %}
|
||||||
@@ -69,11 +73,17 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="shopping-add-card-copy">
|
<span class="shopping-add-card-copy">
|
||||||
<strong>{{ item.name }}</strong>
|
<strong>{{ item.name }}</strong>
|
||||||
<small>{{ item.availability_label }}</small>
|
<small>{% if item.kind == 'shopping' %}Einkaufsartikel{% else %}{{ item.availability_label }}{% endif %}</small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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>
|
<button type="submit">Auf die Liste</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -97,6 +107,9 @@
|
|||||||
'seeds': 'icon-component-seeds',
|
'seeds': 'icon-component-seeds',
|
||||||
'neutral': 'icon-component-neutral',
|
'neutral': 'icon-component-neutral',
|
||||||
}.get(entry.primary_builder_key or entry.base_type, '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">
|
<article class="shopping-entry-card">
|
||||||
<div class="shopping-entry-row">
|
<div class="shopping-entry-row">
|
||||||
<div
|
<div
|
||||||
@@ -165,6 +178,8 @@
|
|||||||
{% elif entry.needed_for_label %}
|
{% elif entry.needed_for_label %}
|
||||||
Für {{ entry.needed_for_label }}
|
Für {{ entry.needed_for_label }}
|
||||||
{% if entry.needed_daypart_name %} · {{ entry.needed_daypart_name }}{% endif %}
|
{% if entry.needed_daypart_name %} · {{ entry.needed_daypart_name }}{% endif %}
|
||||||
|
{% elif entry.item_kind == 'shopping' %}
|
||||||
|
Einkaufsartikel
|
||||||
{% elif entry.is_home %}
|
{% elif entry.is_home %}
|
||||||
Zuhause vorhanden
|
Zuhause vorhanden
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -175,7 +190,7 @@
|
|||||||
<button class="ghost-button" type="button" data-dialog-close>Schließen</button>
|
<button class="ghost-button" type="button" data-dialog-close>Schließen</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="shopping-entry-dialog-actions">
|
<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>
|
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=entry.item_id) }}">Bearbeiten</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" action="{{ url_for('main.shopping_check', entry_id=entry.id) }}">
|
<form method="post" action="{{ url_for('main.shopping_check', entry_id=entry.id) }}">
|
||||||
@@ -201,22 +216,24 @@
|
|||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button class="ghost-button" type="submit">Von Einkaufsliste nehmen</button>
|
<button class="ghost-button" type="submit">Von Einkaufsliste nehmen</button>
|
||||||
</form>
|
</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) }}">
|
<form method="post" action="{{ url_for('main.item_set_not_home', item_id=entry.item_id) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button class="ghost-button" type="submit">Nicht mehr da</button>
|
<button class="ghost-button" type="submit">Nicht mehr da</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% elif entry.item_kind != 'shopping' %}
|
||||||
<form method="post" action="{{ url_for('main.item_set_home', item_id=entry.item_id) }}">
|
<form method="post" action="{{ url_for('main.item_set_home', item_id=entry.item_id) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button class="ghost-button" type="submit">Als zuhause markieren</button>
|
<button class="ghost-button" type="submit">Als zuhause markieren</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if entry.item_kind != 'shopping' %}
|
||||||
<form method="post" action="{{ url_for('main.item_archive', item_id=entry.item_id) }}">
|
<form method="post" action="{{ url_for('main.item_archive', item_id=entry.item_id) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button class="ghost-button" type="submit">Archivieren</button>
|
<button class="ghost-button" type="submit">Archivieren</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|||||||
Reference in New Issue
Block a user