From 06be1371d39fa4e1981c244e52d5858cd4e68e68 Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Wed, 15 Apr 2026 11:25:08 +0200 Subject: [PATCH] Erweitere Einkaufssuche um Archiv und Unsortiert --- nouri/main.py | 450 +++++++++++++++++++++++------ nouri/templates/shopping/list.html | 199 ++++++++++--- 2 files changed, 528 insertions(+), 121 deletions(-) diff --git a/nouri/main.py b/nouri/main.py index 2d4c35c..38db488 100644 --- a/nouri/main.py +++ b/nouri/main.py @@ -21,7 +21,7 @@ from flask import ( url_for, ) -from .auth import admin_required, login_required +from .auth import admin_required, login_required, url_with_scroll_position, wants_to_stay_on_form from .backup import RESTORE_CONFIRMATION_TEXT, export_backup_archive, restore_backup_archive from .constants import ( AVAILABILITY_LABELS, @@ -71,7 +71,8 @@ main_bp = Blueprint("main", __name__) ACTIVE_STATE_OPTIONS = [ ("", "Alle aktiven"), ("home", "Zuhause"), - ("idea", "Merkliste"), + ("idea", "Gerade nicht da"), + ("unsorted", "Unsortiert"), ] KIND_FILTER_OPTIONS = [ ("", "Alles"), @@ -465,6 +466,16 @@ def describe_record(entry: dict) -> dict: entry["for_label"] = "Für mich" if entry["is_mine"] else f"Für {owner_name}" else: entry["for_label"] = "Für alle" + entry["is_archived"] = bool(entry.get("is_archived")) + entry["is_quick_added"] = bool(entry.get("is_quick_added")) + entry["is_home"] = bool(entry.get("availability_state") == "home" and not entry["is_archived"]) + if entry["is_archived"]: + entry["availability_key"] = "archived" + elif entry["is_quick_added"]: + entry["availability_key"] = "unsorted" + else: + entry["availability_key"] = "home" if entry["is_home"] else "idea" + entry["availability_label"] = AVAILABILITY_LABELS.get(entry["availability_key"], AVAILABILITY_LABELS["idea"]) entry["can_edit"] = entry["is_shared"] or entry["is_mine"] or g.user["role"] == "admin" return entry @@ -798,6 +809,7 @@ def fetch_items( kind: str | None = None, availability: str | None = None, include_archived: bool = False, + include_quick_added: bool = False, query: str | None = None, daypart_id: int | None = None, visibility: str | None = None, @@ -809,10 +821,19 @@ def fetch_items( conditions.append("items.kind = ?") params.append(kind) if availability: - conditions.append("items.availability_state = ?") - params.append(availability) + if availability == "archived": + conditions.append("items.is_archived = 1") + elif availability == "unsorted": + conditions.append("items.is_archived = 0") + conditions.append("COALESCE(items.is_quick_added, 0) = 1") + else: + conditions.append("items.is_archived = 0") + conditions.append("items.availability_state = ?") + params.append(availability) elif not include_archived: - conditions.append("items.availability_state != 'archived'") + conditions.append("items.is_archived = 0") + if not include_quick_added and availability != "unsorted": + conditions.append("COALESCE(items.is_quick_added, 0) = 0") if query: conditions.append("LOWER(items.name) LIKE ?") params.append(f"%{query.lower()}%") @@ -849,7 +870,11 @@ def fetch_items( LEFT JOIN users AS target ON target.id = items.target_user_id WHERE {' AND '.join(conditions)} ORDER BY - CASE items.availability_state WHEN 'home' THEN 0 WHEN 'idea' THEN 1 ELSE 2 END, + CASE + WHEN items.is_archived = 1 THEN 2 + WHEN items.availability_state = 'home' THEN 0 + ELSE 1 + END, CASE items.visibility WHEN 'shared' THEN 0 ELSE 1 END, LOWER(items.name) """, @@ -859,13 +884,14 @@ def fetch_items( def fetch_food_options(query: str | None = None): - return fetch_items(kind="food", include_archived=True, query=query) + return fetch_items(kind="food", include_archived=False, query=query) def group_items_by_availability(items: list[dict]) -> list[dict]: grouped = defaultdict(list) for item in items: - grouped[item["availability_state"]].append(item) + key = "archived" if item.get("is_archived") else item.get("availability_state", "idea") + grouped[key].append(item) result = [] for state in ("home", "idea", "archived"): @@ -1108,6 +1134,7 @@ def fetch_meal_missing_components(meal_id: int) -> list[dict]: WHERE meal_components.meal_item_id = ? AND items.household_id = ? AND (items.visibility = 'shared' OR items.owner_user_id = ?) + AND items.is_archived = 0 AND items.availability_state != 'home' ORDER BY LOWER(items.name) """, @@ -1169,16 +1196,6 @@ def ensure_item_or_missing_components_are_shopped( "used_components": True, } - if item["availability_state"] == "home": - return { - "added": False, - "count": 0, - "names": [], - "scheduled_count": 0, - "scheduled_names": [], - "used_components": False, - } - if needed_for_date and not should_activate_shopping_need(parse_plan_date(needed_for_date)): schedule_shopping_need( item_id=item_id, @@ -1279,8 +1296,10 @@ def fetch_shopping_entries(): SELECT shopping_entries.*, items.name AS item_name, items.kind AS item_kind, + items.base_type, items.photo_filename, items.availability_state, + items.is_archived, owner.display_name AS owner_display_name, owner.username AS owner_username, target.display_name AS target_display_name, @@ -1322,6 +1341,7 @@ def activate_due_shopping_needs(today: date | None = None) -> int: WHERE shopping_needs.household_id = ? AND shopping_needs.is_activated = 0 AND shopping_needs.activation_date <= ? + AND items.is_archived = 0 ORDER BY shopping_needs.activation_date, shopping_needs.needed_for_date """, (current_household_id(), today.isoformat()), @@ -1353,6 +1373,7 @@ def fetch_upcoming_shopping_needs(limit: int | None = None) -> list[dict]: items.kind AS item_kind, items.photo_filename, items.availability_state, + items.is_archived, owner.display_name AS owner_display_name, owner.username AS owner_username, target.display_name AS target_display_name, @@ -1366,6 +1387,7 @@ def fetch_upcoming_shopping_needs(limit: int | None = None) -> list[dict]: WHERE shopping_needs.household_id = ? AND shopping_needs.is_activated = 0 AND (shopping_needs.visibility = 'shared' OR shopping_needs.owner_user_id = ?) + AND items.is_archived = 0 AND items.availability_state != 'home' ORDER BY shopping_needs.activation_date, shopping_needs.needed_for_date, LOWER(items.name) """ @@ -1393,6 +1415,7 @@ def fetch_plan_entries_for_range(start_date: date, end_date: date): items.kind AS item_kind, items.photo_filename, items.availability_state, + items.is_archived, dayparts.name AS daypart_name, dayparts.slug AS daypart_slug, dayparts.sort_order, @@ -1434,6 +1457,7 @@ def fetch_recent_plan_items(daypart_id: int, limit: int = 6): items.note, items.photo_filename, items.availability_state, + items.is_archived, owner.display_name AS owner_display_name, owner.username AS owner_username, target.display_name AS target_display_name, @@ -1453,7 +1477,7 @@ def fetch_recent_plan_items(daypart_id: int, limit: int = 6): def fetch_plan_candidates(daypart_id: int, query: str | None = None): params = [daypart_id, *visible_params()] - conditions = [visible_clause("items"), "items.availability_state != 'archived'"] + conditions = [visible_clause("items"), "items.is_archived = 0"] if query: conditions.append("LOWER(items.name) LIKE ?") params.append(f"%{query.lower()}%") @@ -1495,7 +1519,7 @@ def fetch_home_food_ids() -> set[int]: f""" SELECT id FROM items - WHERE kind = 'food' AND availability_state = 'home' AND {visible_clause('items')} + WHERE kind = 'food' AND availability_state = 'home' AND is_archived = 0 AND {visible_clause('items')} """, visible_params(), ).fetchall() @@ -1962,6 +1986,7 @@ def build_dashboard_hints(today: date) -> list[str]: JOIN item_dayparts ON item_dayparts.item_id = items.id JOIN dayparts ON dayparts.id = item_dayparts.daypart_id WHERE items.availability_state = 'home' + AND items.is_archived = 0 AND dayparts.slug = 'dinner' AND {visible_clause('items')} """, @@ -2001,13 +2026,13 @@ def build_dashboard_hints(today: date) -> list[str]: def build_setup_checklist(today: date) -> list[dict]: total_items = int( get_db().execute( - f"SELECT COUNT(*) AS count FROM items WHERE {visible_clause('items')}", + f"SELECT COUNT(*) AS count FROM items WHERE items.is_archived = 0 AND {visible_clause('items')}", visible_params(), ).fetchone()["count"] ) meal_count = int( get_db().execute( - f"SELECT COUNT(*) AS count FROM items WHERE kind = 'meal' AND {visible_clause('items')}", + f"SELECT COUNT(*) AS count FROM items WHERE kind = 'meal' AND items.is_archived = 0 AND {visible_clause('items')}", visible_params(), ).fetchone()["count"] ) @@ -2095,7 +2120,7 @@ def build_day_hints(selected_date: date) -> list[str]: FROM items JOIN item_dayparts ON item_dayparts.item_id = items.id JOIN dayparts ON dayparts.id = item_dayparts.daypart_id - WHERE items.availability_state != 'archived' + WHERE items.is_archived = 0 AND dayparts.slug = 'afternoon-snack' AND {visible_clause('items')} """, @@ -2233,12 +2258,12 @@ def build_day_planner_sections( candidates = fetch_plan_candidates(daypart["id"]) entries = day_entries.get((selected_date.isoformat(), daypart["id"]), []) meal_candidates = dedupe_items( - [item for item in candidates if item["kind"] == "meal" and item["availability_state"] == "home"] + [item for item in candidates if item["kind"] == "meal" and item.get("is_home")] + [item for item in candidates if item["kind"] == "meal"], limit=6, ) food_candidates = dedupe_items( - [item for item in candidates if item["kind"] == "food" and item["availability_state"] == "home"] + [item for item in candidates if item["kind"] == "food" and item.get("is_home")] + fetch_recent_plan_items(daypart["id"]) + [item for item in candidates if item["kind"] == "food"], limit=20, @@ -2281,7 +2306,7 @@ def build_template_day_sections(selected_map: dict[int, list[int]] | None = None for daypart in get_dayparts(): candidates = fetch_plan_candidates(int(daypart["id"])) quick_items = dedupe_items( - [item for item in candidates if item["availability_state"] == "home"] + candidates, + [item for item in candidates if item.get("is_home")] + candidates, limit=10, ) quick_ids = {item["id"] for item in quick_items} @@ -2305,7 +2330,7 @@ def fetch_week_cards(week_start: date): for daypart in get_dayparts(): candidates = fetch_plan_candidates(int(daypart["id"])) meal_candidates = dedupe_items( - [item for item in candidates if item["kind"] == "meal" and item["availability_state"] == "home"] + [item for item in candidates if item["kind"] == "meal" and item.get("is_home")] + [item for item in candidates if item["kind"] == "meal"], limit=4, ) @@ -2379,6 +2404,7 @@ def fetch_plan_entries_for_range_export(start_date: date, end_date: date, *, mod items.kind AS item_kind, items.photo_filename, items.availability_state, + items.is_archived, dayparts.name AS daypart_name, dayparts.slug AS daypart_slug, dayparts.sort_order, @@ -2523,10 +2549,20 @@ def build_week_plan_pdf(week_start: date, *, mode: str = "mine") -> bytes: def count_visible_items(availability_state: str) -> int: - row = get_db().execute( - f"SELECT COUNT(*) AS count FROM items WHERE availability_state = ? AND {visible_clause('items')}", - [availability_state, *visible_params()], - ).fetchone() + if availability_state == "archived": + row = get_db().execute( + f"SELECT COUNT(*) AS count FROM items WHERE is_archived = 1 AND COALESCE(is_quick_added, 0) = 0 AND {visible_clause('items')}", + visible_params(), + ).fetchone() + else: + row = get_db().execute( + f""" + SELECT COUNT(*) AS count + FROM items + WHERE availability_state = ? AND is_archived = 0 AND COALESCE(is_quick_added, 0) = 0 AND {visible_clause('items')} + """, + [availability_state, *visible_params()], + ).fetchone() return int(row["count"]) @@ -3225,6 +3261,8 @@ def day_template_create(): sync_day_template_entries(template_id, form_data["selected_map"]) get_db().commit() flash("Die Tagesvorlage wurde gespeichert.", "success") + if wants_to_stay_on_form(): + return redirect(url_with_scroll_position(url_for("main.day_template_edit", template_id=template_id))) return redirect(url_for("main.template_library")) return render_template( "library/day_form.html", @@ -3270,6 +3308,8 @@ def day_template_edit(template_id: int): sync_day_template_entries(template_id, form_data["selected_map"]) get_db().commit() flash("Die Tagesvorlage wurde aktualisiert.", "success") + if wants_to_stay_on_form(): + return redirect(url_with_scroll_position(url_for("main.day_template_edit", template_id=template_id))) return redirect(url_for("main.template_library")) return render_template( "library/day_form.html", @@ -3337,6 +3377,8 @@ def week_template_create(): sync_week_template_days(template_id, selected_map) get_db().commit() flash("Die Wochenvorlage wurde gespeichert.", "success") + if wants_to_stay_on_form(): + return redirect(url_with_scroll_position(url_for("main.week_template_edit", template_id=template_id))) return redirect(url_for("main.template_library")) return render_template( "library/week_form.html", @@ -3382,6 +3424,8 @@ def week_template_edit(template_id: int): sync_week_template_days(template_id, form_data["selected_map"]) get_db().commit() flash("Die Wochenvorlage wurde aktualisiert.", "success") + if wants_to_stay_on_form(): + return redirect(url_with_scroll_position(url_for("main.week_template_edit", template_id=template_id))) return redirect(url_for("main.template_library")) return render_template( "library/week_form.html", @@ -3436,6 +3480,8 @@ def item_set_create(): sync_item_set_items(set_id, form_data["item_ids"]) get_db().commit() flash("Das Paket wurde gespeichert.", "success") + if wants_to_stay_on_form(): + return redirect(url_with_scroll_position(url_for("main.item_set_edit", set_id=set_id))) return redirect(url_for("main.template_library")) items = fetch_items(include_archived=False, query=form_data["item_search"] or None) return render_template( @@ -3487,6 +3533,8 @@ def item_set_edit(set_id: int): sync_item_set_items(set_id, form_data["item_ids"]) get_db().commit() flash("Das Paket wurde aktualisiert.", "success") + if wants_to_stay_on_form(): + return redirect(url_with_scroll_position(url_for("main.item_set_edit", set_id=set_id))) return redirect(url_for("main.template_library")) items = fetch_items(include_archived=False, query=form_data["item_search"] or None) return render_template( @@ -3531,6 +3579,9 @@ def settings_view(): ) get_db().commit() flash("Die Einkaufsrhythmus-Einstellungen wurden gespeichert.", "success") + if wants_to_stay_on_form(): + return redirect(url_with_scroll_position(url_for("main.settings_view"))) + return redirect(url_for("auth.profile")) elif form_name == "reminders": ensure_user_settings_row() suggestion_style = normalize_suggestion_style(request.form.get("suggestion_style"), "balanced") @@ -3591,6 +3642,9 @@ def settings_view(): ) get_db().commit() flash("Deine Erinnerungen und Hinweise wurden gespeichert.", "success") + if wants_to_stay_on_form(): + return redirect(url_with_scroll_position(url_for("main.settings_view"))) + return redirect(url_for("auth.profile")) elif form_name == "push_test": subscription = get_db().execute( """ @@ -3763,6 +3817,7 @@ def item_list(kind: str): items = fetch_items( kind=kind, availability=state or None, + include_quick_added=state == "unsorted", query=query or None, daypart_id=daypart_id, visibility=scope or None, @@ -3783,6 +3838,104 @@ def item_list(kind: str): ) +@main_bp.route("/items/food/quick-add", methods=("GET", "POST")) +@login_required +def item_quick_add(): + form_data = { + "names_text": "", + "visibility": "shared", + "target_user_id": None, + "target_user_raw": TARGET_USER_OPTIONS_DEFAULT, + "base_type": "neutral", + "flavor_profile": "neutral", + "suggestion_role": "base", + "suggestion_priority": "normal", + "can_be_meal_core": False, + "energy_density": "neutral", + "daypart_ids": [], + "note": "", + } + + if request.method == "POST": + form_data.update( + { + "names_text": request.form.get("names_text", "").strip(), + "visibility": normalize_visibility(request.form.get("visibility"), form_data["visibility"]), + "target_user_id": normalize_target_user_id(request.form.get("target_user_id")), + "target_user_raw": request.form.get("target_user_id", TARGET_USER_OPTIONS_DEFAULT), + "base_type": normalize_base_type(request.form.get("base_type"), form_data["base_type"]), + "flavor_profile": normalize_food_flavor(request.form.get("flavor_profile"), form_data["flavor_profile"]), + "suggestion_role": normalize_food_role(request.form.get("suggestion_role"), form_data["suggestion_role"]), + "suggestion_priority": normalize_suggestion_priority(request.form.get("suggestion_priority"), form_data["suggestion_priority"]), + "can_be_meal_core": request.form.get("can_be_meal_core", "0") == "1", + "energy_density": normalize_energy_density(request.form.get("energy_density"), form_data["energy_density"]), + "daypart_ids": [int(value) for value in request.form.getlist("daypart_ids") if value.isdigit()], + "note": request.form.get("note", "").strip(), + } + ) + + names = list( + dict.fromkeys( + line.strip() + for line in form_data["names_text"].splitlines() + if line.strip() + ) + ) + + if not names: + flash("Bitte mindestens ein Lebensmittel eintragen.", "error") + else: + created_names: list[str] = [] + for name in names: + cursor = get_db().execute( + """ + INSERT INTO items ( + 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, availability_state, is_archived, is_quick_added, created_by, updated_by + ) + VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?, ?, ?, NULL, '', ?, ?, 'idea', 0, 1, ?, ?) + """, + ( + current_household_id(), + g.user["id"], + form_data["target_user_id"], + form_data["visibility"], + name, + "Unsortiert", + form_data["base_type"], + form_data["flavor_profile"], + form_data["suggestion_role"], + form_data["suggestion_priority"], + 1 if form_data["can_be_meal_core"] else 0, + form_data["energy_density"], + form_data["note"], + g.user["id"], + g.user["id"], + ), + ) + item_id = int(cursor.lastrowid) + sync_item_dayparts(item_id, form_data["daypart_ids"]) + created_names.append(name) + + get_db().commit() + flash(f"{len(created_names)} Lebensmittel wurden in „Unsortiert“ angelegt.", "success") + return redirect(url_for("main.item_list", kind="food", state="unsorted")) + + return render_template( + "items/quick_add.html", + form_data=form_data, + builder_options=[(key, label) for key, label in BUILDER_LABELS.items()], + food_flavor_options=FOOD_FLAVOR_OPTIONS, + food_flavor_descriptions=FOOD_FLAVOR_DESCRIPTIONS, + food_role_options=FOOD_ROLE_OPTIONS, + food_role_descriptions=FOOD_ROLE_DESCRIPTIONS, + suggestion_priority_options=SUGGESTION_PRIORITY_OPTIONS, + energy_density_options=ENERGY_DENSITY_OPTIONS, + visibility_options=VISIBILITY_FORM_OPTIONS, + target_user_options=get_target_user_options(), + dayparts=get_dayparts(), + ) + + @main_bp.route("/items//new", methods=("GET", "POST")) @login_required def item_create(kind: str): @@ -3894,6 +4047,8 @@ def item_create(kind: str): sync_meal_components(item_id, form_data["component_ids"]) get_db().commit() flash(f"{ITEM_KIND_SINGULAR_LABELS[kind]} wurde angelegt.", "success") + if wants_to_stay_on_form(): + return redirect(url_with_scroll_position(url_for("main.item_edit", item_id=item_id))) return redirect(url_for("main.item_list", kind=kind)) flash(error, "error") @@ -4025,6 +4180,8 @@ def item_edit(item_id: int): sync_meal_components(item_id, form_data["component_ids"]) get_db().commit() flash("Der Eintrag wurde aktualisiert.", "success") + if wants_to_stay_on_form(): + return redirect(url_with_scroll_position(url_for("main.item_edit", item_id=item_id))) return redirect(url_for("main.item_list", kind=item["kind"])) flash(error, "error") @@ -4040,6 +4197,13 @@ def item_add_to_shopping(item_id: int): flash(str(exc), "error") return redirect(request.referrer or url_for("main.shopping_list")) + if item.get("is_archived"): + get_db().execute( + "UPDATE items SET is_archived = 0, updated_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (g.user["id"], item_id), + ) + get_db().commit() + result = ensure_item_or_missing_components_are_shopped( item_id, g.user["id"], @@ -4068,7 +4232,15 @@ def item_set_home(item_id: int): return redirect(request.referrer or url_for("main.home_view")) get_db().execute( - "UPDATE items SET availability_state = 'home', updated_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + """ + UPDATE items + SET availability_state = 'home', + is_archived = 0, + is_quick_added = 0, + updated_by = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (g.user["id"], item_id), ) get_db().commit() @@ -4076,6 +4248,33 @@ def item_set_home(item_id: int): return redirect(request.referrer or url_for("main.home_view")) +@main_bp.post("/items//set-not-home") +@login_required +def item_set_not_home(item_id: int): + try: + item = get_item(item_id) + ensure_can_edit(item) + except (ValueError, PermissionError) as exc: + flash(str(exc), "error") + return redirect(request.referrer or url_for("main.home_view")) + + get_db().execute( + """ + UPDATE items + SET availability_state = 'idea', + is_archived = 0, + is_quick_added = 0, + updated_by = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (g.user["id"], item_id), + ) + get_db().commit() + flash(f"{item['name']} ist jetzt als nicht mehr da markiert.", "info") + return redirect(request.referrer or url_for("main.home_view")) + + @main_bp.post("/items//archive") @login_required def item_archive(item_id: int): @@ -4087,7 +4286,15 @@ def item_archive(item_id: int): return redirect(request.referrer or url_for("main.archive_view")) get_db().execute( - "UPDATE items SET availability_state = 'archived', updated_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + """ + UPDATE items + SET availability_state = 'idea', + is_archived = 1, + is_quick_added = 0, + updated_by = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (g.user["id"], item_id), ) get_db().commit() @@ -4106,7 +4313,15 @@ def item_restore(item_id: int): return redirect(request.referrer or url_for("main.archive_view")) get_db().execute( - "UPDATE items SET availability_state = 'idea', updated_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + """ + UPDATE items + SET is_archived = 0, + is_quick_added = 0, + availability_state = CASE WHEN availability_state = 'home' THEN 'home' ELSE 'idea' END, + updated_by = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (g.user["id"], item_id), ) get_db().commit() @@ -4114,6 +4329,110 @@ def item_restore(item_id: int): return redirect(request.referrer or url_for("main.archive_view")) +def mark_shopping_entry_checked(entry_id: int) -> dict: + entry = get_db().execute( + f""" + SELECT shopping_entries.*, + owner.display_name AS owner_display_name, + owner.username AS owner_username + FROM shopping_entries + LEFT JOIN users AS owner ON owner.id = shopping_entries.owner_user_id + WHERE shopping_entries.id = ? AND {visible_clause('shopping_entries')} + """, + [entry_id, *visible_params()], + ).fetchone() + if entry is None: + raise ValueError("Der Einkaufseintrag wurde nicht gefunden.") + + ensure_can_edit(describe_record(dict(entry)), "Diesen Einkaufseintrag kannst du gerade nicht ändern.") + item = get_item(entry["item_id"]) + + get_db().execute( + "UPDATE shopping_entries SET is_checked = 1, checked_at = CURRENT_TIMESTAMP, checked_by = ? WHERE id = ?", + (g.user["id"], entry_id), + ) + get_db().execute( + """ + UPDATE items + SET availability_state = 'home', + is_archived = 0, + is_quick_added = 0, + updated_by = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (g.user["id"], item["id"]), + ) + get_db().commit() + return item + + +def remove_shopping_entry(entry_id: int) -> None: + entry = get_db().execute( + f""" + SELECT shopping_entries.*, + owner.display_name AS owner_display_name, + owner.username AS owner_username + FROM shopping_entries + LEFT JOIN users AS owner ON owner.id = shopping_entries.owner_user_id + WHERE shopping_entries.id = ? AND {visible_clause('shopping_entries')} + """, + [entry_id, *visible_params()], + ).fetchone() + if entry is None: + raise ValueError("Der Eintrag wurde nicht gefunden.") + + ensure_can_edit(describe_record(dict(entry)), "Diesen Einkaufseintrag kannst du gerade nicht entfernen.") + get_db().execute("DELETE FROM shopping_entries WHERE id = ?", (entry_id,)) + get_db().commit() + + +@main_bp.post("/items//shopping/bought") +@login_required +def item_mark_bought(item_id: int): + entry = get_db().execute( + """ + SELECT id FROM shopping_entries + WHERE item_id = ? AND is_checked = 0 + ORDER BY added_at DESC + LIMIT 1 + """, + (item_id,), + ).fetchone() + if entry is None: + return redirect(request.referrer or url_for("main.shopping_list")) + try: + item = mark_shopping_entry_checked(int(entry["id"])) + except (ValueError, PermissionError) as exc: + flash(str(exc), "error") + return redirect(request.referrer or url_for("main.shopping_list")) + flash(f"{item['name']} ist jetzt als Zuhause vorhanden markiert.", "success") + return redirect(request.referrer or url_for("main.shopping_list")) + + +@main_bp.post("/items//shopping/remove") +@login_required +def item_remove_from_shopping(item_id: int): + entry = get_db().execute( + """ + SELECT id FROM shopping_entries + WHERE item_id = ? AND is_checked = 0 + ORDER BY added_at DESC + LIMIT 1 + """, + (item_id,), + ).fetchone() + if entry is None: + return redirect(request.referrer or url_for("main.shopping_list")) + try: + remove_shopping_entry(int(entry["id"])) + except (ValueError, PermissionError) as exc: + flash(str(exc), "error") + return redirect(request.referrer or url_for("main.shopping_list")) + flash("Der Eintrag wurde von der Einkaufsliste entfernt.", "info") + return redirect(request.referrer or url_for("main.shopping_list")) + + @main_bp.route("/shopping", methods=("GET", "POST")) @login_required def shopping_list(): @@ -4137,12 +4456,12 @@ def shopping_list(): flash("Dafür ist gerade nichts zusätzlich nötig.", "info") except ValueError as exc: flash(str(exc), "error") - return redirect(url_for("main.shopping_list")) + return redirect(url_with_scroll_position(url_for("main.shopping_list"))) entries = fetch_shopping_entries() upcoming_entries = fetch_upcoming_shopping_needs() - addable_items = fetch_items(include_archived=False) - addable_items = [item for item in addable_items if not item["is_on_shopping_list"]] + addable_items = fetch_items(include_archived=True, include_quick_added=True) + addable_items = [item for item in addable_items if item["kind"] == "food" and not item["is_on_shopping_list"]] household_settings = get_household_settings() shopping_weekday_label = dict(WEEKDAY_OPTIONS).get(household_settings["shopping_weekday"], "gesetzt") return render_template( @@ -4158,68 +4477,25 @@ def shopping_list(): @main_bp.post("/shopping//check") @login_required def shopping_check(entry_id: int): - entry = get_db().execute( - f""" - SELECT shopping_entries.*, - owner.display_name AS owner_display_name, - owner.username AS owner_username - FROM shopping_entries - LEFT JOIN users AS owner ON owner.id = shopping_entries.owner_user_id - WHERE shopping_entries.id = ? AND {visible_clause('shopping_entries')} - """, - [entry_id, *visible_params()], - ).fetchone() - if entry is None: - flash("Der Einkaufseintrag wurde nicht gefunden.", "error") - return redirect(url_for("main.shopping_list")) - - entry_dict = describe_record(dict(entry)) try: - ensure_can_edit(entry_dict, "Diesen Einkaufseintrag kannst du gerade nicht ändern.") - item = get_item(entry["item_id"]) + item = mark_shopping_entry_checked(entry_id) except (ValueError, PermissionError) as exc: flash(str(exc), "error") - return redirect(url_for("main.shopping_list")) - - get_db().execute( - "UPDATE shopping_entries SET is_checked = 1, checked_at = CURRENT_TIMESTAMP, checked_by = ? WHERE id = ?", - (g.user["id"], entry_id), - ) - get_db().execute( - "UPDATE items SET availability_state = 'home', updated_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", - (g.user["id"], item["id"]), - ) - get_db().commit() + return redirect(url_with_scroll_position(url_for("main.shopping_list"))) flash(f"{item['name']} ist jetzt als Zuhause vorhanden markiert.", "success") - return redirect(url_for("main.shopping_list")) + return redirect(url_with_scroll_position(url_for("main.shopping_list"))) @main_bp.post("/shopping//remove") @login_required def shopping_remove(entry_id: int): - entry = get_db().execute( - f""" - SELECT shopping_entries.*, - owner.display_name AS owner_display_name, - owner.username AS owner_username - FROM shopping_entries - LEFT JOIN users AS owner ON owner.id = shopping_entries.owner_user_id - WHERE shopping_entries.id = ? AND {visible_clause('shopping_entries')} - """, - [entry_id, *visible_params()], - ).fetchone() - if entry is None: - flash("Der Eintrag wurde nicht gefunden.", "error") - return redirect(url_for("main.shopping_list")) try: - ensure_can_edit(describe_record(dict(entry)), "Diesen Einkaufseintrag kannst du gerade nicht entfernen.") - except PermissionError as exc: + remove_shopping_entry(entry_id) + except (ValueError, PermissionError) as exc: flash(str(exc), "error") - return redirect(url_for("main.shopping_list")) - get_db().execute("DELETE FROM shopping_entries WHERE id = ?", (entry_id,)) - get_db().commit() + return redirect(url_with_scroll_position(url_for("main.shopping_list"))) flash("Der Eintrag wurde von der Einkaufsliste entfernt.", "info") - return redirect(url_for("main.shopping_list")) + return redirect(url_with_scroll_position(url_for("main.shopping_list"))) @main_bp.get("/home") diff --git a/nouri/templates/shopping/list.html b/nouri/templates/shopping/list.html index a6d4d86..73f912d 100644 --- a/nouri/templates/shopping/list.html +++ b/nouri/templates/shopping/list.html @@ -10,19 +10,68 @@
-
- {{ csrf_input() }} - + +
{% for item in addable_items %} - + {% set item_icon_class = { + 'protein': 'icon-component-protein', + 'carb': 'icon-component-carb', + 'veg': 'icon-component-veg', + 'fruit': 'icon-component-fruit', + 'dairy': 'icon-component-dairy', + 'nuts': 'icon-component-nuts', + 'seeds': 'icon-component-seeds', + 'neutral': 'icon-component-neutral', + }.get(item.primary_builder_key or item.base_type, 'icon-component-neutral') %} + + {{ csrf_input() }} + + + + {% else %} +

Gerade ist nichts zusätzlich offen.

{% endfor %} - - - +
+
{% if entries %} @@ -34,35 +83,117 @@
{% for entry in entries %} -
-
- {{ entry.item_name }} -

{{ item_kind_labels[entry.item_kind] }}

-
- {{ entry.visibility_label }} - {{ entry.owner_label }} - {{ entry.for_label }} - {% if entry.needed_for_label %} - - Für {{ entry.needed_for_label }} - {% if entry.needed_daypart_name %} · {{ entry.needed_daypart_name }}{% endif %} - - {% endif %} + {% set entry_icon_class = { + 'protein': 'icon-component-protein', + 'carb': 'icon-component-carb', + 'veg': 'icon-component-veg', + 'fruit': 'icon-component-fruit', + 'dairy': 'icon-component-dairy', + 'nuts': 'icon-component-nuts', + 'seeds': 'icon-component-seeds', + 'neutral': 'icon-component-neutral', + }.get(entry.primary_builder_key or entry.base_type, 'icon-component-neutral') %} +
+
+
+
+
+ {% if entry.photo_filename %} + + {% else %} + + + + {% endif %} +
+
+ {{ entry.item_name }} + {% if entry.needed_for_label %} +

+ Für {{ entry.needed_for_label }} + {% if entry.needed_daypart_name %} · {{ entry.needed_daypart_name }}{% endif %} +

+ {% endif %} +
+
-
-
-
- {{ csrf_input() }} - -
- {% if entry.can_edit %} -
+
+ {{ csrf_input() }} - + + +
+ {% if entry.can_edit %} +
+ {{ csrf_input() }} +
{% endif %}
+ +
+
+
+

{{ entry.item_name }}

+

+ {% if entry.needed_for_label %} + Für {{ entry.needed_for_label }} + {% if entry.needed_daypart_name %} · {{ entry.needed_daypart_name }}{% endif %} + {% elif entry.is_home %} + Zuhause vorhanden + {% else %} + Gerade nicht da + {% endif %} +

+
+ +
+
+ {% if entry.can_edit %} + Bearbeiten + {% endif %} +
+ {{ csrf_input() }} + +
+ {% if entry.can_edit %} +
+ {{ csrf_input() }} + +
+ {% if entry.is_home %} +
+ {{ csrf_input() }} + +
+ {% else %} +
+ {{ csrf_input() }} + +
+ {% endif %} +
+ {{ csrf_input() }} + +
+ {% endif %} +
+
+
{% endfor %}
{% else %}